feat: complete device onboarding and mobile binding workflows
ci / verify (push) Canceled after 0s

This commit is contained in:
Codex Local
2026-09-12 08:57:29 +08:00
parent 7846f30d0b
commit 98fb2965d0
116 changed files with 15669 additions and 64 deletions
+15
View File
@@ -106,3 +106,18 @@ ARK_CONTENT_MODEL=
ARK_REQUEST_TIMEOUT=30s
EMBEDDING_PROVIDER=local
ARK_EMBEDDING_MODEL=
# 首次认证与绑定(默认关闭;详见docs/onboarding-implementation.md)
ONBOARDING_ENABLED=false
ONBOARDING_PAUSE_CLAIMS=false
ONBOARDING_ACTIVATION_ENABLED=false
ONBOARDING_SMS_LOGIN_ENABLED=false
ONBOARDING_ALLOW_LOCAL_HTTP=false
ONBOARDING_MASTER_KEY=
ONBOARDING_MASTER_KEY_FILE=
ONBOARDING_ENTRY_URL=
ONBOARDING_TRUSTED_PROXY_CIDRS=
ONBOARDING_SELF_SIGNUP_TENANT_IDS=
ONBOARDING_SMS_GATEWAY_URL=
ONBOARDING_SMS_GATEWAY_TOKEN=
ONBOARDING_SMS_GATEWAY_TOKEN_FILE=
+6
View File
@@ -1,9 +1,15 @@
# 变更记录
## 0.81.0-rc.onboarding1 — 2026-09-12
设备C01–C11按ONBOARDING-1.1文档对齐,移除辅助流程字段;专用HTTP校时返回TIME1签名包。新设备SDK执行注册,平台提供配置、30秒许可、结果保存和首通状态;未知结果、重启及跨绑定执行受保护。迁移至58,更新绑定与运维页面,保留旧设备接入。详见docs/onboarding-release-20260912.md。
本项目使用语义化版本号。正式生产放行仍以仓库内的发布候选证据和外部门禁为准;里程碑标签只表示代码、迁移、接口和本地质量门禁形成了可追溯基线。
## 未发布
- 新增首次认证、按键绑定与独立激活流程(迁移53–57、OpenAPI 0.78.0):生产登记/加密交付/放行、DEVREQ1认证、手机OIDC或短信登录、原子绑定与双端ACK、供应商任务与人工对账、首通验收、手机页和运维入口。新开关默认关闭;旧设备协议保留。详见[实施与联调说明](docs/onboarding-implementation.md)。
- 新增V51硬件智能体License池:License明文仅在平台加密库存与设备安全区中出现,平台按Instance ID/Product Key原子分配,设备校验摘要并回执后才允许`PROVISIONING → ACTIVE`;过期、吊销、丢失和解绑会立即阻断新会话。
- 新增V50设备激活状态机:`FACTORY → CLAIMABLE → BOUND → PROVISIONING → ACTIVE → LOST/REVOKED`,认领、火山凭据预配、解绑、丢失恢复和注销统一写入带修订号的迁移审计;LOST/解绑会暂停火山设备凭据,恢复后重新启用。
- 新增独立`volc_hardware_agent`运行时和`/device/v3/agent-sessions`控制面;设备每次聊天从平台取得冻结的人设、档案、近期摘要、已确认长期记忆和授权上下文,不再复用RTC房间语义。
+18
View File
@@ -1,6 +1,24 @@
name: data-terminal-cloud
x-backend-environment: &backend-environment
# First onboarding is opt-in; existing devices retain the legacy protocol.
ONBOARDING_ENABLED: ${ONBOARDING_ENABLED:-false}
ONBOARDING_PAUSE_CLAIMS: ${ONBOARDING_PAUSE_CLAIMS:-false}
ONBOARDING_ACTIVATION_ENABLED: ${ONBOARDING_ACTIVATION_ENABLED:-false}
ONBOARDING_SMS_LOGIN_ENABLED: ${ONBOARDING_SMS_LOGIN_ENABLED:-false}
ONBOARDING_ALLOW_LOCAL_HTTP: ${ONBOARDING_ALLOW_LOCAL_HTTP:-false}
ONBOARDING_MASTER_KEY: ${ONBOARDING_MASTER_KEY:-}
ONBOARDING_ENTRY_URL: ${ONBOARDING_ENTRY_URL:-}
ONBOARDING_TRUSTED_PROXY_CIDRS: ${ONBOARDING_TRUSTED_PROXY_CIDRS:-}
ONBOARDING_SELF_SIGNUP_TENANT_IDS: ${ONBOARDING_SELF_SIGNUP_TENANT_IDS:-}
ONBOARDING_SMS_GATEWAY_URL: ${ONBOARDING_SMS_GATEWAY_URL:-}
ONBOARDING_SMS_GATEWAY_TOKEN: ${ONBOARDING_SMS_GATEWAY_TOKEN:-}
VOLC_HARDWARE_INSTANCE_ID: ${VOLC_HARDWARE_INSTANCE_ID:-}
VOLC_HARDWARE_PRODUCT_KEY: ${VOLC_HARDWARE_PRODUCT_KEY:-}
VOLC_HARDWARE_PRODUCT_SECRET: ${VOLC_HARDWARE_PRODUCT_SECRET:-}
VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY: ${VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY:-}
VOLC_HARDWARE_REGISTER_URL: ${VOLC_HARDWARE_REGISTER_URL:-https://iot-cn-shanghai.iot.volces.com/2021-12-14/DynamicRegister?Action=DynamicRegister&Version=2021-12-14}
VOLC_HARDWARE_REGISTER_TIMEOUT: ${VOLC_HARDWARE_REGISTER_TIMEOUT:-10s}
APP_ENV: local
LOG_LEVEL: debug
DATABASE_URL: postgres://voice:voice_dev_only@postgres:5432/voice_platform?sslmode=disable
+6 -1
View File
@@ -32,7 +32,7 @@ docker compose -f compose.yaml -f deploy/server/compose.override.yaml \
需要在演示机接入真实RTC、豆包语音和方舟摘要时,把凭据文件以`0600`权限放入`/etc/data-terminal-cloud/voice-runtime`,并在`deploy/server/.env`设置`RTC_TOKEN_ISSUER=volcengine`、`VOICE_PROVIDER=volcengine`、`RTC_APP_ID`、`CONTENT_GENERATOR`和`ARK_CONTENT_MODEL`。Compose只读挂载该目录,敏感值不进入源码、镜像或环境文件。
切换火山硬件对话智能体时,维护中心逐台只需把`voice_runtime`设为`volc_hardware_agent`,不填写设备级Bot。平台代设备动态注册时,在`deploy/server/.env`设置`VOLC_HARDWARE_PROVISIONING_ENABLED=true`、`VOLC_HARDWARE_INSTANCE_ID`、`VOLC_HARDWARE_PRODUCT_KEY`、`VOLC_HARDWARE_AGENT_BOT_ID`、`VOLC_HARDWARE_AGENT_TRANSPORT`、`VOLC_HARDWARE_AGENT_MINIMUM_SDK_VERSION`、`VOLC_HARDWARE_PRODUCT_SECRET_FILE=/run/secrets/voice-runtime/volc-hardware-product-secret`和独立随机的`VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY`;将Product Secret以单行、`0600`权限写入`/etc/data-terminal-cloud/voice-runtime/volc-hardware-product-secret`。Product Secret不得进入`.env`、源码、镜像、数据库或设备。设备首次预配时,平台根据这些产品默认值自动创建火山绑定并调用DynamicRegister;火山从产品额度中分配License,平台只加密保存并下发本设备的一机一密Device Secret,不下发License明文。发布前必须验证平台代理注册、默认Bot绑定、每次会话上下文注入、`session.updated`确认后激活、`session_updated`存活上报、`stop_session`执行和最终字幕补传。
切换火山硬件对话智能体时,维护中心逐台只需把`voice_runtime`设为`volc_hardware_agent`,不填写设备级Bot。平台代设备动态注册时,在`deploy/server/.env`设置`VOLC_HARDWARE_PROVISIONING_ENABLED=true`、`VOLC_HARDWARE_INSTANCE_ID`、`VOLC_HARDWARE_PRODUCT_KEY`、`VOLC_HARDWARE_AGENT_BOT_ID`、`VOLC_HARDWARE_AGENT_TRANSPORT`、`VOLC_HARDWARE_AGENT_MINIMUM_SDK_VERSION`、`VOLC_HARDWARE_PRODUCT_SECRET_FILE=/run/secrets/voice-runtime/volc-hardware-product-secret`和独立随机的`VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY`;将Product Secret以单行、`0600`权限写入`/etc/data-terminal-cloud/voice-runtime/volc-hardware-product-secret`。旧设备代理注册流程的Product Secret不下发给设备;新设备ONBOARDING-1.1按附件E.2通过认证后的HTTPS加密配置下发。Product Secret不得进入`.env`、源码、日志或镜像。设备首次预配时,平台根据这些产品默认值自动创建火山绑定并调用DynamicRegister;火山从产品额度中分配License,平台只加密保存并下发本设备的一机一密Device Secret,不下发License明文。发布前必须验证平台代理注册、默认Bot绑定、每次会话上下文注入、`session.updated`确认后激活、`session_updated`存活上报、`stop_session`执行和最终字幕补传。
```sh
sudo install -d -m 0700 /etc/data-terminal-cloud/voice-runtime
@@ -90,3 +90,8 @@ sudo deploy/server/install-offsite-backup.sh
```
安装后默认为禁用状态,不会访问OSS。默认认证方式为`ecs_ram_role`,使用`cn-hangzhou`地域端点并强制先通过IMDSv2确认实例角色,不保存长期AccessKey。创建私有Bucket并绑定最小权限ECS RAM角色后,在`/etc/data-terminal-cloud/offsite-backup.env`填写Bucket和前缀、设置`OFFSITE_BACKUP_ENABLED=true`与`OFFSITE_BACKUP_REQUIRED=true`,再手工启动一次`platform-offsite-backup.service`。任务会上传加密归档及摘要、重新下载并复算SHA-256;健康检查在启用后强制要求最新本机归档已有对应远端验证。配置和真实验收步骤见[《阿里云OSS异地备份适配与门禁》](../../docs/offsite-backup.md)。
## 首次设备认证与绑定(2026-09-12)
新设备使用ONBOARDING-1.1。叠加`deploy/server/compose.onboarding.yaml`,设置ONBOARDING_RELEASE_VERSION与实际网关网段ONBOARDING_TRUSTED_PROXY_CIDRS;主密钥使用服务器`/etc/data-terminal-cloud/voice-runtime/onboarding-master-key`。先备份再迁移至58,更新API、worker和web,并重建gateway使新的Nginx挂载生效。仅HTTP `POST /api/v1/device/time-challenges`直达API;其余HTTP继续跳转HTTPS。详细配置与验证见[实施说明](../../docs/onboarding-implementation.md)。
+32
View File
@@ -0,0 +1,32 @@
# Add after compose.override.yaml and the server's existing compose.subtitle.yaml.
x-onboarding-runtime: &onboarding-runtime
ONBOARDING_ENABLED: ${ONBOARDING_ENABLED:-true}
ONBOARDING_ACTIVATION_ENABLED: ${ONBOARDING_ACTIVATION_ENABLED:-true}
ONBOARDING_PAUSE_CLAIMS: ${ONBOARDING_PAUSE_CLAIMS:-false}
ONBOARDING_SMS_LOGIN_ENABLED: ${ONBOARDING_SMS_LOGIN_ENABLED:-false}
ONBOARDING_ALLOW_LOCAL_HTTP: "false"
ONBOARDING_MASTER_KEY: ""
ONBOARDING_MASTER_KEY_FILE: /run/secrets/voice-runtime/onboarding-master-key
ONBOARDING_ENTRY_URL: ${PUBLIC_ORIGIN}/onboarding/entry
ONBOARDING_TRUSTED_PROXY_CIDRS: ${ONBOARDING_TRUSTED_PROXY_CIDRS:?set the gateway Docker subnet}
ONBOARDING_SELF_SIGNUP_TENANT_IDS: ${ONBOARDING_SELF_SIGNUP_TENANT_IDS:-}
VOLC_HARDWARE_INSTANCE_ID: ${VOLC_HARDWARE_INSTANCE_ID}
VOLC_HARDWARE_PRODUCT_KEY: ${VOLC_HARDWARE_PRODUCT_KEY}
VOLC_HARDWARE_AGENT_BOT_ID: ${VOLC_HARDWARE_AGENT_BOT_ID}
VOLC_HARDWARE_AGENT_TRANSPORT: ${VOLC_HARDWARE_AGENT_TRANSPORT:-low_load}
VOLC_HARDWARE_AGENT_MINIMUM_SDK_VERSION: ${VOLC_HARDWARE_AGENT_MINIMUM_SDK_VERSION:-1.0.0}
VOLC_HARDWARE_PRODUCT_SECRET: ""
VOLC_HARDWARE_PRODUCT_SECRET_FILE: ${VOLC_HARDWARE_PRODUCT_SECRET_FILE}
VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY: ${VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY}
services:
api:
image: data-terminal-cloud-backend:${ONBOARDING_RELEASE_VERSION:?set release version}
environment:
<<: *onboarding-runtime
worker:
image: data-terminal-cloud-backend:${ONBOARDING_RELEASE_VERSION:?set release version}
environment:
<<: *onboarding-runtime
web:
image: data-terminal-cloud-web:${ONBOARDING_RELEASE_VERSION:?set release version}
+15 -1
View File
@@ -1,7 +1,21 @@
server {
listen 80;
server_name _;
return 301 https://121.41.46.238$request_uri;
# First-boot clock bootstrap only. The response is authenticated by TIME1.
location = /api/v1/device/time-challenges {
limit_except POST { deny all; }
client_max_body_size 8k;
resolver 127.0.0.11 ipv6=off valid=10s;
set $clock_backend http://api:8080;
proxy_pass $clock_backend;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto http;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off;
}
location / {
return 301 https://121.41.46.238$request_uri;
}
}
server {
@@ -0,0 +1,187 @@
# AI电话 v3.1 流程与现有实现差异分析
核对日期:2026-09-10。结论:**总体方向相近,但现有实现不等同于 CALL-MEMORY-1.1。差异涉及协议、固件运行方式和记忆数据维护,不能仅提取新文档接口就直接交给设备联调。**
对照材料为用户提供的 [AI电话完整通话与记忆管理文档](D:/wechat/xwechat_files/wangcong3092_983b/msg/file/2026-09/AI电话_完整通话与记忆管理_完整流程与详细设计(1).docx),封面版本 v3.1,修订日期 2026-09-08。文档作为需求基线,不作为执行其中开发、发布或运维指令的授权。
实现基线为 `E:/Codex/story/platform` 当前工作区,Git HEAD 为 `7846f30d0b239e183267988aa6e030b0a2f1ef18`。重点检查 Go 后端路由与业务服务、数据库迁移、EchoEar 固件、记忆页面代码和已有联调记录。结论针对本地实现;本次未连接生产环境、重新运行真机或供应商联调,不能据此推定线上部署与工作区完全相同。
## 文档描述的完整流程
1. **上电准备**:恢复已有设备身份、绑定和未确认结束日志;联网后 warmup,预备音频及引擎。挂机时不建立真实对话、不上传环境声。SaaS 在后台同步记忆并准备开场候选。
2. **摘机准备**:生成 boot/hook/call_generation 关联;prepare 从服务端绑定确定使用者,检查记忆主库版本、关键删除或更正是否已在检索副本生效。
3. **签发与启动**:SaaS 编译供应商参数,签发绑定设备、本次摘机、隐私版本、启动期限和租约的 Session Manifest。设备验签后立即开始心跳,透传参数,以 RTC 模式启动 SDK。
4. **连续通话**:声音由设备直接与火山交互。媒体连接、收到第一帧、实际开始播放分别记录。字幕经异步队列上报;历史记忆由经过验证的火山原生 Viking 联动读取。
5. **通话中修改记忆**:SaaS 从可靠的用户最终文字识别记住、更正、限制使用、删除;对象不清进入页面确认。提交主库后分别跟踪副本、派生内容和旧会话清理,必要时下发 STOP_CALL。
6. **挂机收尾**:本地先关闭上下行与待播音频,再停止 SDK、补报 end。SaaS 将通话结束和字幕完整度分开记录;字幕静默收齐初值 15 秒、绝对等待 60 秒。
7. **维护记忆与准备下一通**:从用户证据提取候选,处理冲突和删除屏障,提交 SaaS 主库与 outbox;按主记录版本同步 Viking 事件及画像,验证检索可见性,再推进连续同步进度。下次摘机重新检查。
文档强调四个结果分别成立:**通话结束、字幕收齐、记忆保存、检索可见**。普通新增同步慢可以按安全模式继续;更正、删除和权限变化不能沿用不安全的旧资料。
## 现有流程
当前 EchoEar 主路径为:
```text
上电恢复平台及火山凭据,尝试补报上次未结束会话,初始化音频与引擎
→ 触摸或唤醒触发对话
→ bootstrap + config + POST /device/v3/agent-sessions
→ 后端冻结使用者、授权和上下文,返回 runtime + session_config
→ 固件组装 session.update,使用 VOLC_MODE_WS 启动
→ session.created/updated 后上报 session_started
→ 通过 session_updated 周期续期,异步上传最终字幕
→ volc_stop + session_ended/session_failed,清理播放器
→ SaaS 摘要/故事/候选处理
→ 另一路将通话文字 AddSession 到 Viking 自动抽取
```
入口依据:[固件创建会话](E:/Codex/story/platform/firmware/echoear-platform-agent/main/platform_client.c:558)、[SDK 启动与退出](E:/Codex/story/platform/firmware/echoear-platform-agent/components/application/service/conv_service/src/volc_conv.c:389)、[后台任务装配](E:/Codex/story/platform/internal/app/app.go:427)。
## 可以复用的实现
| 能力 | 已有实现 | 对齐程度 |
|---|---|---|
| 设备认证与服务端确定使用者 | HMAC、防重放、设备有效绑定;会话保存 subject_id 和授权快照 | 方向一致,认证报文及绑定代数协议不同 |
| 音频与业务链路分开 | 设备直接调用火山 SDK,平台接收控制信息与文字 | 方向一致,EchoEar 实际使用 WS |
| 共享助手和用户记忆范围 | Bot 配置、稳定助手标识、租户与使用者派生的 Viking user_id | 已有基础,不能替代供应商侧防篡改授权验证 |
| 异步文字上报 | SDK 回调投递内存队列,业务任务发 HTTPS,后端事务保存并去重 | 主思路一致,缺修订、缺口和持久重试协议 |
| 结束恢复 | NVS 保存待结束会话,重启补报;后端支持结束状态及迟到最终文字 | 已有基础,缺未取得 SID 的取消及独立远端停止证明 |
| 后台摘要与人工记忆维护 | 用户文字来源、摘要、故事、候选、人工确认、记忆版本、编辑/停用/删除、索引 outbox | 已有基础,与 Viking 自动抽取副本未统一 |
证据:[会话归属与上下文快照](E:/Codex/story/platform/internal/orchestration/repository.go:293)、[字幕保存与原会话归属](E:/Codex/story/platform/internal/orchestration/transcript_batch.go:127)、[仅用户文字参与抽取](E:/Codex/story/platform/internal/extraction/processor.go:94)、[人工记忆维护](E:/Codex/story/platform/internal/saas/content_service.go:568)。
## 关键差异及影响
### 1 设备接口与认证不兼容
文档附件 B 定义七个 `POST /api/v1/device/calls/*` 接口;当前注册的是 `/device/v2/*` 和 `/device/v3/agent-sessions*`。这七个新路径没有注册。
认证也不只是换路径:
| 项目 | v3.1 文档 | 当前实现 |
|---|---|---|
| 时间 | `X-Timestamp-Ms`,Unix 毫秒 | `X-Device-Timestamp`,RFC3339 |
| 签名入口 | `Authorization: Device-HMAC-SHA256 ...` | `X-Device-Signature` |
| 签名内容 | DEVREQ1 开头的九行,含设备、密钥版本、请求 ID | 方法、含查询串的 RequestURI、时间、nonce、body hash 五行 |
| 签名编码 | 小写十六进制 | 无填充 Base64url |
| 响应 | `ok/code/request_id/server_time_ms/data` | `request_id/data/error` |
现有 HMAC 本身已经实现,但不兼容文档的线协议。直接按文档开发新固件会遇到路由或验签失败。
证据:[设备路由](E:/Codex/story/platform/internal/httpapi/router.go:308)、[认证头及验签](E:/Codex/story/platform/internal/deviceauth/verifier.go:22)、[当前签名算法](E:/Codex/story/platform/internal/deviceauth/verifier.go:142)、[响应封装](E:/Codex/story/platform/internal/httpapi/response.go:8)。
### 2 固件实际使用 WS 而文档固定 RTC
文档附件 C 要求 `mode=RTC`,映射 `VOLC_MODE_RTC`。当前 EchoEar 能力声明只有 `low_load`,实际 `volc_start` 参数写的是 `VOLC_MODE_WS`。
后端保留 high_quality/low_load 两类能力,并不等于当前固件已经按 RTC 流程运行。切换需要核对 RTC 参数通道、字幕格式、首音频、打断和 stop 行为,不能只改枚举后视为完成。
证据:[固件能力声明](E:/Codex/story/platform/firmware/echoear-platform-agent/main/platform_client.c:569)、[实际 SDK 模式](E:/Codex/story/platform/firmware/echoear-platform-agent/components/application/service/conv_service/src/volc_conv.c:439)、[后端传输校验](E:/Codex/story/platform/internal/orchestration/repository.go:614)。
### 3 没有新版会话清单及摘机取消机制
文档要求 `boot_id + hook_event_id` 唯一业务键,独立 `call_generation`、`binding_generation`、`media_attempt_id`,以及 `manifest_b64/manifest_mac`。即使 end 先于 prepare 到达,也要保存取消记录,阻止晚到 prepare 启动。
当前创建请求使用 `Idempotency-Key`,返回 `session_nonce/config_digest/runtime/session_config`;具有请求幂等和配置摘要,但没有上述清单结构、按摘机取消、清单撤销及代数校验。当前摘要也不是文档要求的清单 HMAC。尚未取得 conversation_id 时没有等价的结束入口。
证据:[请求与响应结构](E:/Codex/story/platform/internal/orchestration/types.go:95)、[创建幂等入口](E:/Codex/story/platform/internal/orchestration/hardware_agent.go:31)、[固件响应处理](E:/Codex/story/platform/firmware/echoear-platform-agent/main/platform_client.c:584)。
### 4 心跳起点与本地租约停止不同
文档要求验清单后立即开始心跳,与 SDK 启动并行;默认周期 5 秒、租约不超过 45 秒。失联时固件依本地保守截止关闭音频;迟到许可不能复活会话。
当前固件在上报 session_started 后设置下一次心跳,通过 `session_updated` 续期。默认周期为 15 秒;后端租约按 `HeartbeatInterval × 5` 计算,默认对应 75 秒。心跳网络失败时固件记录错误并再次尝试,没有看到该路径维护本地授权截止或到期静音。
这意味着现有服务端超时回收不能等同于文档要求的设备本地有界停止。
证据:[固件续期逻辑](E:/Codex/story/platform/firmware/echoear-platform-agent/main/platform_client.c:693)、[启动成功后才轮询](E:/Codex/story/platform/firmware/echoear-platform-agent/components/application/service/conv_service/src/volc_conv.c:459)、[后端租约](E:/Codex/story/platform/internal/orchestration/hardware_agent.go:205)、[默认周期](E:/Codex/story/platform/internal/config/config.go:479)。
### 5 供应商参数由固件拼装且开场配置未形成文档流程
文档要求 SaaS 基于已验证 provider_profile 编译完整 `vendor_params_json`,设备只透传;热上下文与欢迎语分别产生,且附事实依赖版本。
目前后端返回 authorized_context 等结构化字段,固件自行拼接固定话术、LLMConfig、S2SConfig、MemoryConfig 与回调参数。平台虽然有资料、最近摘要、open_topic 和上下文快照,但未形成独立欢迎语、开场候选依赖失效及 provider_profile 能力证据驱动的编译流程。
当前上下文对部分字段按字符数裁剪,也未体现文档要求的按完整事实删减并保留否定、日期和条件的规则。
证据:[端侧参数组装](E:/Codex/story/platform/firmware/echoear-platform-agent/main/platform_client.c:457)、[上下文与摘要选择](E:/Codex/story/platform/internal/orchestration/repository.go:388)、[开场续聊与裁剪](E:/Codex/story/platform/internal/orchestration/companion_context.go:19)。
### 6 Viking 写入路线与文档有实质冲突
文档第 11 章要求 SaaS 是记忆事实维护方,Viking 保存已核验主库记录的检索副本。默认不让两边根据原始字幕各自抽取和维护同一份记忆。
当前工作器在内容抽取任务中先排入独立 Viking 同步任务;Viking 服务读取 conversation_turn 中的用户和 AI 最终文字,再调用 `/api/memory/session/add`,并设置 `extract_memory_type` 让 Viking 抽取事件及画像。这条任务不以 SaaS curated_memory 提交完成为前置条件。
因此当前是“平台内容整理 + Viking 会话自动抽取”两条路径。它不能保证平台更正、停用或删除某条人工记忆后,Viking 独立生成的事件和画像同步发生相同变化。
证据:[工作器装配](E:/Codex/story/platform/internal/app/app.go:427)、[同步数据取自通话文字](E:/Codex/story/platform/internal/vikingmemory/service.go:175)、[AddSession 及抽取类型](E:/Codex/story/platform/internal/vikingmemory/client.go:127)。
### 7 单条记忆修改删除未贯通 Viking 及旧会话
当前平台已经有 `UpdateMemory/RequestMemoryDeletion`、版本比较、本地索引失效和删除任务。另有 `providermemory` 适配器,调用自定义 `/v1/memories:upsert`,它与 `vikingmemory` 是独立模块,不能把前者的完成视为 Viking 原生事件/画像已清理。
已有接入说明明确指出:单条本地 curated memory 删除不会删除独立抽取的 Viking 事件/画像,统一管理界面尚未实现。文档要求的 source_suppression、单条投影映射、派生摘要清理及防旧源重抽取尚未贯通。
此外,本次在相关源码和迁移中没有找到 `privacy_epoch`、已签发清单撤销目标、对应 `STOP_CALL` 及分阶段本地静音/SDK 停止回执。已有通话停止和授权撤回能力不能替代文档附件 L 的完整机制。
证据:[单条删除入口](E:/Codex/story/platform/internal/saas/content_service.go:705)、[本地删除处理](E:/Codex/story/platform/internal/memorylifecycle/delete_processor.go:32)、[独立 provider 适配器](E:/Codex/story/platform/internal/providermemory/client.go:59)、[Viking 已知限制](E:/Codex/story/platform/docs/viking-memory-integration.md:23)。
### 8 没有通话前同步水位与关键变更拦截
文档要求用户级 memory_revision、viking_visible_revision、关键积压和 EMPTY/IN_SYNC/LAGGING_NORMAL/LAGGING_CRITICAL 等状态。供应商写入成功后还要验证检索可见,且中间版本失败不能被后续成功版本跳过。
当前 NativeAccessForDevice 主要检查当前绑定和授权,再返回用户过滤配置;AddSession 成功后记录审计摘要。未看到针对主库记忆版本的连续可见进度、关键删除拦截、NATIVE_VIKING/HOT_ONLY/OFF 模式及关闭旧记忆的验证机制。Viking 获取配置失败当前可仅记录警告后继续创建,这与按关键积压判断是否允许启动不同。
证据:[创建时 Viking 处理](E:/Codex/story/platform/internal/orchestration/hardware_agent.go:46)、[原生范围配置](E:/Codex/story/platform/internal/vikingmemory/service.go:84)、[写入与审计](E:/Codex/story/platform/internal/vikingmemory/service.go:208)。
### 9 字幕可靠性及结束状态未达到新版要求
已实现最终字幕上报、事务落库、重复检测和结束后补入,但当前 turn_key/sequence_no 的同一条内容变化会冲突;没有文档要求的 utterance_id/source_revision 修订和分片结构。
固件当前使用 8 项内存消息队列,满时记日志;上报失败后释放消息,未看到对应的持久重试与 GAP_DECLARED。端侧上报把 interrupted 固定为 false,不能据此证明 AI 的整句话已播放。
后端 session_ended 会直接完成业务状态并排入抽取,没有独立 DRAINING/COMPLETE/PARTIAL 状态及文档的 15/60 秒收齐窗口。固件退出时先设置 is_stop,但播放器停止在 SDK stop 和网络结束上报之后,未实现文档要求的先立即清空待播内容、分别报告停止证据的顺序。
证据:[字幕结构与冲突规则](E:/Codex/story/platform/internal/orchestration/transcript_batch.go:24)、[端侧上报与失败处理](E:/Codex/story/platform/firmware/echoear-platform-agent/main/platform_client.c:713)、[队列及满队列处理](E:/Codex/story/platform/firmware/echoear-platform-agent/components/application/service/conv_service/src/volc_conv.c:330)、[结束与抽取](E:/Codex/story/platform/internal/orchestration/hardware_agent.go:214)、[退出顺序](E:/Codex/story/platform/firmware/echoear-platform-agent/components/application/service/conv_service/src/volc_conv.c:483)。
### 10 语音记忆变更确认与事项进度需要补充
当前人工记忆编辑能力不等同于文档第 8 章:尚未找到从用户最终文字生成 intent/mutation、来源动作去重、NEEDS_CONFIRMATION、resolve/confirm 冻结方案、六阶段处理进度、PLAY_LOCAL_NOTICE 的整套实现。
现有 open_topic 是摘要中的续聊线索;没有文档的稳定 OPEN_ITEM 标识、OPEN/COMPLETED/CANCELLED/UNKNOWN 及计划修订状态机。因此“第一通提计划、第二通报告完成、第三通不再追问”的结果尚不能由当前数据流程保证。
证据:[现有记忆页面 API](E:/Codex/story/platform/internal/httpapi/router.go:267)、[现有摘要与 open_topic](E:/Codex/story/platform/internal/extraction/processor.go:284)、[续聊线索选择](E:/Codex/story/platform/internal/orchestration/companion_context.go:70)。
## 设备接口对应关系
下表只用于说明差异,**不是 CALL-MEMORY-1.1 已实现接口文档**。文档七个接口均为 POST,统一前缀 `/api/v1/device/calls/`。当前对应功能不代表字段、鉴权、幂等和状态语义兼容。
| 文档接口 | 当前近似入口或调用 | 主要缺口 |
|---|---|---|
| CA01 `warmup` | `GET /device/v2/bootstrap`、`GET /device/v2/config` | 专门 warmup、绑定代数和 provider_profile 准备状态 |
| CA02 `prepare` | `POST /device/v3/agent-sessions` | 摘机唯一键、签名清单、记忆进度校验、取消先到处理 |
| CA03 `events` | `POST /device/v3/agent-sessions/{id}/events` 及 `/{id}/turns:batch` | 统一事件批次、逐条 ACK、连续序号、字幕修订/缺口/播放事件 |
| CA04 `heartbeat` | EchoEar 发送 `session_updated` 到 v3 events;后端另有 v2 conversations heartbeat | 清单后立即心跳、manifest/media/privacy 绑定、绝对租约、本地到期关闭 |
| CA05 `end` | EchoEar 发送 `session_ended/session_failed`;后端另有 `POST /device/v2/conversations/{id}/stop` | 无 SID 取消、原摘机标识、local_audio_stopped、独立 sdk_stop_status/remote_state |
| CA06 `status` | `GET /device/v2/conversations/{id}` | boot/hook 查询、清单状态、字幕状态、隐私版本及命令 |
| CA07 `command-acks` | `POST /device/v2/commands/{id}/ack` | 当前通话代数、MANIFEST_DISCARDED/LOCAL_AUDIO_OFF/SDK_STOPPED 与提示回执 |
当前已注册入口见 [router.go](E:/Codex/story/platform/internal/httpapi/router.go:308)。EchoEar 真实调用需以 [platform_client.c](E:/Codex/story/platform/firmware/echoear-platform-agent/main/platform_client.c:611) 为准,不能把后端存在的 v2 能力自动算成当前固件已使用。
## 建议的对齐顺序
1. **先冻结设备契约与真实供应商组合**:明确按文档 RTC 路线升级;核验动态上下文、原生检索、关闭路径、字幕归属和用户范围授权。文档附件 H 本身也把这些列为待实测能力,不能因代码中存在 MemoryConfig 字段就判定通过。
2. **补齐启动与撤销协议**:设备 HMAC 契约、warmup/prepare、摘机幂等与取消、Session Manifest、双代数、媒体尝试、首次心跳、本地租约、STOP_CALL 和停止证明。
3. **统一记忆主库和 Viking 投影**:以 SaaS 主记录为维护入口,补逐条映射、版本、关键积压、可见性、删除防复活及旧会话撤销;解决当前独立 AddSession 抽取与人工维护脱节。
4. **完善记录和管理交互**:字幕修订与持久重试、收齐状态、变更确认及阶段进度、固定提示能力、OPEN_ITEM 与开场候选失效。
5. **再形成正式设备接口交付件**:同步 OpenAPI、设备请求/响应、签名测试向量、重试/错误规则、事件/命令回执和真实板卡联调结果。
优先回归文档 T04~T07、T12~T14、T20~T31、T39~T48;供应商验证重点为 H02/H03/H05/H06/H12。此处是建议验收范围,不表示本次已执行或通过。
## 核对方法和边界
- 完整读取了 Word 正文、表格及附件 A~L,并查看了全部 10 张内嵌流程图。
- 使用实际路由、结构体、调用顺序和持久化代码验证主要差异,并检索相关后端、迁移、固件及前端是否存在新版核心协议字段。
- 现有运行记录作为历史证据;其中明确保留了真机重启后实际回忆等待验证项。本次没有把旧记录扩大为新版本验收结论。
- 当前环境的文档渲染器缺少 `soffice.exe`,未取得可核验页码,因此文档依据采用章节/附件定位;未改写或重新导出用户的 Word 文件。
- 本次只新增这份分析,不修改业务代码;未运行生产调用、设备刷写或发布操作。已有工作区修改保留。
+27
View File
@@ -0,0 +1,27 @@
# 用户端手机适配
适配范围为 `CustomerPortal` 的全部 11 个页面:首页、聊天记录、故事与记忆、长者资料、提醒与待办、家庭管理、陪伴设备、内容分享、数据与授权、服务用量、设置与权限,以及这些页面的资料编辑、邀请、设备配置、审核和授权弹窗。
- 900px 及以下使用抽屉导航,支持搜索、遮罩关闭、Escape、关闭后恢复焦点和窗口尺寸变化。桌面仍使用侧栏。
- 600px 及以下将表单、列表操作和详情改为单列,长名称和资源编号可换行。家庭切换、年度活动日历与权限表在各自区域内横向滚动,手机日历默认定位到最近日期。
- 聊天采用列表与摘要上下排列,选中后定位摘要,可返回聊天列表;手机上的“更多筛选”保留语音方式和起止日期筛选。
- 表单控件使用适合触屏的尺寸,手机输入文字至少为 16px;年度日历日期格为 24px。弹窗使用动态视口高度和安全区域边距,长表单可以滚动到底部。
- 手机覆盖规则集中在 `web/src/customer-responsive.css`,在现有主题之后加载,限定于用户端。
## 回归检查
在 `web` 目录执行 `npm run dev -- --host 127.0.0.1`。在项目根目录使用已安装的 Playwright 运行:
```powershell
$env:PLAYWRIGHT_REQUIRE_FROM = '包含 Playwright 依赖的目录/package.json'
$env:VISUAL_WEB_URL = 'http://127.0.0.1:5173'
node test/e2e/visual-customer-mobile.mjs
```
默认使用本机 Google Chrome;可通过 `CHROME_PATH` 指定浏览器可执行文件。未安装 Playwright 时,可先用 `npm install --prefix .tmp-mobile-qa --no-audit --no-fund playwright` 安装到忽略的临时目录,再把 `PLAYWRIGHT_REQUIRE_FROM` 设置为该目录下 `package.json` 的绝对路径。
脚本覆盖 320、375、390、430、768、900、1024、1440px 的所有页面,以及横屏、登录、空数据、加载失败、短视口表单、导航交互和主要操作弹窗。`MOBILE_WIDTHS` 可指定逗号分隔的宽度;`MOBILE_INTERACTIONS_ONLY=1` 只执行对应宽度的交互检查。截图和检查结果保存在 `.design-evidence/customer-mobile/`。
测试使用合成家庭数据拦截全部业务 API,非 GET 请求会直接失败,不依赖后端或修改业务数据。该检查验证布局和前端交互,不代替真实身份认证、后端业务验收或 iPhone Safari / Android 真机软键盘测试。
构建检查:在 `web` 目录执行 `npm run build`。
+6 -1
View File
@@ -1,7 +1,7 @@
# 设备 API 兼容性约定
> 当前设备接口主版本:`/device/v2`
> 当前接口契约:OpenAPI 0.77.0
> 当前接口契约:OpenAPI 0.78.0
> 适用对象:云端 API、终端固件、模拟终端和设备联调工具
## 1. 兼容目标
@@ -46,6 +46,7 @@ CI在拉取请求中以目标分支OpenAPI为基线执行破坏性变更检查
| OpenAPI版本 | 设备兼容变更 | 兼容结论 |
|---|---|---|
| 0.78.0 | 新增独立 `/api/v1/device/*` 首次接入协议;physical_confirm设备使用DEVREQ1签名和v3首通许可;旧设备的HMAC、v2路径和一键认领规则保留 | 新库存显式选择新协议;旧设备兼容 |
| 0.77.0 | 新增v3平台代注册端点;Windows硬件智能体客户端不再接收Product Secret,既有v2接口不变 | v2完全兼容;硬件v3客户端升级 |
| 0.68.0 | 生产基础设施状态增加实际应用运行环境与身份服务模式证明;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 |
| 0.67.0 | 发布候选快照增加生产基础设施强制门禁及主机部署模式、异地备份、外部告警、模拟服务状态;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 |
@@ -79,3 +80,7 @@ CI在拉取请求中以目标分支OpenAPI为基线执行破坏性变更检查
| 0.34.0 | 增加短期RTC凭证续签动作 | 由0.41.0补充能力协商,消除旧终端收到未知动作的风险 |
每次OpenAPI版本更新都必须同步本表;设备端破坏性变更还必须给出v2保留期限、v3升级方式、灰度范围和回滚条件。
## 首次接入协议隔离
`ONBOARDING-1.2-SAAS` 仅适用于生产登记为 `physical_confirm` 的新设备,不依靠固件版本猜测协议。工装写入该契约标识、物理编号、raw32字节认证密钥和版本。新设备调用 `/device/v3/agent-sessions`;同一路径新旧认证由已登记身份区分,认证失败不降级。旧 `/device/v2/conversations` 创建不向新设备开放。新设备恢复出厂前须授权解绑,重置响应不含旧claim码。
+412
View File
@@ -0,0 +1,412 @@
# 首次接入接口字段清单
由 `node scripts/generate-onboarding-contract.mjs` 根据实际Go请求结构生成。共 31 项操作。完整响应结构见 [OpenAPI](../openapi/onboarding.yaml),流程、签名、错误恢复见 [实施说明](onboarding-implementation.md)。
## C01 校时挑战
`POST /api/v1/device/time-challenges`
调用身份:校时请求,无请求签名;设备必须核验TIME1响应MAC
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| device_id | string | 是 | 生产编号,KP01-加12位大写MAC;不是平台UUID |
| key_version | integer | 是 | 设备认证密钥版本 |
| boot_id | string | 是 | 每次启动生成并保持的UUID v4 |
| client_nonce | string | 是 | 设备生成的16字节随机数,Base64URL无填充 |
响应:根对象直接返回 `{payload_b64,mac_hex}`。
## C02 首次认证和启动注册
`POST /api/v1/device/register`
调用身份:设备DEVREQ1签名
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| challenge_id | string | 是 | C01签名校时返回的一次性挑战 |
| boot_id | string | 是 | 每次启动生成并保持的UUID v4 |
| hardware_model | string | 是 | 生产登记型号,当前为KP01 |
| hardware_revision | string | 是 | 硬件修订号,注册必须与库存一致 |
| firmware_version | string | 是 | 固件版本,用于兼容诊断 |
| sdk_version | string | 是 | SDK版本,用于兼容诊断 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `RegisterResponse.data`。
## C03 创建临时设置会话
`POST /api/v1/device/setup-sessions`
调用身份:设备DEVREQ1签名
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| boot_id | string | 是 | 每次启动生成并保持的UUID v4 |
| local_setup_id | string | 是 | 设备本次设置入口UUID v4;重试保持不变 |
| transport_profile | string | 是 | 固定LOCAL_WEB,表示设备提供本地网页入口 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `SetupResponse.data`。
## C04 获取一次性手机入口
`POST /api/v1/device/handoffs`
调用身份:设备DEVREQ1签名
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| setup_id | string | 是 | 云端15分钟临时设置会话UUID |
| local_setup_id | string | 是 | 设备本次设置入口UUID v4;重试保持不变 |
| code_challenge | string | 是 | SHA256(ASCII verifier)的Base64URL无填充值 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `HandoffResponse.data`。
## C05 查询状态和命令
`POST /api/v1/device/poll`
调用身份:设备DEVREQ1签名
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| setup_id | string | 是 | 云端15分钟临时设置会话UUID |
| boot_id | string | 是 | 每次启动生成并保持的UUID v4 |
| known_revision | integer | 是 | 设备已知状态版本;允许0 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `PollResponse.data`。
## C06 物理按键确认绑定
`POST /api/v1/device/claim-confirmations`
调用身份:设备DEVREQ1签名
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| claim_id | string | 是 | 待物理确认的绑定申请UUID |
| challenge | string | 是 | C05给设备的短时按键挑战,手机不能获取 |
| binding_digest | string | 是 | 云端生成的绑定摘要,按键回报原值 |
| expected_binding_generation | integer | 是 | 提交时预期绑定代数,防止跨解绑或重新绑定重放 |
| press_event_id | string | 是 | 该次真实物理按键事件UUID v4,重试保持不变 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `ConfirmationResponse.data`。
## C07 绑定状态持久化回执
`POST /api/v1/device/state-acks`
调用身份:设备DEVREQ1签名
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| device_revision | integer | 是 | 云端设备状态版本号 |
| binding_generation | integer | 是 | 绑定代数;解绑、重新绑定后旧代数无效 |
| local_generation | integer | 是 | 本次启动的本地持久化序号,不能倒退 |
| state_hash | string | 是 | STATE1规范字符串的SHA256小写十六进制摘要 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `StateAckResponse.data`。
## C08 申请设备执行许可
`POST /api/v1/device/activation-attempts`
调用身份:设备DEVREQ1签名
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| activation_id | string | 是 | 该物理设备唯一的激活任务UUID |
| config_version | integer | 是 | 配置版本,必须与当前云端版本一致 |
| attempt_id | string | 是 | 设备生成的UUID v4,表示一次引擎初始化或首通执行 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `AttemptResponse.data`。
## C09 回报设备执行结果
`POST /api/v1/device/activation-results`
调用身份:设备DEVREQ1签名
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| activation_id | string | 是 | 该物理设备唯一的激活任务UUID |
| attempt_id | string | 是 | 设备生成的UUID v4,表示一次引擎初始化或首通执行 |
| stage | string | 是 | SDK_REGISTER或CONVERSATION_TEST;与C08平台当前阶段一致 |
| sdk_result | integer | 是 | SDK执行返回码;SUCCESS_OBSERVED时必须为0 |
| outcome | string | 是 | SUCCESS_OBSERVED、DEFINITE_FAILURE或UNKNOWN;未知结果不得盲目重试 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `ResultResponse.data`。
## C10 激活结果保存回执
`POST /api/v1/device/activation-acks`
调用身份:设备DEVREQ1签名
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| activation_id | string | 是 | 该物理设备唯一的激活任务UUID |
| local_generation | integer | 是 | 本次启动的本地持久化序号,不能倒退 |
| config_version | integer | 是 | 配置版本,必须与当前云端版本一致 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `ActivationAckResponse.data`。
## C11 关闭临时设置入口
`POST /api/v1/device/setup-close`
调用身份:设备DEVREQ1签名
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| setup_id | string | 是 | 云端15分钟临时设置会话UUID |
| reason | string | 是 | 关闭、轮换或人工处理原因,见对应枚举 |
| final_revision | integer | 是 | 设备关闭设置入口前看到的最新状态版本 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `CloseResponse.data`。
## 手机兑换一次性入口
`POST /api/v1/web/handoffs/exchange`
调用身份:同源网页;一次性token和verifier
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| token | string | 是 | 90秒内只能兑换一次的handoff令牌 |
| verifier | string | 是 | 设备交给手机的PKCE校验值;32字节随机数的Base64URL无填充字符串 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `BrowserResponse.data`。
## 恢复手机设置会话
`GET /api/v1/web/setup/session`
调用身份:设置Cookie + 写请求CSRF
无请求正文。
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `BrowserResponse.data`。
## 关联OIDC登录
`POST /api/v1/web/setup/auth-link`
调用身份:设置Cookie + CSRF + OIDC Bearer
正文:`{}`。
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `BrowserResponse.data`。
## 发送登录短信
`POST /api/v1/web/auth/sms/send`
调用身份:设置Cookie + 写请求CSRF
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| phone_e164 | string | 是 | 含国家区号的手机号,例如+8613800000000 |
| purpose | string | 是 | 固定LOGIN |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `SMSResponse.data`。
## 验证短信并登录
`POST /api/v1/web/auth/sms/verify`
调用身份:设置Cookie + 写请求CSRF
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| sms_challenge_id | string | 是 | 发送短信得到的挑战UUID |
| code | string | 是 | 6位短信验证码;5分钟有效,最多验证5次 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `BrowserResponse.data`。
## 查询可选择的家庭使用者
`POST /api/v1/web/profiles`
调用身份:设置Cookie + 写请求CSRF
正文:`{}`。
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `ProfilesResponse.data`。
## 创建家庭或使用者
`POST /api/v1/web/profiles/create`
调用身份:设置Cookie + 写请求CSRF
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| household_id | string | 条件或可选 | 家庭公开UUID;省略时须提供新家庭名称 |
| household_name | string | 条件或可选 | 新建家庭的名称 |
| preferred_name | string | 是 | 使用者称呼,用于设备人设和家庭展示 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `ProfileResponse.data`。
## 提交绑定申请
`POST /api/v1/web/claims`
调用身份:设置Cookie + 写请求CSRF
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| elder_id | string | 是 | 使用者公开UUID,复用subject_profile |
| expected_binding_generation | integer | 是 | 提交时预期绑定代数,防止跨解绑或重新绑定重放 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `ClaimResponse.data`。
## 取消自己的绑定申请
`POST /api/v1/web/claims/cancel`
调用身份:设置Cookie + 写请求CSRF
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| claim_id | string | 是 | 待物理确认的绑定申请UUID |
| expected_binding_generation | integer | 是 | 提交时预期绑定代数,防止跨解绑或重新绑定重放 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `StateOnlyResponse.data`。
## 查询手机绑定进度
`POST /api/v1/web/setup/status`
调用身份:设置Cookie + 写请求CSRF
正文:`{}`。
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `WebStatusResponse.data`。
## 网页确认收到绑定结果
`POST /api/v1/web/setup/ack`
调用身份:设置Cookie + 写请求CSRF
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| binding_id | string | 是 | 当前已生效的绑定公开UUID |
| binding_generation | integer | 是 | 绑定代数;解绑、重新绑定后旧代数无效 |
| received_revision | integer | 是 | 网页已收到的设备状态版本 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `AckResponse.data`。
## 设置结束后查询我的设备
`GET /api/v1/web/onboarding/devices`
调用身份:账号Cookie;设备列表也支持OIDC Bearer
无请求正文。
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `DevicesResponse.data`。
## 恢复账号会话及CSRF
`GET /api/v1/web/account/session`
调用身份:账号Cookie;设备列表也支持OIDC Bearer
无请求正文。
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `AccountResponse.data`。
## 退出本浏览器登录
`POST /api/v1/web/auth/logout`
调用身份:账号Cookie;设备列表也支持OIDC Bearer
正文:`{}`。
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `LogoutResponse.data`。
## 查询生产设备和激活状态
`GET /api/v1/ops/device-inventory`
调用身份:运维OIDC Bearer + X-Tenant-ID
无请求正文。
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `DevicesResponse.data`。
## 登记生产设备
`POST /api/v1/ops/device-inventory`
调用身份:运维OIDC Bearer + X-Tenant-ID
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| factory_mac | string | 是 | 12位大写十六进制MAC;相同MAC不能创建第二台物理设备 |
| hardware_model | string | 是 | 生产登记型号,当前为KP01 |
| hardware_revision | string | 是 | 硬件修订号,注册必须与库存一致 |
| batch_no | string | 是 | 生产批次,方便追溯 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `InventoryResponse.data`。
## 向工装交付加密密钥
`POST /api/v1/ops/device-inventory/{id}/credential-deliveries`
调用身份:运维OIDC Bearer + X-Tenant-ID
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| enrollment_job_id | string | 是 | 生产登记或密钥轮换任务UUID |
| receiver_public_key_pem | string | 是 | 工装RSA公钥,PKIX PEM,2048至4096位,指数65537 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `DeliveryResponse.data`。
## 确认烧录读回并放行
`POST /api/v1/ops/device-inventory/{id}/release`
调用身份:运维OIDC Bearer + X-Tenant-ID
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| enrollment_job_id | string | 是 | 生产登记或密钥轮换任务UUID |
| evidence_ref | string | 是 | 烧录读回或人工对账的证据引用,不放密钥 |
| expected_key_version | integer | 是 | 交付放行目标版本;轮换时为当前生效版本 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `ReleaseResponse.data`。
## 准备轮换凭据
`POST /api/v1/ops/device-inventory/{id}/credential-rotations`
调用身份:运维OIDC Bearer + X-Tenant-ID
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| reason | string | 是 | 关闭、轮换或人工处理原因,见对应枚举 |
| expected_key_version | integer | 是 | 交付放行目标版本;轮换时为当前生效版本 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `InventoryResponse.data`。
## 人工记录激活对账结论
`POST /api/v1/ops/onboarding-activations/{id}/reconcile`
调用身份:运维OIDC Bearer + X-Tenant-ID
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| expected_version | integer | 是 | 运维列表中的激活任务版本,防止覆盖并发处理 |
| conclusion | string | 是 | OBSERVED_REGISTERED、CONFIRMED_NOT_REGISTERED或DEVICE_ATTEMPT_STOPPED |
| evidence_ref | string | 是 | 烧录读回或人工对账的证据引用,不放密钥 |
| reason | string | 是 | 关闭、轮换或人工处理原因,见对应枚举 |
| attempt_id | string | 条件或可选 | 设备生成的UUID v4,表示一次引擎初始化或首通执行 |
| device_secret | string | 条件或可选 | 已核实的供应商单设备密钥,仅人工对账写入,加密保存 |
| rtc_app_id | string | 条件或可选 | 供应商RTC应用ID;不填则使用已保存值 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `StateOnlyResponse.data`。
@@ -0,0 +1,9 @@
# 首次接入实现记录
2026-09-11:本轮本地代码实施与验证已完成。正式说明见 [实施与联调说明](onboarding-implementation.md),全部接口字段见 [字段清单](onboarding-api-fields.md)。
已交付:53–57迁移、31项HTTP操作、设备独立认证、生产交付/放行/轮换、手机登录与家庭资料、按键确认绑定、双端ACK、激活outbox与补偿、配置回执与首通验证、代数及重放保护、手机页与运维页、OpenAPI及生成类型、调用工具、测试向量、灰度和恢复说明。
验证:全量Go单元测试、go vet、10个模块独立PostgreSQL/Redis集成测试、OpenAPI本地引用检查、前端TypeScript/生产构建、12个浏览器截图检查点通过。前端构建仍提示现有主bundle较大,构建成功;未在本轮重构整个前端拆包。
未发布生产。只在本地新建隔离数据库演练迁移,实际.env和已有业务库未改。真实短信、供应商账户、目标SDK、物理按键及NVS持久化由后续联调验收,不以合成测试结果代替。
+63
View File
@@ -0,0 +1,63 @@
# 首次认证、绑定与激活:实施说明
更新:2026-09-12。设备契约 ONBOARDING-1.1,按用户要求移除 recovery_action、retry_after_ms、next_action。详细输入、输出和示例见[设备端接口](设备端注册绑定接口.md);完整操作见[字段清单](onboarding-api-fields.md)及[OpenAPI](../openapi/onboarding.yaml)。发布结果见[发布记录](onboarding-release-20260912.md)。
## 已实现功能
- 生产库存登记、MAC 唯一性、单设备独立密钥、工装加密交付、放行及凭据轮换。
- 专用 HTTP 校时、TIME1 响应签名、HTTPS 启动注册、DEVREQ1 请求签名和 Redis nonce 防重放。
- 临时设置会话、PKCE 一次性交接入口、OIDC 登录、家庭与使用者选择、手机申请及设备物理按键确认。
- 绑定事务、绑定代数、权限校验、持久化回执、双端 ACK 收尾、重启和断网恢复。
- 激活配置、设备 SDK 执行许可、结果上报、结果保存确认、首次通话和人工对账。
- 旧设备认证保留;解绑、隐私删除和凭据轮换使旧归属请求失效。
手机入口为 `/onboarding/entry#token=<handoff_token>&verifier=<verifier>`,由设备本地页面打开。手机临时流程为 `/onboarding/setup`,已绑定设备为 `/onboarding/devices`;生产登记和对账为 `/maintenance?area=onboarding`。
## 设备认证
C01 请求包含 device_id、key_version、boot_id、client_nonce,无请求时间戳或请求 HMAC。成功根对象直接返回 payload_b64、mac_hex。设备核验 `HMAC-SHA256(raw_device_key, "TIME1\n" + payload_b64)`,解码并核对本设备、本次启动及新随机数,再设置时间。包有效期120秒;固件还应执行原附件中的单调时钟往返限时、可信时间下限检查。
C02–C11 使用严格验证服务器证书的 HTTPS。DEVREQ1 九行使用 LF 分隔,末尾无换行:版本、方法、路径、设备编号、密钥版本、UTC毫秒、nonce、请求UUID、原始正文SHA256。完整签名规则以设备文档为准。
请求正文最大8KiB,拒绝多份JSON、重复字段、未知字段、缺少字段、null必填字段和非法UTF-8。设备请求不带query。版本及计数为0至2147483647,SDK返回码允许有符号32位整数。
业务重试保持请求UUID及原始正文不变,重新生成时间戳、nonce及签名。响应缓存同时检查当前启动、设备密钥、绑定代数、权限和原业务对象。C01不使用普通业务响应封装;其他成功外层为 `{ok,code,request_id,server_time_ms,data}`,失败为 `{ok,code,request_id,message}`。
## 绑定与收尾
手机申请不直接建立归属,C06真实按键确认后才提交绑定与激活任务。绑定变更提升同一条单调递增代数,旧请求不能恢复旧归属。
C07保存设备已接收状态。仅设备ACK时至少等待30秒;网页也ACK时从双方均完成起至少等待3秒。C07重复请求重新计算setup_can_close,但不延长原accepted_at。C11最终检查最新版本及关闭条件。
## 激活状态机
1. 绑定后,worker或C05准备加密配置;worker不会调用供应商注册。配置包含附件E.2字段,iot内是instance_id、product_key、product_secret、device_name。
2. C05激活命令仅返回config_b64、config_mac。设备验证CFG1 HMAC及包内设备、绑定代数、版本、到期时间。
3. 设备申请C08许可,平台在数据库持久化执行编号及30秒开始执行期限。未注册时对应SDK_REGISTER;已经注册并C10确认保存后对应CONVERSATION_TEST,不新增输入字段。
4. C09报告SUCCESS_OBSERVED、DEFINITE_FAILURE或UNKNOWN,阶段须匹配执行编号。UNKNOWN和到期未报结果均阻止新的不确定执行;仅许可过期不能当作失败。
5. 注册成功是物理设备事实,保留至解绑以后。C10确认结果保存,随后首通成功使当前绑定进入READY。重新绑定必须重新进行首通,不能使用上一代READY证明。
6. 配置有效期24小时,更新版本前确认没有不确定执行。相同绑定下续期配置ACK后可复用已记录的首通证明。
默认首通由设备SDK直接执行,C09携带设备认证后的观察结果。若首通走既有平台v3通话接口,则仍传原通话必填字段及intent=activation_test、activation_id、attempt_id,不需要permit_token;平台按关联会话核验开始、首音频、结束事件。每个执行编号最多关联一次会话。
C09不上传DeviceSecret。设备SDK生成的单设备凭据应按原文在设备本地保存。平台既有代管通话如需该密钥,仍使用原有预配或运维核验凭据机制;本次没有从精简接口推导新的密钥上传字段。
## 人工对账
需要ops_admin,填写任务版本、证据引用、处理原因。供应商注册结果不明时,核实注册成功用OBSERVED_REGISTERED;确认没有注册且已停止执行用CONFIRMED_NOT_REGISTERED。DeviceSecret是可选的既有平台凭据补录字段。DEVICE_ATTEMPT_STOPPED仅可释放已停止的首次通话尝试,不能借此跳过注册对账。许可到期后还需经过3分钟等待窗口,平台不会伪造供应商查询结果。
## 发布配置
数据库迁移000053至000058增加首次接入所需身份、会话、激活和回执记录;000052是原有设备重置计数迁移。
服务器使用原有compose.yaml、compose.override.yaml及compose.subtitle.yaml,再叠加[compose.onboarding.yaml](../deploy/server/compose.onboarding.yaml)。只更新API、worker、web及网关;原凭据和已有业务配置保留。ONBOARDING_MASTER_KEY_FILE指向服务器单独生成的64位hex主密钥文件。API和worker必须读取同一份主密钥和供应商产品配置。
ONBOARDING_ENABLED、ONBOARDING_ACTIVATION_ENABLED控制新流程;ONBOARDING_PAUSE_CLAIMS暂停新绑定申请。ONBOARDING_TRUSTED_PROXY_CIDRS应为实际网关Docker网段;ONBOARDING_ALLOW_LOCAL_HTTP在线上保持false。Nginx仅放行HTTP的C01,其余HTTP仍跳转HTTPS。
已有OIDC账号可以绑定。短信仅在配置真实发送通道后开启。自动创建账号的租户白名单默认空,按实际运营范围配置ONBOARDING_SELF_SIGNUP_TENANT_IDS。
## 验证
`go test ./...`、`go vet ./...`、`scripts/test-onboarding-integration.ps1`、`npm run generate:api`和`npm run build`均已通过。集成测试为各模块创建独立迁移数据库,不改本地业务数据库。覆盖设备11接口、按键绑定、跨代重放、未知结果、30秒许可、保存顺序、配置续期、直接SDK和既有SaaS会话路径。
测试中的SDK观察结果和短信是合成数据;实物按键、断电持久化及真实供应商SDK尚需设备端联调。
+45
View File
@@ -0,0 +1,45 @@
# 设备认证与绑定接口发布记录
发布时间:2026-09-12 01:01:44(北京时间)。
已部署版本:`0.81.0-rc.onboarding1`。设备协议:`ONBOARDING-1.1`,按本次要求删除辅助流程字段。当前发布目录为 `/opt/data-terminal-cloud/releases/0.81.0-rc.onboarding1`,`/opt/data-terminal-cloud/current` 已切换到该目录。
## 在线地址
- 平台:[https://121.41.46.238](https://121.41.46.238)
- 运维首次接入:[登记与放行](https://121.41.46.238/maintenance?area=onboarding)
- 手机绑定:[设置页面](https://121.41.46.238/onboarding/setup),须先从设备生成的一次性交接入口进入;单独打开不能替代设备授权。
- 已绑定设备:[我的设备](https://121.41.46.238/onboarding/devices)
- 业务接口根地址:`https://121.41.46.238/api/v1/device/`
- 专用校时:`POST http://121.41.46.238/api/v1/device/time-challenges`
完整字段、中文解释、数据来源与示例见[设备端接口文档](设备端注册绑定接口.md)。
## 本次实现及部署
设备 C01–C11 输入输出已对齐。校时成功直接返回签名包;平台按任务状态发放30秒SDK执行许可;注册与首通分别上报;C10确认结果保存;未知结果及执行中断电重启进入待对账,禁止重复注册。SaaS worker负责准备配置,不再替这些新设备调用供应商注册。
更新API、worker、前端和Nginx。保留服务器现有供应商、字幕、记忆和登录配置,以及旧设备协议。认证主密钥只在服务器凭据目录保存,权限为root所有、运行用户组65532可读(0640),没有放入源码、镜像或项目压缩包。
数据库从51升至58,迁移状态正常。发布前已备份PostgreSQL、Redis、对象存储和登录服务数据,加密备份:`/var/backups/data-terminal-cloud/platform-20260911T165021Z.tar.gz.enc`。原发布目录 `0.80.2-rc.profile-ui3` 保留。
## 验证结果
- 全量Go测试和go vet通过;10个模块的隔离PostgreSQL/Redis集成测试通过。
- 最后补充的断电许可保护已重新运行设备接口集成测试及相关API、HTTP、通话模块测试。
- 前端TypeScript和生产构建通过。
- 线上20项检查通过,使用正常证书校验,无关闭TLS验证。
- API和worker健康检查通过;在线版本接口返回本发布版本。
- 11条设备路由的未授权或缺字段响应符合契约,没有已删除的辅助字段。
- HTTP校时接口返回平台JSON错误而非HTTPS重定向;其他HTTP业务与手机入口仍跳转HTTPS。
- 手机绑定、我的设备、运维入口和对应新版前端静态资源可访问。
设备完整认证、按键绑定、配置签名及激活成功路径在隔离数据库中验证。线上探测未使用真实设备密钥、未创建测试设备或业务账号。真实板卡、SDK注册、物理按键和断电持久化尚需设备端联调。短信发送通道未配置,本次保留OIDC账号登录,短信开关关闭。
## 后续维护
启动命令需包含原有三个Compose文件,并最后叠加 `deploy/server/compose.onboarding.yaml`;该文件固定本次API、worker和前端镜像。不要只运行旧的两文件部署命令,否则会遗漏首次接入运行配置。
暂停新业务优先关闭ONBOARDING_ENABLED、ONBOARDING_ACTIVATION_ENABLED或开启ONBOARDING_PAUSE_CLAIMS,保留认证主密钥与历史数据。已有新协议设备投入使用后,不能直接回滚为不识别新设备的旧应用;应保留数据库迁移并采取前向修复。
源码基线:`7846f30d0b239e183267988aa6e030b0a2f1ef18`,本次包含工作区实现,以项目包清单及文件哈希为准。未新建Git提交。
+476
View File
@@ -0,0 +1,476 @@
# 设备注册与绑定接口简版
按当前 SaaS 代码整理,适用协议 `ONBOARDING-1.2-SAAS`。
正常流程:**后台登记并放行 → 设备联网认证 → 打开手机绑定页 → 用户登录并提交绑定 → 设备按键确认 → 保存绑定结果 → 激活语音服务。**
`register` 成功表示设备身份认证通过;`binding_state=BOUND` 才表示绑定成功;`activation_state=READY` 表示语音服务已就绪。
## 阅读约定
- 每个接口只列用途、输入和输出。输入示例为请求 JSON;输出示例为响应 `data` 的主要字段,已省略部分状态和时间字段。
- `<…>` 都是占位值,须替换为实际返回值或设备生成值;UUID、密钥、签名不能照抄占位值。版本号也必须使用最新返回值。
- 路径前加实际 SaaS 域名,例如 `https://saas.example`。请求使用 `Content-Type: application/json`;下面 POST 请求统一携带 `X-Request-ID`(UUID v4)。
- 设备接口除校时外,需要设备签名请求头;手机接口使用浏览器 Cookie,兑换入口后的 POST 还需 `X-CSRF-Token`,并按同源请求调用;运维接口需要运维登录凭证和 `X-Tenant-ID`。具体签名见[设备注册接口说明](设备注册接口说明.md)。
- 这是代码中的接口清单,实际可用性取决于部署开关;短信登录需要已配置并开启短信服务。
响应外层统一如下,后文的“输出”放在 `data` 中:
```json
{"ok":true,"code":"OK","message":"","request_id":"<本次请求UUID>","server_time_ms":1789092000000,"recovery_action":"NONE","retry_after_ms":0,"data":{}}
```
## 一、后台预登记:运维后台或生产工装调用
### 1. 登记一台生产设备
`POST /api/v1/ops/device-inventory`
**用途:** 在平台建立设备档案,生成设备编号和凭据交付任务。MAC、型号、硬件版本用于识别设备;批次用于追溯。另需请求头 `Idempotency-Key`,避免重复建档。
输入:
```json
{"factory_mac":"AABBCCDDEEFF","hardware_model":"KP01","hardware_revision":"A1","batch_no":"20260911-01"}
```
输出:设备生产编号、平台编号、交付任务和是否放行。
```json
{"device_id":"KP01-AABBCCDDEEFF","platform_device_id":"<平台设备UUID>","enrollment_job_id":"<交付任务UUID>","released":false}
```
### 2. 获取设备认证凭据交付包
`POST /api/v1/ops/device-inventory/{id}/credential-deliveries`
**用途:** 将设备认证密钥加密交给生产工装。`{id}` 使用上一步的 `platform_device_id`;公钥属于接收工装。
输入:
```json
{"enrollment_job_id":"<交付任务UUID>","receiver_public_key_pem":"<工装RSA公钥PEM>"}
```
输出:加密交付包。工装解密后可取得 `device_id`、`device_key_b64`、`key_version` 等设备认证资料。
```json
{"algorithm":"RSA-OAEP-SHA256+A256GCM","enrollment_job_id":"<交付任务UUID>","wrapped_key_b64":"<加密的数据密钥>","ciphertext_b64":"<凭据密文>","expires_at_ms":1789092600000}
```
### 3. 确认凭据写入并放行
`POST /api/v1/ops/device-inventory/{id}/release`
**用途:** 确认设备凭据已写入并读回校验,允许设备开始联网认证。`evidence_ref` 是校验记录的引用。
输入:
```json
{"enrollment_job_id":"<交付任务UUID>","evidence_ref":"factory-check/20260911/001","expected_key_version":1}
```
输出:该设备已放行。
```json
{"device_id":"KP01-AABBCCDDEEFF","platform_device_id":"<平台设备UUID>","enrollment_job_id":"<交付任务UUID>","released":true}
```
## 二、设备认证与绑定:设备调用
### 4. 获取平台时间和注册挑战
`POST /api/v1/device/time-challenges`
**用途:** 取得可信时间和本次注册需要的一次性凭证。`boot_id` 标识本次启动,`client_nonce` 是设备生成的 16 字节随机数,以 Base64URL 无填充编码。
输入:
```json
{"device_id":"KP01-AABBCCDDEEFF","key_version":1,"boot_id":"<本次启动UUID>","client_nonce":"<16字节随机数的Base64URL>"}
```
输出:带签名的校时数据包。设备验签并解码后读取 `challenge_id`、`server_time_ms`、`expires_at_ms`,同时核对设备编号、启动编号和随机数。
```json
{"payload_b64":"<校时数据的Base64URL>","mac_hex":"<平台响应签名>"}
```
### 5. 设备注册/启动认证
`POST /api/v1/device/register`
**用途:** 校验设备身份,记录本次启动和软件版本,返回绑定及激活状态。设备编号通过请求头 `X-Device-ID` 传入。首次联网和重新启动都调用。
输入:
```json
{"challenge_id":"<上一步challenge_id>","boot_id":"<本次启动UUID>","hardware_model":"KP01","hardware_revision":"A1","firmware_version":"1.0.0","sdk_version":"1.0.0"}
```
输出:协议版本和当前设备状态。下面表示认证通过,但尚未绑定。
```json
{"contract_version":"ONBOARDING-1.2-SAAS","state":{"device_id":"KP01-AABBCCDDEEFF","binding_state":"UNBOUND","activation_state":"NOT_STARTED","next_action":"WAIT_BIND"}}
```
### 6. 创建一次绑定设置会话
`POST /api/v1/device/setup-sessions`
**用途:** 为本次手机绑定建立临时会话。`local_setup_id` 由设备生成,表示本次设置入口;`transport_profile` 固定为 `LOCAL_WEB`。
输入:
```json
{"boot_id":"<本次启动UUID>","local_setup_id":"<本次设置入口UUID>","transport_profile":"LOCAL_WEB"}
```
输出:云端会话编号,有效期最长 15 分钟;`poll_after_ms` 表示建议多久后查询状态。
```json
{"setup_id":"<设置会话UUID>","expires_at_ms":1789092900000,"poll_after_ms":2000,"mode":"ENROLL"}
```
### 7. 获取手机绑定页面入口
`POST /api/v1/device/handoffs`
**用途:** 获取手机绑定页地址和一次性入口令牌。设备生成 `verifier`,将其 SHA256 结果以 Base64URL 无填充编码后,作为 `code_challenge`。
输入:
```json
{"setup_id":"<设置会话UUID>","local_setup_id":"<本次设置入口UUID>","code_challenge":"<verifier的SHA256结果的Base64URL>"}
```
输出:页面地址、一次性令牌和到期时间。令牌最长 90 秒有效,只能兑换一次;`verifier` 保留在设备侧,由设备一并交给手机。
```json
{"entry_url":"https://saas.example/onboarding/entry","handoff_token":"<一次性令牌>","expires_at_ms":1789092090000}
```
手机实际打开:
```text
https://saas.example/onboarding/entry#token=<handoff_token>&verifier=<设备生成的verifier>
```
### 8. 查询绑定进度/领取平台命令
`POST /api/v1/device/poll`
**用途:** 设备定期查询是否有人申请绑定,以及是否有激活配置。`known_revision` 是设备已知的状态版本,首次可传 0。
输入:
```json
{"setup_id":"<设置会话UUID>","boot_id":"<本次启动UUID>","known_revision":0}
```
输出示例:手机已经提交绑定申请,平台要求设备提示用户按键确认。`payload` 中的确认字段用于下一接口。
```json
{"state":{"binding_state":"CLAIM_PENDING"},"command":{"command_id":"<绑定申请UUID>","type":"CONFIRM_CLAIM","expires_at_ms":1789092120000,"payload":{"claim_id":"<绑定申请UUID>","challenge":"<平台按键挑战>","binding_digest":"<平台绑定摘要>","expected_binding_generation":0,"masked_account":"尾号1234"}},"poll_after_ms":2000}
```
没有命令时 `command=null`。绑定后可能返回 `APPLY_ACTIVATION`,其中包含激活任务、配置版本、配置包及校验值;见第四部分。
### 9. 设备按键确认绑定
`POST /api/v1/device/claim-confirmations`
**用途:** 用户真实按下设备确认键后,设备上报确认,平台正式建立归属关系。前四个字段取自上一步命令,`press_event_id` 标识本次实际按键。
输入:
```json
{"claim_id":"<绑定申请UUID>","challenge":"<平台按键挑战>","binding_digest":"<平台绑定摘要>","expected_binding_generation":0,"press_event_id":"<本次按键事件UUID>"}
```
输出:`BOUND` 表示正式绑定成功;`binding_generation` 是绑定代数,绑定或解绑发生后会变化。
```json
{"claim_id":"<绑定申请UUID>","already_applied":false,"state":{"binding_state":"BOUND","binding_generation":1,"next_action":"RESUME_ACTIVATION"}}
```
### 10. 设备确认已保存绑定结果
`POST /api/v1/device/state-acks`
**用途:** 设备将最新平台状态保存到本地后回执。状态版本、绑定代数和摘要使用平台返回值;`local_generation` 是本次启动的本地保存序号,不能倒退。
输入:
```json
{"device_revision":5,"binding_generation":1,"local_generation":1,"state_hash":"<平台返回的state_hash>"}
```
输出:平台已收到保存回执;设备继续查询状态,等允许后关闭临时设置入口。
```json
{"accepted_revision":5,"setup_can_close":false,"close_after_ms":30000,"next_action":"POLL_FOR_CLOSE"}
```
### 11. 关闭本次临时设置入口
`POST /api/v1/device/setup-close`
**用途:** 绑定流程收尾,关闭临时设置入口。正常完成时等待 `poll` 返回 `setup_can_close=true`,再传 `SUCCESS`;其他原因可为 `TIMEOUT`、`CANCELLED`、`NETWORK_SWITCH`。
输入:
```json
{"setup_id":"<设置会话UUID>","reason":"SUCCESS","final_revision":5}
```
输出:允许关闭;关闭入口不等于语音激活完成,激活可继续进行。
```json
{"close_allowed":true,"state":{"binding_state":"BOUND"}}
```
## 三、手机绑定页面:SaaS 前端调用
用户看到的页面是 `/onboarding/setup`,正常由带一次性凭证的 `/onboarding/entry` 进入。登录账号及目标设备由会话确定,无需前端在绑定请求中再传手机号或设备编号。
### 12. 兑换设备提供的一次性入口
`POST /api/v1/web/handoffs/exchange`
**用途:** 手机用设备提供的令牌和校验值建立绑定会话。服务端通过 Cookie 保持该会话,并返回后续 POST 使用的 `csrf_token`。
输入:
```json
{"token":"<设备提供的handoff_token>","verifier":"<设备提供的verifier>"}
```
输出:注意此接口的响应 `data` 内还有一层 `data`,这是当前代码的实际结构。
```json
{"csrf_token":"<后续请求使用的CSRF值>","data":{"setup_id":"<设置会话UUID>","device_id":"KP01-AABBCCDDEEFF","sms_enabled":true,"state":{"binding_state":"UNBOUND","binding_generation":0}}}
```
### 13. 发送手机登录验证码
`POST /api/v1/web/auth/sms/send`
**用途:** 给用户手机号发送登录验证码。`phone_e164` 含国家区号,`purpose` 固定为 `LOGIN`。
输入:
```json
{"phone_e164":"+8613800000000","purpose":"LOGIN"}
```
输出:验证码任务编号、过期时间、多久后可重发;不会在接口响应中返回验证码。
```json
{"sms_challenge_id":"<短信验证UUID>","expires_at_ms":1789092300000,"resend_after_ms":60000}
```
### 14. 验证短信并登录
`POST /api/v1/web/auth/sms/verify`
**用途:** 验证用户输入的短信验证码,建立登录状态。
输入:
```json
{"sms_challenge_id":"<短信验证UUID>","code":"123456"}
```
输出:登录成功,并返回更新后的 CSRF 值;前端后续请求改用该值。
```json
{"csrf_token":"<更新后的CSRF值>","data":{"authenticated":true,"setup_id":"<设置会话UUID>"}}
```
### 15. 查询可以绑定的家庭和使用者
`POST /api/v1/web/profiles`
**用途:** 列出当前用户有权选择的家庭和设备使用者。
输入:
```json
{}
```
输出:家庭和使用者列表。选择目标后,后续绑定使用 `elder_id`。
```json
{"items":[{"household_id":"<家庭UUID>","household_name":"我的家庭","elder_id":"<使用者UUID>","preferred_name":"爸爸"}]}
```
### 16. 创建家庭/设备使用者
`POST /api/v1/web/profiles/create`
**用途:** 没有可选使用者时创建档案。新建家庭传 `household_name`;已有家庭则改传 `household_id`。`preferred_name` 是使用者称呼。
输入示例:新建家庭和使用者。
```json
{"household_name":"我的家庭","preferred_name":"爸爸"}
```
输出:新建的家庭和使用者编号。
```json
{"household_id":"<家庭UUID>","elder_id":"<使用者UUID>"}
```
### 17. 提交设备绑定申请
`POST /api/v1/web/claims`
**用途:** 申请把当前设备绑定给选中的使用者。`elder_id` 来自上面两个接口,`expected_binding_generation` 来自最新设备状态。
输入:
```json
{"elder_id":"<使用者UUID>","expected_binding_generation":0}
```
输出:申请编号和状态。`CLAIM_PENDING` 表示等待设备按键确认,还没有正式绑定。
```json
{"claim_id":"<绑定申请UUID>","expires_at_ms":1789092120000,"state":{"binding_state":"CLAIM_PENDING","binding_generation":0}}
```
### 18. 查询手机页面上的绑定进度
`POST /api/v1/web/setup/status`
**用途:** 手机刷新等待确认、已绑定和激活进度。`device_saved` 表示设备是否已确认保存绑定结果。
输入:
```json
{}
```
输出示例:设备已经绑定并保存结果。
```json
{"binding_id":"<正式绑定UUID>","device_saved":true,"authenticated":true,"state":{"device_revision":5,"binding_generation":1,"binding_state":"BOUND"}}
```
### 19. 手机确认已收到绑定成功结果
`POST /api/v1/web/setup/ack`
**用途:** 告诉平台手机页面已收到绑定成功结果,配合设备完成设置收尾。三个字段均取自上一步响应。
输入:
```json
{"binding_id":"<正式绑定UUID>","binding_generation":1,"received_revision":5}
```
输出:回执已接收。
```json
{"accepted":true}
```
## 四、绑定后的语音激活:设备继续调用
配置由平台后台任务准备,通过前面的 `POST /api/v1/device/poll` 下发。设备不用直接调用供应商注册接口。
`poll` 的激活命令输出示例(仍是响应 `data` 内的字段):
```json
{"command":{"command_id":"<激活任务UUID>","type":"APPLY_ACTIVATION","payload":{"activation_id":"<激活任务UUID>","config_version":1,"config_digest":"<配置摘要>","config_b64":"<编码后的配置包>","config_mac":"<配置签名>","key_version":1,"config_saved":false,"engine_initialized":false}}}
```
配置包解码后包含设备编号、绑定代数、配置版本、到期时间、IoT 连接资料、单设备密钥、机器人及 RTC 配置。设备校验后保存,用于连接语音服务。
### 20. 确认已保存激活配置
`POST /api/v1/device/activation-acks`
**用途:** 设备校验并保存配置后回执。任务、版本和摘要取自激活命令;绑定代数取自当前状态。
输入:
```json
{"activation_id":"<激活任务UUID>","config_version":1,"binding_generation":1,"local_generation":2,"config_digest":"<配置摘要>"}
```
输出:配置保存已确认。
```json
{"accepted":true,"config_version":1}
```
### 21. 申请执行初始化/首次通话测试
`POST /api/v1/device/activation-attempts`
**用途:** 执行 SDK 初始化或首次通话测试前,申请本次执行许可。`attempt_id` 由设备生成;`stage` 为 `ENGINE_INIT` 或 `CONVERSATION_TEST`。首次通话必须由用户主动发起,并传 `user_initiated=true`。
输入示例:申请初始化 SDK。
```json
{"activation_id":"<激活任务UUID>","config_version":1,"attempt_id":"<本次执行UUID>","stage":"ENGINE_INIT","user_initiated":false}
```
输出:本次执行许可和到期时间。许可最长 120 秒,首次通话测试最长 60 秒。
```json
{"activation_id":"<激活任务UUID>","attempt_id":"<本次执行UUID>","stage":"ENGINE_INIT","permit_token":"<短时执行许可>","permit_expires_at_ms":1789092120000,"maximum_test_seconds":60}
```
### 22. 上报初始化/首次通话测试结果
`POST /api/v1/device/activation-results`
**用途:** 上报执行结果。`outcome` 为 `SUCCESS`、`DEFINITE_FAILURE` 或 `UNKNOWN`;成功时 `sdk_result` 必须为 0。
输入示例:真实首次通话完成后上报成功,`evidence` 引用平台已记录的会话和事件。初始化结果使用 `stage=ENGINE_INIT`,`evidence` 可传 `{}`。
```json
{"activation_id":"<激活任务UUID>","attempt_id":"<首次通话执行UUID>","stage":"CONVERSATION_TEST","sdk_result":0,"outcome":"SUCCESS","evidence":{"conversation_id":"<测试会话UUID>","started_event_id":"<开始事件ID>","first_audio_event_id":"<首音频事件ID>","ended_event_id":"<结束事件ID>"}}
```
输出示例:首次通话验收通过,设备可正常使用。仅初始化成功不会直接返回 `READY`。
```json
{"duplicate":false,"state":{"binding_state":"BOUND","activation_state":"READY","next_action":"ENTER_READY"}}
```
首次通话本身复用既有 `POST /device/v3/agent-sessions` 及会话事件接口;不能只调用本节的结果接口就完成验收。原有通话字段和新增测试许可字段见[实施说明中的首通流程](onboarding-implementation.md#6-激活首通和人工恢复)。
## 五、页面恢复和可选操作
这些操作按需要使用,不是每次首次绑定都必须调用。
| 接口 | 用途 | 输入示例 | 输出示例(响应 data,节选) |
|---|---|---|---|
| `GET /api/v1/web/setup/session` | 页面刷新后恢复设置会话,取得新的 CSRF 值 | 无正文,浏览器携带设置 Cookie | `{"csrf_token":"<新CSRF值>","data":{"authenticated":true,"state":{"binding_state":"CLAIM_PENDING"}}}` |
| `POST /api/v1/web/setup/auth-link` | 使用已有 OIDC 登录状态,作为短信登录的另一种方式 | 正文 `{}`,另带 `Authorization: Bearer <登录令牌>` | `{"csrf_token":"<新CSRF值>","data":{"authenticated":true,"setup_id":"<设置会话UUID>"}}` |
| `POST /api/v1/web/claims/cancel` | 用户取消自己的待确认绑定申请 | `{"claim_id":"<绑定申请UUID>","expected_binding_generation":0}` | `{"state":{"binding_state":"UNBOUND"}}` |
| `GET /api/v1/web/onboarding/devices` | 设置完成后,通过登录账号查询已绑定设备 | 无正文,携带账号 Cookie 或 OIDC 登录令牌 | `{"items":[{"device_id":"KP01-AABBCCDDEEFF","activation_state":"READY","household_name":"我的家庭","preferred_name":"爸爸"}]}` |
## 最常用字段怎么理解
| 字段 | 含义/来源 |
|---|---|
| `device_id` | 生产设备编号,例如 `KP01-AABBCCDDEEFF` |
| `platform_device_id` | 平台设备 UUID,用于后台设备接口中的 `{id}` |
| `boot_id` | 设备本次启动编号,重启后重新生成 |
| `setup_id` | 本次临时绑定流程编号,由平台创建 |
| `elder_id` | 使用者档案编号,由手机选择或创建 |
| `claim_id` | 待设备按键确认的绑定申请编号 |
| `binding_id` | 已正式生效的绑定记录编号,由手机状态接口返回 |
| `binding_generation` | 绑定代数,用于区分绑定、解绑后的不同归属状态 |
| `device_revision` | 平台状态版本,用于判断拿到的是哪一版状态 |
| `activation_id` | 激活任务编号,通过设备 `poll` 命令取得 |
| `attempt_id` | 设备生成的一次初始化或首次通话执行编号 |
接口路径与请求字段依据[当前 HTTP 路由](../internal/onboarding/http.go)、[设备流程](../internal/onboarding/setup.go)、[手机流程](../internal/onboarding/web.go)、[短信登录](../internal/onboarding/sms.go)、[激活流程](../internal/onboarding/activation.go)及[生产登记](../internal/onboarding/inventory.go)核对。完整 31 项首次接入操作(含管理、退出和异常处理)见[字段清单](onboarding-api-fields.md)。
+261
View File
@@ -0,0 +1,261 @@
# 设备注册接口说明
适用版本:`ONBOARDING-1.2-SAAS`;对应当前仓库实现,OpenAPI `0.78.0`。
设备注册用于验证设备身份、登记本次启动和固件/SDK版本,并返回当前绑定及激活进度。首次联网和每次重新启动时使用本流程。注册成功后,用户绑定与语音服务激活继续按照返回状态处理。
## 1. 调用前提与顺序
设备必须已在 SaaS 完成生产登记、认证凭据交付和放行,并持有以下信息:
| 信息 | 示例 | 用途 |
|---|---|---|
| device_id | KP01-AABBCCDDEEFF | 生产设备编号,格式为 KP01- 加 12 位大写 MAC |
| device_key_b64 | 工装交付的密钥编码 | 解码为 raw 32 字节后,用于 HMAC 签名和校时验签 |
| key_version | 1 | 当前生效认证密钥的版本 |
| hardware_model | KP01 | 库存登记的设备型号 |
| hardware_revision | A1 | 库存登记的硬件修订号 |
只有生产模式为 `physical_confirm` 且已放行的设备使用此协议;旧设备维持原有协议。未登记的编号不会通过本注册接口自动建档。
调用顺序:
1. 本次启动生成一个 `boot_id`,整个启动期间保持不变。
2. C01 获取校时挑战,验证响应签名及随机数,取得可信时间和 `challenge_id`。
3. C02 使用设备密钥签名并提交注册请求。
4. 根据返回状态继续:未绑定设备创建设置会话;已绑定设备进入恢复激活或就绪流程。
下文以 `https://saas.example` 表示部署后的 SaaS 域名。示例中的编号、时间和版本均为说明用值,联调时须使用实际返回值。
## 2. C01:获取校时挑战
```http
POST /api/v1/device/time-challenges
Content-Type: application/json
```
此接口不要求请求 HMAC。建议使用 HTTPS;服务端为启动校时保留 HTTP 访问能力。设备必须验证响应中的 TIME1 签名,才可采用其中的时间。C02 必须使用 HTTPS。
请求正文:
```json
{
"device_id": "KP01-AABBCCDDEEFF",
"key_version": 1,
"boot_id": "10000000-0000-4000-8000-000000000001",
"client_nonce": "AAECAwQFBgcICQoLDA0ODw"
}
```
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| device_id | string | 是 | 与生产登记一致的设备编号 |
| key_version | integer | 是 | 当前生效密钥版本,必须大于等于 1 |
| boot_id | string | 是 | 标准小写 UUID v4;本次启动标识 |
| client_nonce | string | 是 | 16 字节随机数的 Base64URL 无填充编码;用于确认校时响应属于本次请求 |
`X-Request-ID` 可选;传入时必须是标准小写 UUID v4,省略时由平台生成。它用于追踪 C01 请求;校时挑战本身按设备、密钥版本、boot_id、client_nonce 去重。
成功响应的 `data`:
```json
{
"payload_b64": "<校时JSON原始字节的Base64URL无填充编码>",
"mac_hex": "<64位小写十六进制签名>"
}
```
校验公式:
```text
raw_key = Base64URLDecode(device_key_b64)
expected_mac = HexLower(HMAC-SHA256(raw_key, UTF8("TIME1\n" + payload_b64)))
```
其中 `\n` 是一个 LF 换行字节。先核对 mac_hex,再解码 payload_b64。解码内容为:
```json
{
"device_id": "KP01-AABBCCDDEEFF",
"boot_id": "10000000-0000-4000-8000-000000000001",
"client_nonce": "AAECAwQFBgcICQoLDA0ODw",
"challenge_id": "10000000-0000-4000-8000-000000000002",
"server_time_ms": 1789099200000,
"expires_at_ms": 1789099320000
}
```
设备核对 device_id、boot_id、client_nonce 与本地请求完全一致,保存 challenge_id,并用 server_time_ms 建立时间基准。挑战从签发起有效 120 秒,不能重复用于不同的成功注册操作;同一 client_nonce 重试不会延长有效期。过期后换新的 client_nonce 重新获取。
## 3. C02:提交设备注册
| 项目 | 内容 |
|---|---|
| 请求地址 | `POST https://saas.example/api/v1/device/register` |
| 调用方 | 已登记、已放行的设备 |
| 认证方式 | DEVREQ1 / HMAC-SHA256 |
| 请求正文 | JSON,UTF-8 无 BOM,最大 8 KiB |
| URL参数 | 不接受 query 参数 |
| 时间要求 | 请求时间与服务端时间差不超过正负 120 秒 |
**请求头**
| 请求头 | 必填 | 示例/用途 |
|---|---|---|
| Content-Type | 是 | `application/json` |
| X-Device-ID | 是 | `KP01-AABBCCDDEEFF`,生产设备编号 |
| X-Key-Version | 是 | `1`,签名使用的当前密钥版本 |
| X-Timestamp-Ms | 是 | 当前可信 UTC 毫秒时间戳 |
| X-Nonce | 是 | 每次发送新生成的 16 字节随机数,Base64URL 无填充编码 |
| X-Request-ID | 是 | 标准小写 UUID v4,标识本次注册业务请求;网络重试保持不变 |
| Authorization | 是 | `Device-HMAC-SHA256 <64位小写十六进制签名>` |
整数请求头使用标准十进制,不带正号、空格或前导零。X-Nonce 与 C01 的 client_nonce 用途不同:前者用于每次 HTTP 发送防重放,后者用于校验对应的校时响应。
**请求正文**
```json
{
"challenge_id": "10000000-0000-4000-8000-000000000002",
"boot_id": "10000000-0000-4000-8000-000000000001",
"hardware_model": "KP01",
"hardware_revision": "A1",
"firmware_version": "1.2.0",
"sdk_version": "1.0.0"
}
```
| 字段 | 类型 | 必填 | 数据用途和约束 |
|---|---|---|---|
| challenge_id | string | 是 | C01 响应中取得的挑战 UUID v4;必须属于同一设备、密钥版本和 boot_id,且尚未使用、未过期 |
| boot_id | string | 是 | 本次启动的 UUID v4;与 C01 相同,用于识别重启和关闭旧临时设置流程 |
| hardware_model | string | 是 | 设备型号;必须与 SaaS 生产登记完全一致 |
| hardware_revision | string | 是 | 硬件修订号;必须与 SaaS 生产登记完全一致 |
| firmware_version | string | 是 | 当前固件版本,1–32 个 UTF-8 字节;用于版本记录和诊断 |
| sdk_version | string | 是 | 当前 SDK 版本,1–64 个 UTF-8 字节;用于版本记录和诊断 |
device_id 通过请求头传入。设备注册由库存确定租户,不需要用户登录令牌、手机号、家庭编号或使用者编号。不要提交设备明文密钥、供应商 ProductSecret 或 Wi-Fi 密码。请求中不接受未定义的字段。
## 4. C02 签名计算
先把最终要发送的 JSON 编码为 UTF-8 原始字节,再计算 `body_hash = SHA256(body)` 的小写十六进制值。按以下顺序构造 **9 行**文本,行间只用 LF,末尾不加换行:
```text
DEVREQ1
POST
/api/v1/device/register
<X-Device-ID>
<X-Key-Version>
<X-Timestamp-Ms>
<X-Nonce>
<X-Request-ID>
<body_hash>
```
```text
signature = HexLower(HMAC-SHA256(raw_key, UTF8(上述9行文本)))
Authorization = "Device-HMAC-SHA256 " + signature
```
raw_key 是解码后的 32 字节设备密钥,不能直接用 device_key_b64 的字符串字节。签名中的路径不包含域名。签名后不能再修改正文中的空格、换行或字段顺序。
项目提供 [Python 签名调用工具](../scripts/onboarding-request.py) 和 [固定测试向量](../test/fixtures/onboarding-devreq1.json)。将上面的注册正文保存为 UTF-8 无 BOM 的 `register.json`,使用实际有效的 challenge_id、boot_id 和设备版本:
```powershell
python scripts/onboarding-request.py --device-id KP01-AABBCCDDEEFF --key-file device-key.b64 --key-version 1 --path /api/v1/device/register --body-file register.json --request-id 10000000-0000-4000-8000-000000000003
```
命令默认只输出本次签名请求头;加 `--send https://你的SaaS域名` 才会发送一次请求。工具使用运行它的电脑当前时间,使用前须保证电脑时间准确。固定测试向量使用合成密钥和固定时间,用于校验算法,不能直接发送到真实设备。
## 5. C02 成功响应
HTTP 200,以下为一台尚未绑定设备的示例:
```json
{
"ok": true,
"code": "OK",
"message": "",
"request_id": "10000000-0000-4000-8000-000000000003",
"server_time_ms": 1789099201000,
"recovery_action": "NONE",
"retry_after_ms": 0,
"data": {
"contract_version": "ONBOARDING-1.2-SAAS",
"state": {
"device_id": "KP01-AABBCCDDEEFF",
"platform_device_id": "10000000-0000-4000-8000-000000000004",
"device_revision": 3,
"binding_generation": 0,
"binding_state": "UNBOUND",
"activation_state": "NOT_STARTED",
"next_action": "WAIT_BIND",
"state_hash": "<当前状态的64位小写SHA256摘要>"
}
}
}
```
示例中的 state_hash 是占位说明;设备须使用真实响应中的摘要。
| 响应字段 | 用途 |
|---|---|
| ok / code / message | 请求是否成功及业务结果说明 |
| request_id | 与请求对应,方便联调和日志定位 |
| server_time_ms | 响应生成时的服务器 UTC 毫秒时间 |
| recovery_action / retry_after_ms | 错误恢复建议及推荐退避时间,成功时为 NONE / 0 |
| data.contract_version | 本次接口使用的协议版本 |
| state.device_id | 设备认证使用的生产编号 |
| state.platform_device_id | 既有 SaaS 管理接口使用的设备公开 UUID,与生产编号分开 |
| state.device_revision | 当前状态版本,设备同步和状态 ACK 使用 |
| state.binding_generation | 当前绑定代数;解绑、重新绑定后变化,用于使旧绑定请求和配置失效 |
| state.binding_state | UNBOUND=未绑定;CLAIM_PENDING=等待设备物理确认;BOUND=已绑定 |
| state.activation_state | NOT_STARTED=未开始开通;CONTACT_POSSIBLE=供应商执行中或结果待确认;REGISTERED_OBSERVED=已观察到注册成功、待设备验收;READY=验收完成;RECONCILE_REQUIRED=待对账;BLOCKED=当前开通受阻;RESERVED 为预留状态 |
| state.next_action | WAIT_BIND=继续设置和绑定流程;RESUME_ACTIVATION=恢复激活;ENTER_READY=进入就绪流程 |
| state.state_hash | 当前绑定和激活状态摘要,用于后续状态持久化回执 |
注册成功表示 SaaS 已接受设备本次启动认证。是否已经绑定看 binding_state,是否完成通话验收看 activation_state。C02 不返回新的设备密钥或供应商语音凭据。
## 6. 服务端处理和重试规则
服务端先校验设备、放行状态、租户、密钥、HMAC、时间和随机数,再校验挑战归属及版本字段。成功后消费挑战,记录 boot_id、最近注册时间、固件版本和 SDK 版本,并返回设备状态。
新的 boot_id 会关闭上一次启动的临时设置会话并取消待确认的绑定申请;已经完成的正式绑定保留。
| 情况 | 设备端处理 |
|---|---|
| 请求超时、回包丢失 | 保持原 X-Request-ID 和原始正文,更新 X-Timestamp-Ms、X-Nonce 并重新签名 |
| 前一次已经成功 | 在同一启动、绑定代数及有效权限范围内,24 小时内可返回缓存的原业务结果;外层响应时间重新生成 |
| 相同请求号更改正文 | 返回 REQUEST_BODY_CONFLICT;不得用同一个请求号提交另一份版本信息或挑战 |
| 挑战无效或过期 | 重新 C01,取得新挑战后使用新的 X-Request-ID 发起新的注册请求 |
| 设备重启 | 生成新的 boot_id,重新执行 C01/C02,不重放上一次启动的注册请求 |
| 注册缓存已过期或绑定代数已变化 | 恢复当前启动流程并查询最新状态,不依赖旧缓存结果 |
## 7. 常见错误
| HTTP | code | 说明与处理 |
|---|---|---|
| 400 | REQUEST_ID_REQUIRED | C02 缺少或使用了非 UUID v4 请求号 |
| 400 | HTTPS_REQUIRED | C02 必须通过 HTTPS 访问 |
| 400 | QUERY_FORBIDDEN | 去掉 URL query 参数 |
| 400 | INVALID_JSON / VALIDATION_FAILED | 检查正文结构、重复字段、型号、硬件版本和版本字符串长度 |
| 401 | DEVICE_AUTHENTICATION_FAILED | 检查编号、已放行状态、密钥版本、raw key、签名、时间和 nonce;重复发送同一 nonce 也会被拒绝 |
| 403 | RESOURCE_ACCESS_DENIED | 当前租户、设备或业务操作不可访问,核对设备状态 |
| 409 | CHALLENGE_INVALID | 挑战不属于当前设备/密钥/启动,或已消费、过期;重新 C01 |
| 409 | REQUEST_BODY_CONFLICT | 同一请求号对应的正文发生变化 |
| 409 | BINDING_GENERATION_CONFLICT | 当前启动或绑定状态已变化,恢复当前流程 |
| 410 | CHALLENGE_EXPIRED | C01 返回的同一校时挑战已到期;换新的 client_nonce |
| 410 | REQUEST_RESULT_EXPIRED | 注册请求的缓存结果已超过有效期 |
| 413 | REQUEST_TOO_LARGE | 请求正文超过 8 KiB |
| 415 | JSON_REQUIRED | Content-Type 必须为 application/json |
| 429 | RATE_LIMITED | 按 Retry-After / retry_after_ms 退避;当前建议等待 60 秒 |
| 503 | AUTHENTICATION_UNAVAILABLE / RATE_LIMIT_UNAVAILABLE | 认证防重放或限速依赖暂不可用,保留业务请求并退避重试 |
| 500 | INTERNAL_ERROR | 内部处理异常,记录 request_id,按原请求号与正文重试 |
失败时统一返回 `ok=false`、对应 code,data 为 null。恢复动作是通用建议;对于 CHALLENGE_INVALID 等具体错误,应按上表执行。
## 8. 注册后的接口
未绑定设备接着调用 `POST /api/v1/device/setup-sessions`,创建手机绑定入口所需的临时会话。后续完整字段见 [首次接入接口字段清单](onboarding-api-fields.md),部署与开关见 [实施说明](onboarding-implementation.md)。
本说明依据当前源码核对:[注册处理](../internal/onboarding/setup.go)、[设备认证及校时](../internal/onboarding/auth.go)、[签名计算](../internal/onboarding/crypto.go)、[HTTP响应](../internal/onboarding/http.go)。当前是代码接口说明,实际域名和开放范围由部署配置决定。
@@ -0,0 +1,24 @@
# 设备端接口对齐记录
更新:2026-09-12。当前代码已按[设备端接口文档](设备端注册绑定接口.md)同步实现,原有差异清单已由下表取代。
| 位置 | 当前实现 |
|---|---|
| 协议及外层 | ONBOARDING-1.1;不返回 recovery_action、retry_after_ms、next_action |
| 公共状态 | 仅设备编号、状态版本、绑定代数、绑定状态、激活状态、状态摘要 |
| C01 | 无时间戳请求签名;HTTP专用入口;成功根对象仅 payload_b64、mac_hex,单设备 TIME1 HMAC |
| C03 / C05 | 删除 mode 和轮询收尾辅助字段;known_revision 必填 |
| 激活配置 | 命令 payload 仅 config_b64、config_mac;包内使用 iot.product_secret;设备 SDK 执行注册 |
| C07 | 仅 accepted_revision、setup_can_close;重复回执重新计算关闭条件,保留原保存时间 |
| C08 | 仅任务、配置版本、执行编号输入;返回 allowed、执行编号、30秒到期时间、state;平台按任务事实确定阶段 |
| C09 | 仅任务、执行编号、SDK_REGISTER/CONVERSATION_TEST、SDK返回码、结果分类输入;返回任务、state、reconcile_required |
| C10 | 仅任务、保存序号、配置版本输入;注册成功后确认保存结果;返回 accepted、state |
| C11 | 继续检查最终版本与收尾条件;关闭临时入口不删除绑定 |
默认设备 SDK 直接执行与上报首通观察结果;不要求额外上传 evidence。如采用平台既有 v3 通话服务,则平台依据已关联的会话、开始、首音频和结束事件核验结果,不向 C09 增加字段。设备供应商密钥不通过 C09 上传;既有平台代管通话仍需要其原有凭据配置。
原文保留的 TRANSFER_PENDING 等枚举不代表本次新增了完整转移流程。当前首次绑定下发的命令为 CONFIRM_CLAIM 和 APPLY_ACTIVATION。短信通道与真实板卡、供应商 SDK 需使用真实环境分别验证。
本地验证:全量 Go 测试、go vet、10 个模块的隔离 PostgreSQL/Redis 集成测试、前端 TypeScript 和生产构建通过。设备 HTTP 测试覆盖 C01–C11 成功字段、缺失或额外字段拒绝、HMAC、HTTPS限制、注册和首通、未知结果、跨绑定重放及收尾等待。
线上结果见[发布记录](onboarding-release-20260912.md)。
+798
View File
@@ -0,0 +1,798 @@
# 设备端注册、绑定与激活接口说明
更新日期:2026-09-12。
依据原始《AI电话_首次配网注册与激活_完整流程与详细设计(1)》附件 A、B、C、E、I 整理,仅列设备直接调用 SaaS 的 C01~C11。按本次要求省去辅助流程控制字段,不添加原文没有规定的请求或响应字段。
本文件保留字段列表、中文含义、获取来源和示例。服务端已按此接口契约实现,版本为 `ONBOARDING-1.1`,并按本次要求移除三个辅助流程控制字段。实现与验证记录见[接口对齐说明](设备端接口_文档与当前代码差异.md);线上发布结果另见[发布记录](onboarding-release-20260912.md)。
## 通用规则
- 设备已完成生产登记,持有出厂写入的 `device_id`、32 字节 `device_key`、`key_version`。
- 全部接口使用 POST,正文为 JSON,不带 query 参数;普通报文上限 8 KiB,配置包上限 32 KiB。
- C01 是无可信时间时可通过专用 HTTP 入口调用的校时接口。其余接口使用严格验证证书和主机名的 HTTPS。
- `<…>` 为占位值,必须使用实际返回值或生成值;示例中的编号、版本、时间不是可直接请求线上服务的数据。
- `string` 为字符串,`integer` 为整数,`boolean` 为 true/false,`object` 为 JSON 对象。UUID 按原文为标准 36 字符 UUID v4;Base64URL 无填充编码末尾不带 `=`。
- 版本、计数按原文范围为 0~2147483647,时间为 Unix 毫秒。字段表中的全部输入均为原文必填字段。
### 请求头
C02~C11 使用下列认证头。C01 的设备编号和密钥版本放在正文中,不要求先提交可信时间戳或普通设备请求签名。
| 字段 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- |
| `Content-Type` | 正文格式 | 固定 `application/json`,C01 也使用。 |
| `X-Device-ID` | 生产设备编号 | 从出厂配置读取,例如 `KP01-AABBCCDDEEFF`。 |
| `X-Key-Version` | 设备认证密钥版本 | 从本机已保存凭据读取,十进制填写。 |
| `X-Timestamp-Ms` | 本次发送时间 | C01 验证并校时后,读取当前可信 UTC 毫秒时间。 |
| `X-Nonce` | 本次发送随机数 | 每次 HTTP 发送生成新的 16 字节安全随机数,做 Base64URL 无填充编码。 |
| `X-Request-ID` | 业务请求编号 | 由设备生成 UUID v4;同一次业务操作重试保留,原始正文也保留。 |
| `Authorization` | 设备请求认证码 | 使用本机 `device_key` 计算签名,填写 `Device-HMAC-SHA256 <64位小写十六进制HMAC>`。 |
签名按附件 B 的 DEVREQ1 规则计算,以下 9 行以 LF 连接,末尾不再加换行:
```text
DEVREQ1
<大写HTTP方法>
<实际path>
<device_id>
<key_version十进制>
<timestamp_ms十进制>
<nonce的Base64URL>
<request_id>
<原始请求正文的SHA256小写十六进制>
```
密钥使用原始 32 字节,不发送密钥本身。网络重试更新随机数、发送时间和签名,但不重新生成同一操作的业务编号、按键事件编号或执行编号。
### 普通响应外层
C02~C11 使用此封装,C01 不使用。成功时读取 `data`,失败时读取 `code`、`message`;设备归属和激活结果以业务状态为准。
| 字段 | 类型 | 中文含义 | 来源/怎么用 |
| --- | --- | --- | --- |
| `ok` | boolean | 本次请求是否成功 | 平台生成;不能单独用它判断设备已完成绑定或激活。 |
| `code` | string | 业务结果码 | 平台返回,成功为 `OK`,失败为对应错误码。 |
| `request_id` | string(UUID) | 请求追踪编号 | 平台返回与本次请求关联的编号,供查找请求记录。 |
| `data` | object | 成功业务输出 | 字段按各接口下表读取。 |
| `server_time_ms` | integer | 平台时间 | 平台生成;原文成功响应中提供,不能替代 C01 的首次可信校时。 |
| `message` | string | 错误中文说明 | 失败时由平台提供,便于提示原因。 |
```json
{
"ok": true,
"code": "OK",
"request_id": "<请求UUID>",
"data": {},
"server_time_ms": 1789092000000
}
```
失败示例:
```json
{
"ok": false,
"code": "CLAIM_EXPIRED",
"request_id": "<请求UUID>",
"message": "绑定确认已过期,请重新发起"
}
```
### 公共设备状态 `state`
下表相对于 `data.state`。按本次删减后的口径列出,状态版本、绑定代数和摘要必须来自同一版返回状态。
| 字段 | 类型 | 中文含义 | 来源/后续使用 |
| --- | --- | --- | --- |
| `device_id` | string | 生产设备编号 | 来自平台生产记录,设备与本机编号核对。 |
| `device_revision` | integer | 平台设备状态版本 | 由平台维护;后续用于 C05、C07、C11。 |
| `binding_generation` | integer | 绑定代数 | 平台随绑定关系变化而更新,初始 0,首次绑定后 1;用于防止旧归属信息被继续使用。 |
| `binding_state` | string | 绑定状态 | 平台根据归属关系返回,取值见下表。 |
| `activation_state` | string | 激活状态 | 平台根据激活任务返回,只有 READY 表示服务已就绪。 |
| `state_hash` | string | 本版状态摘要 | 平台按附件 I.1 计算;设备保存该版状态后在 C07 原样回传。 |
| 字段 | 取值及中文含义 |
| --- | --- |
| `binding_state` | `UNBOUND` 未绑定;`CLAIM_PENDING` 等待按键;`BOUND` 已绑定;`TRANSFER_PENDING` 转移处理中。 |
| `activation_state` | `NOT_STARTED` 未开始;`RESERVED` 已预留;`CONTACT_POSSIBLE` 可能已执行;`REGISTERED_OBSERVED` 已确认注册;`READY` 已就绪;`RECONCILE_REQUIRED` 待对账;`BLOCKED` 被阻止。 |
`state_hash` 按原文对以下内容以 LF 连接后计算 SHA256,输出小写十六进制,末尾无换行:
```text
STATE1
<device_id>
<device_revision>
<binding_generation>
<binding_state>
<activation_state>
```
`device_revision` 是平台状态版本,`binding_generation` 是归属代数,`local_generation` 是设备本地保存序号,`config_version` 是激活配置版本;不能互相代填。
## 接口总览
下面路径前统一加 `/api/v1/device`。
| 编号 | POST 路径 | 用途 |
| --- | --- | --- |
| C01 | `/time-challenges` | 校时并获取注册挑战 |
| C02 | `/register` | 认证设备并登记启动 |
| C03 | `/setup-sessions` | 创建设置会话 |
| C04 | `/handoffs` | 获取可交给手机的一次性绑定入口 |
| C05 | `/poll` | 查询状态及命令 |
| C06 | `/claim-confirmations` | 设备按键确认绑定 |
| C07 | `/state-acks` | 确认已保存绑定状态 |
| C08 | `/activation-attempts` | 申请一次 SDK 执行许可 |
| C09 | `/activation-results` | 上报激活执行结果 |
| C10 | `/activation-acks` | 确认激活结果已保存 |
| C11 | `/setup-close` | 记录设置收尾并关闭入口 |
## C01 校时并取得注册挑战
`POST /api/v1/device/time-challenges`
用途:让设备在时钟不正确时先取得可信时间,再使用严格 HTTPS。只返回时间和挑战,不返回用户资料、登录令牌或火山密钥。
### 输入字段
| 字段 | 类型 | 必填 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- | --- | --- |
| `device_id` | string | 是 | 生产设备编号 | 读取出厂配置:`KP01-` 加 12 位大写十六进制 MAC。 |
| `key_version` | integer | 是 | 认证密钥版本 | 读取本机保存的凭据版本,不是密钥内容。 |
| `boot_id` | string(UUID) | 是 | 本次启动编号 | 设备每次启动生成并保留,后续 C02、C03、C05 沿用。 |
| `client_nonce` | string | 是 | 本次校时随机数 | 设备生成 16 字节安全随机数并做 Base64URL 编码,每次校时重试换新值。 |
输入示例:
```json
{
"device_id": "KP01-AABBCCDDEEFF",
"key_version": 1,
"boot_id": "<本次启动UUID>",
"client_nonce": "<16字节随机数的Base64URL>"
}
```
### 输出字段
下表为校时响应根对象。
| 字段 | 类型 | 中文含义 | 来源/设备怎么用 |
| --- | --- | --- | --- |
| `payload_b64` | string | 校时数据包 | 平台将下方六个字段序列化并编码;设备用原字符串验签后解码。 |
| `mac_hex` | string | 校时数据认证码 | 平台用本设备密钥计算 TIME1 HMAC,设备使用同一密钥验证。 |
输出示例:
```json
{
"payload_b64": "<校时数据包的Base64URL>",
"mac_hex": "<64位小写十六进制HMAC>"
}
```
`payload_b64` 验签后解码字段:
| 字段 | 类型 | 中文含义 | 来源/怎么用 |
| --- | --- | --- | --- |
| `device_id` | string | 设备编号回显 | 核对等于本次请求及本机编号。 |
| `boot_id` | string(UUID) | 启动编号回显 | 核对等于设备本次启动编号。 |
| `client_nonce` | string | 随机数回显 | 核对等于本次新请求的随机数。 |
| `challenge_id` | string(UUID) | 注册挑战编号 | 由平台生成并返回,保存后传入 C02。 |
| `server_time_ms` | integer | 经过认证的平台时间 | 平台可信时钟生成;全部校验通过后设置设备时钟。 |
| `expires_at_ms` | integer | 挑战到期时间 | 平台签发时间加 120 秒,到期后重新 C01。 |
```json
{
"device_id": "KP01-AABBCCDDEEFF",
"boot_id": "<本次启动UUID>",
"client_nonce": "<本次请求随机数>",
"challenge_id": "<注册挑战UUID>",
"server_time_ms": 1789092000000,
"expires_at_ms": 1789092120000
}
```
校验公式:`HMAC-SHA256(device_key, UTF8("TIME1\n" + payload_b64))`,其中 `\n` 表示一个 LF 换行。附件 B 要求设备核对全部回显字段、单调计时 RTT 不超过 10 秒、时间不早于固件或已保存可信时间允许的下限;通过后校时,再开始 HTTPS。
## C02 设备认证与启动登记
`POST /api/v1/device/register`
用途:认证已有设备身份并登记本次启动;重复请求不新建设备、不清空既有归属。
### 输入字段
| 字段 | 类型 | 必填 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- | --- | --- |
| `challenge_id` | string(UUID) | 是 | 注册挑战 | 取 C01 验签后解码对象中的 `challenge_id`。 |
| `boot_id` | string(UUID) | 是 | 本次启动编号 | 沿用同一次 C01 的 `boot_id`。 |
| `hardware_model` | string,≤32字节 | 是 | 设备型号 | 从出厂配置读取,按真实型号填写。 |
| `hardware_revision` | string,≤16字节 | 是 | 硬件版本 | 读取出厂记录的硬件修订号。 |
| `firmware_version` | string,≤32字节 | 是 | 固件版本 | 读取设备当前实际运行的固件版本。 |
| `sdk_version` | string,≤64字节 | 是 | SDK版本 | 读取设备实际集成的 SDK 版本信息。 |
输入示例:
```json
{
"challenge_id": "<C01挑战UUID>",
"boot_id": "<本次启动UUID>",
"hardware_model": "KP01",
"hardware_revision": "A1",
"firmware_version": "1.0.0",
"sdk_version": "1.0.0"
}
```
### 输出字段
下表字段位于普通响应的 `data` 中。
| 字段 | 类型 | 中文含义 | 来源/设备怎么用 |
| --- | --- | --- | --- |
| `state` | object | 当前设备状态 | 平台返回已有记录的最新状态,完整字段见公共状态表。 |
| `contract_version` | string | 协议版本 | 原文规定 `ONBOARDING-1.1`;用于确认两侧接口口径。 |
输出示例:
```json
{
"state": {
"device_id": "KP01-AABBCCDDEEFF",
"device_revision": 3,
"binding_generation": 0,
"binding_state": "UNBOUND",
"activation_state": "NOT_STARTED",
"state_hash": "<本版状态SHA256摘要>"
},
"contract_version": "ONBOARDING-1.1"
}
```
## C03 创建设置会话
`POST /api/v1/device/setup-sessions`
用途:为本次设置建立平台会话;已绑定设备可恢复上下文,但不能借此重新认领。
### 输入字段
| 字段 | 类型 | 必填 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- | --- | --- |
| `boot_id` | string(UUID) | 是 | 本次启动编号 | 沿用已完成 C02 的启动编号。 |
| `local_setup_id` | string(UUID) | 是 | 本地设置编号 | 设备为本次设置流程生成;重试和 C04 沿用。 |
| `transport_profile` | string | 是 | 本地交互方式 | 按原文固定传 `LOCAL_WEB`。 |
输入示例:
```json
{
"boot_id": "<本次启动UUID>",
"local_setup_id": "<设备生成的本地设置UUID>",
"transport_profile": "LOCAL_WEB"
}
```
### 输出字段
下表字段位于普通响应的 `data` 中。
| 字段 | 类型 | 中文含义 | 来源/设备怎么用 |
| --- | --- | --- | --- |
| `setup_id` | string(UUID) | 平台设置会话编号 | 平台生成,后续传 C04、C05、C11;不同于设备生成的 local_setup_id。 |
| `expires_at_ms` | integer | 设置会话到期时间 | 平台生成;默认 15 分钟,原文绝对上限 30 分钟,设备以返回期限为准。 |
| `poll_after_ms` | integer | 建议轮询间隔,毫秒 | 平台返回;绑定期间原文默认 2000。 |
| `state` | object | 当前设备状态 | 平台返回,完整字段见公共状态表。 |
输出示例:
```json
{
"setup_id": "<平台设置会话UUID>",
"expires_at_ms": 1789092900000,
"poll_after_ms": 2000,
"state": {
"device_id": "KP01-AABBCCDDEEFF",
"device_revision": 3,
"binding_generation": 0,
"binding_state": "UNBOUND",
"activation_state": "NOT_STARTED",
"state_hash": "<本版状态SHA256摘要>"
}
}
```
## C04 取得一次性手机入口
`POST /api/v1/device/handoffs`
用途:设备取得 SaaS 绑定入口,并交给当前已授权的本地会话。
### 输入字段
| 字段 | 类型 | 必填 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- | --- | --- |
| `setup_id` | string(UUID) | 是 | 平台设置会话编号 | 取 C03 的 `data.setup_id`。 |
| `local_setup_id` | string(UUID) | 是 | 本地设置编号 | 沿用 C03 请求中的同名字段。 |
| `code_challenge` | string | 是 | 入口校验摘要 | 设备生成32字节随机值并编码为 verifier;对 verifier 的 ASCII 字节算 SHA256,再编码为 Base64URL。 |
输入示例:
```json
{
"setup_id": "<C03设置会话UUID>",
"local_setup_id": "<C03本地设置UUID>",
"code_challenge": "<32字节SHA256结果的Base64URL>"
}
```
### 输出字段
下表字段位于普通响应的 `data` 中。
| 字段 | 类型 | 中文含义 | 来源/设备怎么用 |
| --- | --- | --- | --- |
| `handoff_token` | string | 一次性入口令牌 | 平台生成32字节随机值并编码;设备把它放进入口链接。 |
| `expires_at_ms` | integer | 令牌到期时间 | 平台生成,原文有效期90秒;不能由设备自行延长。 |
| `entry_url` | string | SaaS绑定页面地址 | 由平台固定配置的 HTTPS 白名单入口。 |
输出示例:
```json
{
"handoff_token": "<平台一次性令牌>",
"expires_at_ms": 1789092090000,
"entry_url": "https://saas.example/onboarding/entry"
}
```
设备自己保留 `verifier`,不是向平台多传一个字段。交给手机的完整链接为:
```text
<entry_url>#token=<handoff_token>&verifier=<设备生成的verifier>
```
## C05 查询状态和命令
`POST /api/v1/device/poll`
用途:设备查询当前状态,领取绑定确认命令或激活配置;查询本身不代表用户按键或 SDK 执行完成。
### 输入字段
| 字段 | 类型 | 必填 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- | --- | --- |
| `setup_id` | string(UUID) | 是 | 平台设置会话编号 | 取 C03 返回的设置会话编号。 |
| `boot_id` | string(UUID) | 是 | 本次启动编号 | 沿用本次已经注册的启动编号。 |
| `known_revision` | integer | 是 | 设备已知状态版本 | 取最近返回的 `state.device_revision`;首次不知道时传0。按原文该字段必填。 |
输入示例:
```json
{
"setup_id": "<C03设置会话UUID>",
"boot_id": "<本次启动UUID>",
"known_revision": 3
}
```
### 输出字段
下表字段位于普通响应的 `data` 中。
| 字段 | 类型 | 中文含义 | 来源/设备怎么用 |
| --- | --- | --- | --- |
| `state` | object | 当前设备状态 | 平台返回,完整字段见公共状态表。 |
| `command` | object 或 null | 当前命令 | 平台产生;没有命令时为 null,原文兼容 NONE 类型。命令子字段见下表。 |
| `poll_after_ms` | integer | 下次轮询间隔,毫秒 | 由平台返回;绑定阶段默认2秒,普通待机不保持高频轮询。 |
输出示例:
```json
{
"state": {
"device_id": "KP01-AABBCCDDEEFF",
"device_revision": 3,
"binding_generation": 0,
"binding_state": "UNBOUND",
"activation_state": "NOT_STARTED",
"state_hash": "<本版状态SHA256摘要>"
},
"command": null,
"poll_after_ms": 2000
}
```
### `command` 命令字段
| 字段 | 类型 | 中文含义 | 来源/怎么用 |
| --- | --- | --- | --- |
| `command_id` | string(UUID) | 命令编号 | 平台生成,设备识别重复命令;原文未规定它必须等于申请或激活任务编号。 |
| `type` | string | 命令类型 | 原文为 NONE、CONFIRM_CLAIM、APPLY_ACTIVATION、UPDATE_STATE、CLOSE_SETUP;按返回类型处理。 |
| `expires_at_ms` | integer | 命令到期时间 | 平台生成,设备按可信时间判断有效性。 |
| `payload` | object | 命令数据 | 由平台生成;下面只展开原文明确给出的两种命令内容。 |
`CONFIRM_CLAIM` 的 `payload`:
| 字段 | 类型 | 中文含义 | 来源/怎么用 |
| --- | --- | --- | --- |
| `claim_id` | string(UUID) | 绑定申请编号 | 平台产生,设备从此处获取后传 C06。 |
| `challenge` | string | 按键挑战 | 平台生成32字节随机值编码,真实按键后 C06 原样回传。 |
| `binding_digest` | string | 固定绑定内容摘要 | 平台计算64位 SHA256 小写十六进制,设备不改写,C06 原样回传。 |
| `masked_account` | string | 脱敏账号提示 | 平台生成,例如“尾号1234”,供用户确认当前申请。 |
| `expected_binding_generation` | integer | 预期绑定代数 | 平台在建立申请时确定;C06 原样回传。 |
```json
{
"command_id": "<命令UUID>",
"type": "CONFIRM_CLAIM",
"expires_at_ms": 1789092120000,
"payload": {
"claim_id": "<绑定申请UUID>",
"challenge": "<32字节随机值的Base64URL>",
"binding_digest": "<固定绑定内容SHA256>",
"masked_account": "尾号1234",
"expected_binding_generation": 0
}
}
```
`APPLY_ACTIVATION` 的 `payload` 按原文只列配置封装:
| 字段 | 类型 | 中文含义 | 来源/怎么用 |
| --- | --- | --- | --- |
| `config_b64` | string | 激活配置包 | 平台生成配置 JSON 并编码;设备验签后解码,下方列出解码字段。 |
| `config_mac` | string | 配置认证码 | 平台计算 `HMAC-SHA256(device_key, UTF8("CFG1\n" + config_b64))` 的十六进制值;其中 \n 表示一个 LF,设备用本机密钥验证。 |
配置包验签解码后的字段,依据附件 E.2:
| 字段 | 类型 | 中文含义 | 来源/怎么用 |
| --- | --- | --- | --- |
| `contract_version` | string | 配置协议版本 | 原文为 `ONBOARDING-1.1`。 |
| `device_id` | string | 配置所属设备 | 平台读取生产记录,设备核对与本机一致。 |
| `activation_id` | string(UUID) | 激活任务编号 | 平台创建并保留;设备从配置包中取出,后续 C08、C09、C10 使用。 |
| `config_version` | integer | 激活配置版本 | 平台生成;后续 C08、C10 使用,不自行写死。 |
| `binding_generation` | integer | 配置对应绑定代数 | 平台维护;与当前公共状态核对,防止使用旧归属配置。 |
| `expires_at_ms` | integer | 配置到期时间 | 由平台签发,设备不得用过期配置开始新的首次激活;原文未在此规定统一24小时有效期。 |
| `iot` | object | 供应商注册资料 | 平台组装,子字段如下。 |
| `iot.instance_id` | string | 供应商实例编号 | 来自平台保存的实例配置。 |
| `iot.product_key` | string | 供应商产品标识 | 来自平台保存的产品配置。 |
| `iot.product_secret` | string | 受保护的产品注册密钥 | 这是原文 E.2 明确列出的字段;设备通过受保护配置取得,供原文 SDK 注册流程使用;与当前代码方案的差异见单独说明。 |
| `iot.device_name` | string | 物理设备在供应商侧的固定名称 | 由 SaaS 首次激活前生成并永久记录,设备原样使用;不随账号、重试、重启更换。 |
| `bot_id` | string | 共享智能体编号 | 来自平台配置,设备按对应 SDK 要求使用。 |
原文要求配置仅经设备 HTTPS 下发,落地使用加密 NVS,不交给手机或写入普通日志。该表没有增加当前代码中的其他运行配置字段。
## C06 设备按键确认绑定
`POST /api/v1/device/claim-confirmations`
用途:用户真实按下设备确认键后,设备提交本次确认,平台正式建立归属关系。
### 输入字段
| 字段 | 类型 | 必填 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- | --- | --- |
| `claim_id` | string(UUID) | 是 | 绑定申请编号 | 取 C05 同一确认命令的 `payload.claim_id`。 |
| `challenge` | string | 是 | 按键挑战 | 取该命令的 `payload.challenge`,原样回传。 |
| `binding_digest` | string,64位hex | 是 | 绑定内容摘要 | 取该命令同名字段,原样回传。 |
| `expected_binding_generation` | integer | 是 | 预期绑定代数 | 取该命令同名字段,不能自行加一。 |
| `press_event_id` | string(UUID) | 是 | 本次真实按键事件编号 | 设备在真实按键发生时生成并保存,网络重试沿用。 |
输入示例:
```json
{
"claim_id": "<C05申请UUID>",
"challenge": "<C05按键挑战>",
"binding_digest": "<C05绑定摘要>",
"expected_binding_generation": 0,
"press_event_id": "<设备实际按键UUID>"
}
```
### 输出字段
下表字段位于普通响应的 `data` 中。
| 字段 | 类型 | 中文含义 | 来源/设备怎么用 |
| --- | --- | --- | --- |
| `claim_id` | string(UUID) | 已处理的绑定申请 | 平台返回,设备核对是本次申请。 |
| `state` | object | 处理后的归属及激活状态 | 平台返回;保存对应版本、代数和摘要后调用 C07。 |
| `already_applied` | boolean | 该确认是否已经提交过 | 由平台识别重复相同申请的结果;不可据此产生第二次绑定。 |
输出示例:
```json
{
"claim_id": "<绑定申请UUID>",
"state": {
"device_id": "KP01-AABBCCDDEEFF",
"device_revision": 4,
"binding_generation": 1,
"binding_state": "BOUND",
"activation_state": "RESERVED",
"state_hash": "<本版状态SHA256摘要>"
},
"already_applied": false
}
```
## C07 确认绑定状态已保存
`POST /api/v1/device/state-acks`
用途:报告设备已经保存平台状态,不根据此回执修改 owner。
### 输入字段
| 字段 | 类型 | 必填 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- | --- | --- |
| `device_revision` | integer | 是 | 已保存状态版本 | 取同一版 `state.device_revision`。 |
| `binding_generation` | integer | 是 | 已保存绑定代数 | 取同一版 `state.binding_generation`。 |
| `local_generation` | integer | 是 | 本地保存序号 | 由设备本地保存模块产生并在保存成功后记录,不用平台版本号代替。 |
| `state_hash` | string,64位hex | 是 | 已保存状态摘要 | 取同一版 `state.state_hash` 并核对实际保存内容。 |
输入示例:
```json
{
"device_revision": 4,
"binding_generation": 1,
"local_generation": 1,
"state_hash": "<已保存状态摘要>"
}
```
### 输出字段
下表字段位于普通响应的 `data` 中。
| 字段 | 类型 | 中文含义 | 来源/设备怎么用 |
| --- | --- | --- | --- |
| `accepted_revision` | integer | 平台接受的保存版本 | 平台确认后返回,设备可记录该版已完成回执。 |
| `setup_can_close` | boolean | 是否可以收尾 | 平台根据回执和流程条件判断;设备按结果执行收尾。 |
输出示例:
```json
{
"accepted_revision": 4,
"setup_can_close": false
}
```
原文收尾条件:页面与设备都确认后等待3秒,或设备确认后最多30秒。这里保留原文的布尔判断,不新增等待时间或后续动作字段。
## C08 申请 SDK 执行许可
`POST /api/v1/device/activation-attempts`
用途:平台先记录本次可能执行的事实,再决定是否允许本次 SDK 调用,避免不确定结果下重复执行。
### 输入字段
| 字段 | 类型 | 必填 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- | --- | --- |
| `activation_id` | string(UUID) | 是 | 激活任务编号 | 从 C05 激活配置包解码后的 `activation_id` 取得。 |
| `config_version` | integer | 是 | 本次使用的配置版本 | 从同一份已验证配置的 `config_version` 取得。 |
| `attempt_id` | string(UUID) | 是 | 一次 SDK 执行编号 | 设备为本次实际执行生成并保留;本次请求重试沿用,不重新申请第二次执行。 |
输入示例:
```json
{
"activation_id": "<配置包中的激活任务UUID>",
"config_version": 1,
"attempt_id": "<设备生成的执行UUID>"
}
```
### 输出字段
下表字段位于普通响应的 `data` 中。
| 字段 | 类型 | 中文含义 | 来源/设备怎么用 |
| --- | --- | --- | --- |
| `allowed` | boolean | 是否允许本次执行 | 平台判断后返回;只有 true 才能开始此次 SDK 调用。 |
| `attempt_id` | string(UUID) | 许可对应执行编号 | 平台返回本次执行编号,后续 C09 使用。 |
| `permit_expires_at_ms` | integer | 许可到期时间 | 原文 I.1 规定许可有效期30秒;超窗重新查询,不能推断先前调用一定没有执行。 |
| `state` | object | 当前设备状态 | 平台返回,完整字段见公共状态表。 |
输出示例:
```json
{
"allowed": true,
"attempt_id": "<本次执行UUID>",
"permit_expires_at_ms": 1789092030000,
"state": {
"device_id": "KP01-AABBCCDDEEFF",
"device_revision": 5,
"binding_generation": 1,
"binding_state": "BOUND",
"activation_state": "CONTACT_POSSIBLE",
"state_hash": "<本版状态SHA256摘要>"
}
}
```
## C09 上报激活执行结果
`POST /api/v1/device/activation-results`
用途:上报已获许可的实际 SDK 执行结果。超时或无法确定结果时上报 UNKNOWN,不能当作明确失败重复执行。
### 输入字段
| 字段 | 类型 | 必填 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- | --- | --- |
| `activation_id` | string(UUID) | 是 | 激活任务编号 | 沿用 C08 所属任务编号。 |
| `attempt_id` | string(UUID) | 是 | 实际执行编号 | 沿用 C08 已申请并返回的执行编号,不在上报时重新生成。 |
| `stage` | string | 是 | 执行阶段 | 按原文 I.1 填 `SDK_REGISTER` 或 `CONVERSATION_TEST`,由设备依据本次实际执行操作确定。 |
| `sdk_result` | integer | 是 | SDK返回码 | 读取本次实际 SDK 调用或回调结果,不编造结果码。 |
| `outcome` | string | 是 | 本次结果分类 | 原文取值:`SUCCESS_OBSERVED` 已观察到成功、`DEFINITE_FAILURE` 明确失败、`UNKNOWN` 未知。 |
输入示例:
```json
{
"activation_id": "<激活任务UUID>",
"attempt_id": "<C08执行UUID>",
"stage": "SDK_REGISTER",
"sdk_result": 0,
"outcome": "SUCCESS_OBSERVED"
}
```
### 输出字段
下表字段位于普通响应的 `data` 中。
| 字段 | 类型 | 中文含义 | 来源/设备怎么用 |
| --- | --- | --- | --- |
| `activation_id` | string(UUID) | 结果所属任务 | 平台返回,设备与本次任务核对。 |
| `state` | object | 处理后的设备状态 | 平台返回,判断仍在激活、待处理或已 READY。 |
| `reconcile_required` | boolean | 是否需要对账 | 平台根据本次结果判定;true 时不要自行开始第二次同类激活执行。 |
输出示例:
```json
{
"activation_id": "<激活任务UUID>",
"state": {
"device_id": "KP01-AABBCCDDEEFF",
"device_revision": 6,
"binding_generation": 1,
"binding_state": "BOUND",
"activation_state": "REGISTERED_OBSERVED",
"state_hash": "<本版状态SHA256摘要>"
},
"reconcile_required": false
}
```
首次通话测试时使用 `stage=CONVERSATION_TEST`。结果分类只表示这一次执行的事实,不等同于设备最终状态;服务是否可用仍读取 `state.activation_state`。
## C10 确认激活结果已保存
`POST /api/v1/device/activation-acks`
用途:设备确认激活结果已在本地保存;重复相同回执不产生新的激活副作用。
### 输入字段
| 字段 | 类型 | 必填 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- | --- | --- |
| `activation_id` | string(UUID) | 是 | 已保存结果所属任务 | 沿用配置包及 C09 对应的激活任务编号。 |
| `local_generation` | integer | 是 | 本地保存序号 | 由设备保存模块在本次结果保存成功后记录,不使用配置版本代替。 |
| `config_version` | integer | 是 | 结果对应配置版本 | 沿用本次激活实际使用的配置包版本。 |
输入示例:
```json
{
"activation_id": "<激活任务UUID>",
"local_generation": 2,
"config_version": 1
}
```
### 输出字段
下表字段位于普通响应的 `data` 中。
| 字段 | 类型 | 中文含义 | 来源/设备怎么用 |
| --- | --- | --- | --- |
| `accepted` | boolean | 回执是否被接受 | 由平台验证后返回。 |
| `state` | object | 当前设备状态 | 平台返回,完整字段见公共状态表。 |
输出示例:
```json
{
"accepted": true,
"state": {
"device_id": "KP01-AABBCCDDEEFF",
"device_revision": 6,
"binding_generation": 1,
"binding_state": "BOUND",
"activation_state": "REGISTERED_OBSERVED",
"state_hash": "<本版状态SHA256摘要>"
}
}
```
## C11 设置流程收尾
`POST /api/v1/device/setup-close`
用途:请求或报告本次设置流程收尾。关闭临时入口不清空已提交的绑定或激活任务。
### 输入字段
| 字段 | 类型 | 必填 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- | --- | --- |
| `setup_id` | string(UUID) | 是 | 要关闭的设置会话 | 取本次 C03 返回的 `setup_id`。 |
| `reason` | string | 是 | 关闭原因 | 设备按实际情况填 SUCCESS、TIMEOUT、CANCELLED 或 NETWORK_SWITCH。 |
| `final_revision` | integer | 是 | 关闭前最后收到的状态版本 | 取最后成功收到的 `state.device_revision`,不是设备本地保存序号。 |
输入示例:
```json
{
"setup_id": "<C03设置会话UUID>",
"reason": "SUCCESS",
"final_revision": 6
}
```
### 输出字段
下表字段位于普通响应的 `data` 中。
| 字段 | 类型 | 中文含义 | 来源/设备怎么用 |
| --- | --- | --- | --- |
| `close_allowed` | boolean | 是否允许关闭 | 平台判定并返回,设备据此执行关闭。 |
| `state` | object | 当前设备状态 | 平台返回并保留正式绑定及激活事实,便于后续恢复。 |
输出示例:
```json
{
"close_allowed": true,
"state": {
"device_id": "KP01-AABBCCDDEEFF",
"device_revision": 6,
"binding_generation": 1,
"binding_state": "BOUND",
"activation_state": "REGISTERED_OBSERVED",
"state_hash": "<本版状态SHA256摘要>"
}
}
```
## 字段从哪里取得
| 要使用的字段 | 取值来源 |
| --- | --- |
| `device_id`、`device_key`、`key_version` | 设备出厂写入的身份及凭据。 |
| `boot_id` | 设备每次启动生成。 |
| `challenge_id` | C01 验签后的校时包。 |
| `local_setup_id` | 设备为一次本地设置生成。 |
| `setup_id` | C03 平台返回。 |
| `claim_id`、`challenge`、`binding_digest`、预期绑定代数 | 同一条 C05 确认命令,不跨命令拼接。 |
| `press_event_id` | 设备真实按键时生成。 |
| `activation_id`、`config_version` | C05 激活配置包验签解码后取得。 |
| `attempt_id` | 设备为一次 SDK 执行生成,C08 申请、C09 沿用。 |
| `local_generation` | 设备保存模块记录本地保存完成的序号。 |
| `device_revision`、`binding_generation`、`state_hash` | 平台返回的同一版公共状态。 |
## 平台如何确定 C08 执行阶段
C08 不增加阶段字段。平台按任务中已确认的事实判定:尚未观察到注册成功时,许可用于 `SDK_REGISTER`;已注册且 C10 确认保存后,许可用于 `CONVERSATION_TEST`。C09 的阶段必须匹配平台为该执行编号记录的阶段。已完成 READY 的同代绑定不再发放首次激活许可。
原文未给出部分命令的完整 payload,当前实际下发 `CONFIRM_CLAIM`、`APPLY_ACTIVATION` 两种命令,其余保留枚举不作为新增接口功能。
来源:[原始流程文档](<D:/wechat/xwechat_files/wangcong3092_983b/msg/file/2026-09/AI电话_首次配网注册与激活_完整流程与详细设计(1).docx>),重点是附件 B(认证及校时)、C(设备接口)、E(状态和配置)、I.1(逐接口输入与输出)。
@@ -0,0 +1,473 @@
# 首次配网设备认证与绑定 SaaS 修改方案
版本:方案草案 1.0 日期:2026 年 9 月 11 日
本方案用于把当前平台改造成支持设备首次认证、手机登录交接、实体按键确认绑定和独立 AI 激活的 SaaS。修改以现有 Go 服务、PostgreSQL、Redis、React、OIDC、设备绑定及供应商凭据模块为基础,分阶段交付,保留旧设备协议。
最终用户流程是:设备联网并认证,手机进入 SaaS 登录并选择使用者,用户按设备按键确认,SaaS 保存绑定,设备保存结果并回执,AI 服务随后独立开通。激活失败时显示“已绑定,AI 服务待处理”,用户不必重新配网或绑定。
本文是一份待实施的设计,不代表接口、数据库或页面已经修改。依据为用户提供的 ONBOARDING-1.1 首次接入文档、上一份差异分析和当前工作区代码。接口、数据表、配置项及模块名称标注为新增或拟新增的,均属于本方案建议。
## 1 方案采用的实现路线
| 决策 | 采用的方案 | 原因及影响 |
|---|---|---|
| 设备身份 | 保留数据库 UUID,新增唯一工厂编号和 MAC 映射 | 兼容现有业务外键、通话和管理接口 |
| 新设备认证 | 新增 DEVREQ1 验签,使用原始 32 字节密钥 | 与附件认证算法衔接;旧设备原签名和密钥解释不变 |
| 账号归属 | 延续租户、家庭、使用者模型;绑定人从当前登录态取得 | 复用权限服务,防止跨租户或任意指定 owner |
| 绑定方式 | 新设备必须走当前申请的设备按键确认 | 绑定码、运维分配等旧入口不能绕过该规则 |
| 手机登录 | 首先接入现有 OIDC 完成联调,完整交付补短信登录和注册 | 先验证绑定业务;短信未完成时不宣称覆盖附件的全部手机流程 |
| AI 激活 | 推荐保留 SaaS 代注册,再向设备下发 DeviceSecret | 复用当前供应商集成;ProductSecret 留在服务端 |
| 任务执行 | 绑定与 outbox 同事务,外部调用在独立 worker 中执行 | 回包丢失或进程退出仍能恢复原任务 |
| 上线方式 | 按设备或产线批次启用新协议,先发布兼容保护版本 | 保证旧设备持续运行,也保证新设备不能降级绕过确认 |
由于激活执行位置与原文档不同,建议把最终新契约暂命名为 `ONBOARDING-1.2-SAAS`,待双方联调后冻结。该名称是本方案的拟定版本,不是现有已发布协议。
### 1.1 与附件的明确调整
1. C01 至 C07、C11 的业务顺序和 DEVREQ1/TIME1/STATE1 计算保留;公共状态额外返回 platform_device_id,说明内部 UUID 与工厂编号的关系。
2. C08 至 C10 调整为设备引擎初始化或首通测试的许可、结果和配置保存回执;供应商注册由 SaaS worker 执行并记录证据。不得继续用设备上报 SDK_REGISTER 来表示服务端代注册结果。
3. 激活配置使用 device_secret,不下发 product_secret;完整性仍采用 CFG1,包含设备、绑定代数、任务、版本和期限。
4. OIDC 是第一阶段的账号接入路径;短信是完整交付的一部分。两者最后映射到同一平台账号和家庭权限模型。
5. 新设备进行日常通话时继续复用现有会话服务,设备入口增加显式协议路由与统一身份解析,不能让设备在进入通话阶段突然改用未经配置的另一套密钥。
若后续要求逐字段保持 ONBOARDING-1.1,则需要另行选择设备执行动态注册路线,恢复附件的 C08 至 C10 和 ProductSecret 配置语义;不能把两种执行模式混在同一个协议版本里。当前方案仅规划 SaaS 代注册这一条实施主线。
### 1.2 SaaS 与设备的工作边界
SaaS 实现库存、凭据、认证、会话交接、账号权限、绑定提交、状态回执、激活任务、对账和运维页面。设备配合生成及保存启动标识、调用新接口、按键确认指定申请、保存状态并 ACK,以及使用平台下发的运行凭据。
家庭 Wi-Fi 密码、AP/NAPT、SSID 扫描、GPIO 和本地 NVS 的实现不进入 SaaS。接口联调需要设备配合,但本方案不安排具体硬件开发。
## 2 修改后的流程及完成条件
| 阶段 | SaaS 提交的事实 | 进入下一阶段的条件 | 失败时保留什么 |
|---|---|---|---|
| 生产登记 | 库存、固定身份和受保护凭据 | 烧录校验并受控放行 | 原设备身份,不生成新设备副本 |
| 设备认证 | 时间挑战已消费、当前启动报告已更新 | 合法 HMAC、挑战、型号和设备状态 | 已有绑定和激活任务 |
| 手机交接 | setup 已建立、handoff 已兑换 | 有效入口和匹配 verifier | 设备和网络已有结果 |
| 申请绑定 | 固定账号和使用者的 PENDING claim | 当前账号有管理权限且设备未绑定 | 已完成登录,不提前建立归属 |
| 提交绑定 | 唯一有效绑定、代数、状态和激活待办 | 设备确认匹配当前 claim | 即使回包丢失,绑定仍成立 |
| 保存与收尾 | 设备和手机回执、设置入口关闭事实 | 回执条件满足或到达关闭上限 | 绑定、设备凭据和原激活任务 |
| AI 激活 | 供应商结果、运行配置和验收证据 | 按阶段检查,不以 HTTP 200 统一判断 | 账号绑定及可能已占用的供应商权益 |
手机可以在绑定完成后离开设置页。设备本地保存失败只修复落盘,AI 激活失败只修复激活。普通重配网保留绑定;解绑和转移使用独立业务流程。
## 3 数据库修改
数据库变更先增加字段和新表,再回填,再启用约束及新功能。以下为逻辑设计,正式 migration 编号在开发时按仓库最新序号分配。
### 3.1 扩展现有记录
| 现有记录 | 拟增加的数据 | 约束及用途 |
|---|---|---|
| device | enrollment_mode、onboarding_contract、factory_device_id、factory_mac、binding_generation、onboarding_revision | 旧数据默认为 legacy;工厂编号和 MAC 各自唯一;绑定代数和状态版本独立 |
| device_binding | generation、source、claim_id | device 与 generation 唯一;保留当前有效绑定唯一索引;claim_id 只对应一次提交 |
| 账号身份映射 | 统一的身份提供方、稳定主体和 user_account 关联 | 保留现有 OIDC 映射;短信身份不伪造成某个外部 OIDC 用户 |
| 审计及事件 | setup/claim/activation/attempt 的关联字段或结构化详情 | 追踪整条首次接入链路,不保存 token、验证码或密钥正文 |
onboarding_revision 在新协议中输出为 device_revision,避免更改现有 device.version、activation_revision、配置 revision 的语义。新表全部使用内部 UUID 做外键,并建立与 tenant_id 一致的组合约束。
### 3.2 新增的逻辑记录
| 拟新增表 | 关键字段 | 必须具备的约束 |
|---|---|---|
| device_inventory | device_id、factory_device_id、factory_mac、tenant_id、型号、批次、生产状态、放行记录 | MAC 和工厂编号唯一;一个库存只关联一个平台设备 |
| device_onboarding_credential | device_id、key_version、encrypted_key、nonce、key_ref、status、activated_at | device/key_version 唯一;只允许一个当前有效版本;密钥是原始 32 字节 |
| device_enrollment_job | job_id、device_id、request_digest、烧录包引用、校验及交付状态 | 重复登记不换身份;受保护交付结果可按原任务恢复 |
| device_time_challenge | device_id、key_version、boot_id、client_nonce、签发和到期时间、consumed_by_request | 绑定设备与启动;挑战只消费一次;可关联同一已提交注册请求 |
| device_onboarding_state | device_id、binding_state、activation_state、current_activation_id、last_boot_id、state_hash | 每设备一条;与 revision 和当前有效绑定在事务内一致更新 |
| device_setup_session | setup_id、device_id、boot_id、local_setup_id、mode、expires_at、absolute_expires_at、closed_at | 当前流程模式为 ENROLL 或 RECOVERY;已绑定设备只能恢复 |
| device_handoff | token_hash、setup_id、code_challenge、expires_at、consumed_at | token 哈希唯一;一次兑换;不得生成第二个可复活的入口 |
| web_setup_session | session_hash、setup_id、认证账号引用、CSRF 信息、期限、撤销时间 | 浏览器只访问当前流程;登录后旋转会话标识 |
| device_binding_claim | claim_id、device_id、setup_id、目标账号/家庭/使用者、challenge_hash、加密挑战、binding_digest、expected_generation、status、expires_at、press_event_id、result_binding_id | 每设备最多一条 PENDING;已成功申请长期去重;按键事件不能确认第二个申请 |
| device_state_snapshot / device_state_ack | device_id、revision、binding_generation、state_hash、local_generation、boot_id、保存及接收时间 | 快照不可变;重复相同 ACK 无副作用;旧 ACK 不覆盖当前状态 |
| device_setup_web_ack | setup_id、binding_id、binding_generation、received_revision、received_at | 记录页面看到了哪次绑定;与设备 ACK 独立 |
| device_activation_job | activation_id、device_id、provider_identity_id、状态、配置版本、当前 attempt、权益引用 | 每设备只有一个当前激活任务;终态和历史保留 |
| device_activation_attempt | attempt_id、activation_id、executor、stage、permit_deadline、fence_version、结果和证据 | executor 区分 SERVER/DEVICE;attempt 唯一;超时不自动授予第二次可能重复的调用 |
| provider_device_identity / activation_entitlement | 固定 device_name、供应商产品、状态、预留或对账证据 | 设备身份不因换用户重建;额度扣占必须事务化且有业务去重键 |
可以复用现有供应商凭据表保存加密 DeviceSecret,在请求供应商之前先持久化 provider_device_identity。权益模型与当前 License 内容表分开处理,不能把文件导入记录当成供应商已确认授权。
新 credential 表避免直接破坏现有 device_credential 按设备唯一的结构。旧密钥继续由旧解析器读取,新密钥由版本化解析器读取;共用加密基础时分别定义包含协议和版本的 AAD。
### 3.3 唯一约束示意
以下 SQL 用于表达约束,不是可直接执行的 migration:
```sql
CREATE UNIQUE INDEX device_binding_claim_one_pending
ON device_binding_claim (device_id)
WHERE status = 'PENDING';
CREATE UNIQUE INDEX device_binding_generation_unique
ON device_binding (device_id, generation);
CREATE UNIQUE INDEX device_activation_one_current
ON device_activation_job (device_id)
WHERE is_current = true;
```
不要在部分索引条件中使用 now()。创建新申请前,在设备锁内把已过期 PENDING 更新为 EXPIRED;后台清理只能辅助,不能作为并发正确性的前提。
### 3.4 绑定代数和历史回填
新设备初始 generation=0;首次绑定变为 1;解绑增加一次;重新绑定再增加一次。第一版转移走“原账号授权解绑,再由目标账号和设备确认新绑定”,不提供绕过确认的一步换主。
存量绑定按稳定的 bound_at、id 顺序建立迁移映射,考虑中间解绑事件,为当前归属确定唯一有效代数。不能把所有历史记录统一回填为 1。历史证据不足的设备保留 legacy 模式,待受控迁移时设置并审计基线;不凭推测补造过去的按键或验收证据。
## 4 生产登记和认证修改
### 4.1 新增生产管理入口
以下路径为本方案新增建议,由受控生产服务账号或具备相应能力的运维账号调用,鉴权与设备请求分开。
| 方法与路径 | 请求数据 | 返回和效果 |
|---|---|---|
| POST /api/v1/ops/device-inventory | factory_mac、hardware_model、hardware_revision、batch_no、目标租户;Idempotency-Key | 服务端生成 factory_device_id,建立或核对原库存;返回内部 UUID 和 enrollment_job_id |
| POST /api/v1/ops/device-inventory/{id}/credential-deliveries | enrollment_job_id、受控产线接收方和交付用途 | 返回仅产线可解密的烧录包或受控交付引用;不在普通列表返回密钥 |
| POST /api/v1/ops/device-inventory/{id}/release | 校验记录引用、expected_version | 已完成烧录校验后放行,允许首次云端认证 |
| POST /api/v1/ops/device-inventory/{id}/credential-rotations | reason、expected_key_version、交付接收方 | 创建新版本交付任务,确认后切换版本,旧版本撤销 |
同 MAC 再登记时返回同一库存和设备;型号、租户或板卡记录冲突返回 409,不静默覆盖。返修使用原登记任务或受控恢复任务;不因响应丢失重新生成密钥。烧录包有效期、访问授权和销毁均可审计,普通幂等响应只保存任务引用。
放行前设备可以处于现有 FACTORY 状态;放行后同步为 CLAIMABLE,避免被当前认证门禁拒绝。尚未通过生产校验的设备不得因请求 C01 而自动放行。
### 4.2 新鉴权组件
新增 `internal/onboardingauth`,读取以下头:X-Device-ID、X-Key-Version、X-Timestamp-Ms、X-Nonce、X-Request-ID、Authorization。DEVREQ1 九行签名按附件原始 body 字节计算;时间容差 ±120 秒;nonce 按设备和版本至少保存 5 分钟。
处理顺序固定为:限制体积和格式 → 查生产放行记录和凭据版本 → 恒定时间验签 → 时间窗口 → 原子 nonce 防重放 → 设备及租户当前状态 → 业务幂等 → 业务事务。新设备请求拒绝 query 参数,JSON 拒绝重复键、非法 UTF-8 和超过 12 层嵌套,普通报文 8 KiB、配置 32 KiB。
限流在昂贵验签之前先按来源执行,通过认证后再按设备执行。Redis 不可用时不能跳过防重放,返回可重试的服务不可用。公开工厂编号和 MAC 只用于定位记录,不构成认证证据。
身份解析器输出内部 device UUID、tenant UUID、工厂编号、协议和密钥版本。现有内部服务继续按 UUID 访问数据库,新协议对外的 device_id 使用工厂编号,platform_device_id 明确返回管理及历史资源所用的 UUID。
### 4.3 C01 与 C02
C01 允许专用 HTTP 校时入口,只返回 `payload_b64` 与 `mac_hex`,签名域为 TIME1;其他业务必须 HTTPS。部署时给 C01 单独路由,不能把整个 /api/v1/device/ 路径开放为明文业务入口。
挑战包含 device_id、key_version 的服务端关联、boot_id、client_nonce、challenge_id、server_time_ms、expires_at_ms;有效期 120 秒。设备校验新 nonce、回显和 RTT 是联调条件。服务器不接受将 C01 响应当成登录凭证。
C02 使用挑战完成已有身份的上线登记,保存固件和 SDK 版本、当前 boot_id。库存登记已经关联内部设备记录,因此此处更新既有记录,不让公开调用创建设备身份。注册时核对型号和生产记录,不以设备自报值覆盖库存型号。
C02 的 challenge 消费、注册结果和幂等提交在同一事务内完成。已成功请求重试先定位原业务结果,同时复核当前绑定代数和权限;挑战已消费不影响读取该成功结果,新 request_id 则须新挑战。
### 4.4 幂等执行器的必要修改
现有 `internal/idempotency/executor.go` 在命中缓存时直接返回 response_body。新协议必须增加事务内 replay guard,并支持调用方控制事务的 ExecuteTx,保证检查当前状态和读取重试结果之间没有换绑竞争。
作用域建议为 tenant/internal_device/contract/method/path/request_id,摘要采用原始 body。保存 24 小时的是短期请求结果;claim_id、activation_id、attempt_id 的业务唯一记录不随缓存过期删除。
固定操作结果与当前状态分别处理:操作未变化且代数仍有效时返回原结果;设备已换绑、停用或权限变化时返回 409/403 并要求查询最新状态,不把旧状态包装成新结果。对只读 poll/status 不缓存过时状态,每次查询使用新的请求号。
handoff token、设备密钥和 DeviceSecret 不明文存进通用 response_body。短时响应恢复采用独立加密存储,绑定有效期和当前权限;普通幂等记录只保存资源引用。
## 5 设备接口修改清单
以下 11 个 POST 路径保留附件的路径命名;字段标注“新增”的属于拟定 1.2 契约变化,不得对 1.1 客户端隐式启用。C01 使用专用校时协议,其余使用新 HMAC。
| 编号和路径 | body 核心字段 | 实施后的行为 |
|---|---|---|
| C01 /api/v1/device/time-challenges | device_id、key_version、boot_id、client_nonce | 返回签名时间及挑战,未知/未放行设备拒绝 |
| C02 /api/v1/device/register | challenge_id、boot_id、hardware_model、hardware_revision、firmware_version、sdk_version | 验证现有身份,返回契约版本及公共 state |
| C03 /api/v1/device/setup-sessions | boot_id、local_setup_id、transport_profile=LOCAL_WEB | 未绑定建立 ENROLL;已绑定建立 RECOVERY,恢复原激活任务 |
| C04 /api/v1/device/handoffs | setup_id、local_setup_id、code_challenge | 仅 ENROLL 签发 90 秒一次性手机入口 |
| C05 /api/v1/device/poll | setup_id、boot_id、known_revision | 当前 state、有效命令和 poll_after_ms;绑定阶段默认 2 秒 |
| C06 /api/v1/device/claim-confirmations | claim_id、challenge、binding_digest、expected_binding_generation、press_event_id | 指定申请确认;事务提交绑定或返回同一有效结果 |
| C07 /api/v1/device/state-acks | device_revision、binding_generation、local_generation、state_hash | 记录设备保存事实,返回 accepted_revision、setup_can_close |
| C08 /api/v1/device/activation-attempts | activation_id、config_version、attempt_id;新增 stage | 只签发 ENGINE_INIT/CONVERSATION_TEST 的设备许可;供应商注册许可由 worker 内部执行 |
| C09 /api/v1/device/activation-results | activation_id、attempt_id、stage、sdk_result、outcome;测试附新增 evidence | 核对同设备尝试和真实会话证据,更新相应阶段;拒绝设备伪报 SERVER_REGISTER |
| C10 /api/v1/device/activation-acks | activation_id、local_generation、config_version;新增 binding_generation、config_digest | 核对当前绑定和配置,记录设备保存运行配置 |
| C11 /api/v1/device/setup-close | setup_id、reason、final_revision | 关闭临时入口和未完成申请;保留已绑定关系及激活任务 |
新增协议版本通过生产记录固定,并在 C02 返回。固件只支持 1.1 时不得把 1.2 当作可忽略的小字段更新;必须完成版本能力校验后再使用。
### 5.1 公共状态示例
以下是拟定响应示例,UUID 为演示值,state_hash 由正式状态计算,不能把示例占位符作为测试向量。
```json
{
"ok": true,
"code": "OK",
"request_id": "f25fcabb-53e4-42c2-ad5b-048775f54414",
"server_time_ms": 1788681600000,
"data": {
"contract_version": "ONBOARDING-1.2-SAAS",
"state": {
"device_id": "KP01-84FCE6123456",
"platform_device_id": "b71dd44c-9a4d-4fdc-a4c3-e6921462c443",
"device_revision": 4,
"binding_generation": 1,
"binding_state": "BOUND",
"activation_state": "RESERVED",
"next_action": "RESUME_ACTIVATION",
"state_hash": "<STATE1计算得到的64位小写hex>"
}
}
}
```
新路径使用附件的 ok/code/data 响应封装,旧 API 保留原封装。不要全局修改 WriteJSON 使旧前端和旧设备同时发生响应变化。
### 5.2 首次接入命令
新增独立的 onboarding 命令队列,复用已有投递和去重经验,但不直接塞入只允许四种旧命令的 device_command 约束。
- CONFIRM_CLAIM:固定 claim_id、挑战、binding_digest、masked_account、expected_binding_generation 和期限。
- APPLY_ACTIVATION:固定 activation_id、config_version、binding_generation、config_digest、config_b64、config_mac 和期限;设备重复收到只同步同版配置。
- UPDATE_STATE:请求同步较新状态,不改变 owner。
- CLOSE_SETUP:请求结束临时设置流程,不等于解绑。
命令按设备、setup 模式、代数和到期时间过滤;取消或换绑后立刻失效。挑战及含 DeviceSecret 的命令载荷加密保存,重复投递可恢复原内容,普通日志仅记录命令 ID 和摘要。
## 6 手机接口和账号修改
### 6.1 七个目标接口
以下字段是完整方案拟定的数据契约,补齐附件未写全的短信及回执部分。新 Web 写接口均使用 HTTPS、当前会话、Origin/CSRF 检查与 X-Request-ID。
| POST 路径 | 请求数据 | 响应及限制 |
|---|---|---|
| /api/v1/web/handoffs/exchange | token、verifier | 设置临时 Cookie,返回 setup_id、脱敏设备信息及当前阶段;不要求预先登录 |
| /api/v1/web/auth/sms/send | phone_e164、purpose=LOGIN | 返回 sms_challenge_id、expires_at_ms、resend_after_ms;不泄露号码是否已有账号 |
| /api/v1/web/auth/sms/verify | sms_challenge_id、code | 原子消费验证码,登录或建立身份映射,旋转 Cookie;保留服务端 setup |
| /api/v1/web/claims | elder_id、expected_binding_generation | 服务端解析使用者所属家庭,返回 claim_id、期限、等待按键状态 |
| /api/v1/web/setup/status | 空对象,使用当前 Cookie | 当前 setup 的绑定、设备保存、激活进度和可恢复动作 |
| /api/v1/web/setup/ack | binding_id、binding_generation、received_revision | 记录页面已看到该绑定;旧绑定的回执不能用于新绑定收尾 |
| /api/v1/web/claims/cancel | claim_id、expected_binding_generation | 仅取消该会话有权管理的 PENDING;已提交时返回当前已绑定状态 |
第一阶段暂不开放 sms/send 和 sms/verify,改由 OIDC 登录接续接口完成身份衔接。完整发布前补短信提供方适配和真实联调,不能用模拟验证码对外发布。
### 6.2 OIDC 接续及账号权限
第一阶段拟新增 `POST /api/v1/web/setup/auth-link`:接收当前已验证的 OIDC Bearer 凭据,同时核对 setup Cookie 和 CSRF,将平台账号与本 setup 关联并旋转会话。它是 OIDC 适配接口,不替代 handoff 兑换。
完整方案增加统一平台会话解析层。OIDC 身份和已验证短信身份均映射到稳定的平台主体,再从 user_account、household_member 获取权限。仅向明确开放的家庭/档案路由增加平台会话鉴权;Cookie 写请求必须检查 CSRF。旧 Bearer 入口继续按现有方式工作。
现有部分服务直接按 issuer/subject 查询账号,需集中到共同的账号访问解析器,或在可信会话适配层使用明确的自有身份命名空间。不得把未经外部提供方签发的用户伪装为其 OIDC subject,也不得允许手机号自动合并另一账号。
设备库存确定目标租户。首次用户只可在允许自助入驻的消费租户中建立普通客户账号及家庭;企业租户没有邀请或授权时拒绝自动加入。注册不授予运维或租户管理能力。手机号绑定到已有 OIDC 账号须经过双方身份验证,不按相同显示名称合并。
用户已有家庭和使用者时直接选择;没有时通过现有家庭及档案服务创建。完成登记后必须再次校验使用者属于同租户且当前账号拥有设备管理能力。
### 6.3 入口页和浏览器会话
新建 `/onboarding/entry`、`/onboarding/setup`、`/onboarding/result` 页面。entry 读取 fragment 中的 token 和 verifier,立即 replaceState 清除地址,然后同源 HTTPS 兑换;禁用第三方脚本、统计和凭据缓存。登录页不访问设备私网 HTTP。
handoff 90 秒、claim 120 秒;setup 默认 15 分钟且绝对不超过 30 分钟。Cookie 建议为 `__Host-onboarding`,Secure、HttpOnly、Path=/、无 Domain,并按 OIDC 顶层回跳验证 SameSite=Lax 的实际行为。
登录成功后旋转 session ID 并保留数据库中的 setup;手机号、验证码、Token 不写 URL 或普通日志。发送验证码默认间隔 60 秒、验证码 5 分钟、单挑战最多 5 次验证,为拟定初值,另设手机号/IP/浏览器/全局发送限额。
兑换响应丢失时,有 Cookie 就查询当前状态;无 Cookie 就从同设备重新取入口,已经消费的 token 不复活。setup 关闭后普通账号会话仍可从设备列表查看进度,避免用户只能通过一次性入口访问已绑定设备。
## 7 绑定提交和所有归属写入口
### 7.1 统一的绑定服务
新增 `internal/devicebinding`,提供接收 pgx.Tx 的归属操作,供新 claim、旧绑定码、运维、正式解绑转移、恢复出厂和隐私删除共用。已有事务由调用方持有,公共服务不能私自另开事务导致部分提交。
新设备的 ConfirmClaim 应完成:
1. 验证设备请求,并按固定顺序锁定设备、setup 和 claim;目标权限行按统一顺序读取和锁定。多设备任务按 UUID 排序取锁。
2. 检查同申请是否已经成功:仅当前有效绑定和代数一致时返回原结果;归属已变化返回 409,要求同步当前状态。
3. 检查 setup 为有效 ENROLL、设备未绑定且未停用、claim 未取消且未过期、当前账号仍有使用者管理权限。
4. 核验 challenge、binding_digest、expected_binding_generation、press_event_id,并确保按键事件没有被另一申请使用。
5. 写入唯一有效绑定,binding_generation 增加一次,消费 claim,更新 onboarding state 和 revision,保存不可变快照。
6. 创建或关联固定激活任务,写入 `device.bound`/`device.activation.requested` outbox,事件去重键含设备与绑定代数;同时记录审计。
7. 提交后返回 BOUND。设备落盘 ACK 和供应商调用均发生在本事务之后。
取消和确认采用相同锁顺序。已绑定后再取消,只返回“绑定已完成”,不删除 owner。不同账号同时申请时返回 CLAIM_BUSY,不泄露另一个账号的完整信息。
### 7.2 其他写入口的处理
| 当前入口 | 新设备的规则 | 旧设备的兼容处理 |
|---|---|---|
| /api/v1/device-claims 绑定码 | 拒绝直接建归属,返回 DEVICE_CONFIRMATION_REQUIRED 和受控流程说明 | 保留原流程,但重试复核有效绑定 |
| 运维 Create/Patch 的预分配 | 对新模式禁止直接指定家庭/使用者;普通运维不能模拟设备按键 | 原设备按现有权限执行,并统一记录代数变化 |
| 运维删除/吊销 | 撤销归属及旧会话/命令,记录不可用状态;不能把设备重新开放给任意账号 | 保持原停用业务,复用新失效清理 |
| binding-change | 新模式走原账号授权解绑,目标账号重新申请并由设备确认 | 旧模式保持现有变更前检查,写入代数与审计 |
| /device/v3/factory-reset | 新模式只执行已获原账号或受控售后授权的完整重置;无授权不能解绑 | 保留旧产品行为并明确其语义;不可作为普通重配网接口 |
| 隐私删除处理器 | 解除对应使用者设备关系、递增代数、撤销旧流程与个性化配置 | 同样接入统一归属服务,避免绕过代数更新 |
最后一项来自当前 `internal/privacy/delete_processor.go` 的直接解绑写入,实施时必须覆盖。不能只修改可见绑定页面。
新设备普通重配网保留身份、owner 和供应商身份;需要云端记录时仅报告网络操作结果。受控售后解绑的申请、执行账号、理由和证据独立审计,不纳入首次绑定的快捷入口。
## 8 状态回执和收尾
binding_state 为 UNBOUND、CLAIM_PENDING、BOUND、TRANSFER_PENDING;activation_state 为 NOT_STARTED、RESERVED、CONTACT_POSSIBLE、REGISTERED_OBSERVED、READY、RECONCILE_REQUIRED、BLOCKED。现有 lifecycle 字段保留作兼容投影,由同一状态服务更新,避免多个模块各写一套最终事实。
device_revision 只随新协议公开状态变化;last_seen 更新不必增加该版本。STATE1 使用 device_id、device_revision、binding_generation、binding_state、activation_state 五个字段,前置 STATE1 后共六行;platform_device_id 等新增展示字段不加入原 hash 公式。
每次版本变更保存状态快照。C07 收到同版本同 hash 返回成功;过期代数或未知 hash 返回 409 并提示同步。local_generation 在设备和有效启动上下文中验证;若设备存储发生恢复,不以降低云端代数的方式迁就本地状态。
绑定与激活可继续并行:C07 ACK 只表示保存当前状态,Web ACK 表示看到了当前绑定。收尾以同一 binding_id/generation 的有效设备保存证据和页面接收证据为依据;页面收到较早 revision 不得被误当成看到了另一次绑定。
设备和页面都确认后等待 3 秒关闭;只有设备确认时最多 30 秒;AI 激活不延长该时间。C11 失败时设备可按本地安全上限关闭 AP,随后补报;SaaS 保存关闭事实但不删除原激活任务。
setup 过期或关闭后不再签发 handoff 和新 claim;认证设备可通过 C03 建 RECOVERY 上下文,再经 C05 获取自身状态、原任务和必要配置。恢复入口不授予新的账号所有权。
## 9 激活模块改造
### 9.1 将现有同步 Provision 拆成持久化阶段
当前 `internal/hardwareprovision/service.go` 在数据库事务持锁期间调用供应商注册。改造后分为:
1. 短事务内固定 provider_device_identity 和 activation_id,核验当前绑定与权益,创建 SERVER_REGISTER attempt,持久化 CONTACT_POSSIBLE,提交。
2. worker 在事务外调用供应商,携带稳定 device_name;不持有数据库长事务。
3. 新短事务保存明确结果和证据,加密 DeviceSecret,更新状态并发布 APPLY_ACTIVATION 命令。
worker 取到 outbox 重试事件时先检查业务任务状态。已有 CONTACT_POSSIBLE 但没有明确结果,进入对账;租约过期不能直接重新调用供应商。fence_version 用于拒绝旧 worker 覆盖新状态,但不把本地租约当成远端请求已取消的证据。
### 9.2 成功与未知结果
| 结果 | 处理 |
|---|---|
| 明确未发出请求的本地前置失败 | 保存错误;修复后可按策略创建新 attempt |
| 明确注册成功并取得合法凭据 | 保存供应商身份与 REGISTERED_OBSERVED;不直接写 READY |
| 明确授权不足/过期 | BLOCKED,显示服务待处理;保留绑定 |
| 注册请求超时、崩溃或回包未知 | RECONCILE_REQUIRED;保留原身份、任务和可能占用的权益 |
| 设备未保存配置 | 保持已知供应商结果,重发相同有效配置或生成新的配置版本 |
| 设备引擎初始化失败 | 记录 DEVICE/ENGINE_INIT 尝试,保留已知注册结果;不凭此释放供应商权益 |
| 已验证首通测试成功 | 满足证据、权限和保存条件后进入 READY |
对账通过经验证的供应商查询能力或受控人工证据完成。若供应商不提供可靠查询,不虚构自动对账 API;保持待处理,由运维基于供应商记录执行有审计的结论操作。
供应商身份按物理设备固定。换绑定时更新个性化配置版本并使旧代数失效,不重新分配 provider device_name 或盲目再注册。旧 attempt 的晚到结果可记录物理设备供应商事实,但不能恢复上一位用户的配置或推进新用户的 READY。
### 9.3 设备配置和 SDK 兼容验证
新的配置包含 contract_version、device_id、activation_id、config_version、binding_generation、expires_at_ms、config_digest、instance_id、product_key、device_name、device_secret、bot_id。通过设备 HTTPS 通道交付,并以 CFG1 HMAC 验证完整性。
ProductSecret 只在 SaaS worker 的供应商适配器中使用。配置不放进 Web status、普通 outbox payload 或明文幂等缓存。outbox 只记录引用,设备拉取时重新核对代数、任务和有效期。
必须验证当前 SDK 能使用已注册 DeviceSecret,且不会无条件触发另一轮动态注册。若该能力不成立,阻断此激活路线的发布并回到协议设计,不在代码里静默双重注册。此项只影响真实激活发布,不阻止先完成认证与绑定。
### 9.4 首通测试与日常通话
不能让“只有 READY 才能创建任何会话”和“必须完成首通测试才能 READY”互相阻塞。新设备达到 REGISTERED_OBSERVED、有效绑定、运行配置已 ACK 且用户明确发起测试后,允许携带 C08 的短时测试许可创建专用测试会话。
拟扩展现有 `/device/v3/agent-sessions`:仅新协议支持 intent=activation_test、activation_id、attempt_id、permit_token;正常设备通话仍检查当前绑定、配置、权限与服务状态。测试路径有时长/次数限制,不构成绕过普通服务权限的入口。
C09 的测试 evidence 至少关联 conversation_id、首次有效下行音频观测、音频检查结果、SDK/固件版本和结束状态;服务端与该次会话记录核对。只收到 session_started 或媒体连接事件不能写 READY。挂机准备不自动采集环境音,测试必须由用户明确触发。
### 9.5 新旧设备如何调用现有会话接口
新增统一 DevicePrincipal 适配:先根据已登记的设备身份与 enrollment_mode 选择唯一允许的认证方案,再交给现有内部服务。新设备在无 query 的现有 v3 会话/事件及 v2 上报路径可使用 DEVREQ1,旧设备继续旧五行签名;不根据 User-Agent 判断协议,不在验签失败后回退另一算法。
含 query 的旧 GET 接口不供新协议复用,新设备使用 C02/C05 等新 POST 路径。若以后需要 OTA 下载、媒体查询等功能,必须另列适配清单或提供无 query 的新入口;不能宣称本次首次接入方案已经覆盖所有旧设备能力。
现有 `/device/v3/hardware-agent-credentials:provision` 对旧模式保持原约定;新模式只读取已有已确认凭据,未准备完成返回当前任务状态或引导 C05,不能再次触发同步注册。凭据读取同样必须检查当前有效绑定。
## 10 前端和代码修改位置
| 模块或路径 | 修改内容 |
|---|---|
| internal/onboardingauth 新增 | DEVREQ1、TIME1、版本密钥解析、限长校验和新身份上下文 |
| internal/onboarding 新增 | C01 至 C07、C11、setup/handoff、状态快照和命令投递 |
| internal/devicebinding 新增 | 统一的事务内绑定、解绑、代数推进和旧上下文失效 |
| internal/activationjob 新增 | 固定任务、attempt、许可、权益、结果和对账 |
| internal/websession / internal/smsauth 新增 | OIDC 接续、短信验证、平台会话和 CSRF |
| internal/httpapi/router.go 及新增 handlers | 按契约挂载新路由、独立响应封装、显式鉴权分组 |
| internal/opsdevice/service.go | 工厂库存入口、凭据交付及所有归属写入的模式限制 |
| internal/saas/device_claim.go、device_binding_service.go | 旧入口新设备门禁、共同绑定事务和权限复核 |
| internal/devicereset/service.go | 区分普通重配网和获授权完整重置,撤销旧流程 |
| internal/privacy/delete_processor.go | 隐私解绑接入代数和状态失效流程 |
| internal/hardwareprovision/service.go | 从同步注册拆到 worker,保留已确认凭据读取和加密能力 |
| internal/idempotency/executor.go | ExecuteTx、事务内缓存命中权限/代数检查、敏感响应引用 |
| internal/outbox 和 worker 注册处 | 绑定/激活事件处理;网络重试与外部未知结果分开 |
| internal/identity、internal/app、internal/config | 账号解析统一、服务装配、新配置和按设备开关 |
| web/src/pages 下新增 Onboarding 页面 | 入口兑换、登录接续、档案选择、按键等待、保存和激活进度 |
| web/src/pages/SaaSWorkspace.tsx | 新设备不展示直接绑定码流程;正常账号设备列表显示分阶段状态 |
| web/src/pages/OpsDeviceWorkspace.tsx | 工厂编号/MAC、绑定历史、ACK、激活异常、受控对账操作 |
| web/src/api/client.ts、schema.d.ts | 从更新后的契约同步请求与返回类型 |
| openapi/openapi.yaml 和兼容说明 | 增加新契约及身份编码;保留 v2/v3 旧契约和旧测试 |
| internal/contractgovernance | 新增 ONBOARDING 契约检查、敏感字段边界和接口覆盖检查 |
前台文案按实际事实显示:正在认证、等待按键、已绑定但设备保存中、AI 服务准备中、待处理、可用。设备重连和手动刷新复用原 setup/claim/activation,不自动发新验证码或再建任务。
运维页面新增读取接口建议:`GET /api/v1/ops/devices/{id}/onboarding`、`GET /api/v1/ops/devices/{id}/activation-jobs`。受控对账建议 `POST /api/v1/ops/activation-jobs/{id}/reconciliations`,提交 conclusion、evidence_ref、reason、expected_version;权限限制到专门能力,不能用一个普通“重试”按钮解除未知结果。
## 11 错误与恢复规则
| 错误或状态 | HTTP | 客户端下一步 |
|---|---|---|
| DEVICE_AUTHENTICATION_FAILED | 401 | 停止敏感请求;检查密钥、时钟或受控恢复 |
| DEVICE_ACCESS_DENIED | 403 | 设备停用、租户或账号权限问题,显示联系支持 |
| REQUEST_BODY_CONFLICT | 409 | 同请求号不同内容,修复调用方 |
| DEVICE_CONFIRMATION_REQUIRED | 409 | 进入按键绑定流程,不能改用旧接口绕过 |
| CLAIM_BUSY | 409 | 等当前申请结束或由有权会话取消 |
| CLAIM_EXPIRED / HANDOFF_EXPIRED | 410 | 重新申请短期对象,保持设备和既有绑定 |
| BINDING_GENERATION_CONFLICT / STATE_MISMATCH | 409 | 查询当前状态,丢弃旧配置或旧确认 |
| CONTRACT_UNSUPPORTED | 426 | 使用双方支持的固件和契约,不猜测枚举含义 |
| RATE_LIMITED | 429 | 按 retry_after_ms 退避 |
| ACTIVATION_RECONCILE_REQUIRED | 200 状态查询或 409 新调用申请 | 展示待对账;禁止自动再注册 |
| DEPENDENCY_UNAVAILABLE | 503 | 保留业务 ID、原 body,更新时间和 nonce 后重试 |
错误响应携带 request_id、retry_after_ms、recovery_action 和可公开的错误原因,默认退避 2、5、15、30 秒,最大 60 秒并加抖动。配置不得通过错误详情泄露。
## 12 分阶段开发和交付物
每阶段完成其必需测试后进入下一阶段,工期由团队人数和供应商适配验证后排定,不用未经评估的天数替代验收条件。
| 阶段 | 工作项 | 可验收的交付物 | 依赖 |
|---|---|---|---|
| P0 兼容保护与契约 | 固定 ID/密钥/代数/租户规则;新增数据结构;所有旧归属入口加入模式门禁;OpenAPI 草案 | 数据迁移演练和旧接口回归通过,新模式尚未对产线开放 | 无 |
| P1 生产登记与认证 | 库存去重、凭据交付/放行、C01/C02、新 HMAC、幂等 guard | 模拟设备可受控登记并重复认证;测试向量与旧设备均通过 | P0 |
| P2 完整绑定流程 | C03 至 C07/C11、handoff、OIDC 接续、申请和确认事务、ACK、收尾页面 | 两个账号并发、回包丢失和断电恢复均不重复绑定;前台显示已绑定待开通 | P1 |
| P3 独立激活 | outbox worker、固定供应商身份、尝试与对账、C08 至 C10、配置 ACK、首通测试 | 明确成功与未知结果均可恢复,只有真实验收后 READY | P2;SDK 凭据模式及供应商行为验证 |
| P4 完整手机入口与发布 | 短信/账号注册、租户策略、正常列表恢复、运维对账、灰度观测 | 文档目标手机流程完成,真实短信和设备联调通过后扩大批次 | P2;P3 通过后才发布完整 AI 可用流程 |
建议按以上阶段拆 PR。P0/P1 可以独立评审,P2 可先用模拟激活验收,不等待真实火山调用。短信适配可在接口固定后与 P3 由团队独立实施,但完整验收必须等真实提供方接入。
完整交付应包含:迁移脚本、API 契约、后端与页面修改、设备调用示例和 HMAC 向量、故障测试记录、灰度及恢复手册、供应商模式验证结论。不能只以新增路由都返回 200 作为完成标准。
## 13 迁移 灰度和回退
1. 发布 P0 兼容保护版本,所有新开关默认关闭,旧设备正常回归。数据库只增加结构,保留原列和旧凭据。
2. 回填旧设备 legacy 标记、既有供应商身份和可验证的绑定历史。不能把旧 ACTIVE 自动当成新 READY;有真实证据才迁入 READY,否则保留旧模式直到完成迁移验收。
3. 在测试租户、单个受控工厂批次启用 enrollment_mode=physical_confirm。已有设备迁移必须核对实际 MAC、身份映射、密钥交付和固件能力,不能靠数据库批量切标记完成。
4. 分别控制新建入口、认领、激活外部执行、恢复查询四类开关。暂停新建不关闭已提交绑定的查询/ACK,不让设备因发布回退而失去恢复原任务的路径。
5. 灰度检查注册成功率、handoff 兑换率、确认成功率、状态保存延迟、激活对账积压、nonce 拒绝和 409 分类;指标只记录关联 ID,不包含秘密。
6. 回退到至少包含 P0 保护和恢复能力的版本;不执行删除新表的 down migration,不把 physical_confirm 设备改回 legacy 来绕过故障。
7. 停止新的供应商调用后,保留 CONTACT_POSSIBLE/RECONCILE_REQUIRED,等待已有调用和对账处理。不得回收可能已经占用的权益。
建议配置分别表达入组范围、入口启用、激活 worker 启用、恢复读取保留和短信发送启用。前端按钮隐藏只控制展示,所有权限与模式门禁在服务端执行。
## 14 测试和验收清单
| 测试类别 | 必测情形 | 通过标准 |
|---|---|---|
| 生产身份 | 同 MAC 重试、登记回包丢失、型号/租户冲突、返修、密钥轮换中断 | 同一设备不变;冲突不覆盖;可恢复原交付任务 |
| 新旧鉴权 | 附件 H 向量、旧 UTF-8 密钥、原始 JSON 空格变化、版本错误、query、超限 JSON | 各自按契约通过;错误不回退另一算法 |
| 时间和重放 | 首次错误时钟、旧挑战、同 nonce、不同 boot_id、Redis 中断 | 不接受旧挑战或跳过防重放,不关闭业务 TLS |
| 幂等和权限 | 同请求同 body、同请求不同 body、缓存命中前停用/换绑、24 小时后旧 claim | 有效操作不重复;旧权限和旧状态不可复活 |
| Handoff | 双兑换、过期、verifier 错误、Cookie 回包丢失、OIDC 回跳 | 一次兑换且可恢复;不把入口当所有权 |
| 短信与租户 | 重发、穷举、供应商超时、注册重试、已有 OIDC 账号、企业租户无邀请 | 不重复建账号、不自动越权、不错误合并身份 |
| 绑定竞争 | 双账号认领、确认与取消并发、权限撤销、申请过期后的按键 | 唯一有效归属;无当前设备确认不能提交 |
| 状态保存 | 提交后丢包、设备 ACK 重复或旧版本、页面 ACK 旧绑定、保存中断电 | 云端绑定不回滚;只承认对应代数和状态证据 |
| 旧入口覆盖 | 绑定码、运维预分配、解绑/转移、恢复出厂、隐私删除 | 新模式无确认绕过;代数和旧上下文一致失效 |
| 激活 worker | 外部调用前崩溃、调用中崩溃、成功后数据库失败、重复 outbox、租约过期 | 同一可能已调用的任务不被自动再次执行 |
| 供应商与权益 | 额度不足、已注册查询、未知结果、人工对账、换绑期间晚到结果 | 保留身份和可能占用权益,证据可追踪 |
| 首通及运行时 | DeviceSecret 模式、测试许可重放、媒体已连无首音频、正常首通、立即挂机 | 不重复注册;无真实测试证据不 READY;待机不采音 |
| 浏览器恢复 | 手机切网、门户关闭、普通浏览器重开、登录超时、设备已绑定后再进旧入口 | 恢复当前状态,不要求重做已成功步骤 |
| 发布回退 | 新旧设备同时在线、停用新建入口、P0 版本回退、恢复服务继续 | 旧设备可用,新设备既有绑定和任务不丢失 |
后端使用 PostgreSQL/Redis 集成测试验证并发与事务,不用 mock 替代唯一约束和进程中断测试。前端使用现有 Playwright 流程验证手机和桌面页面。真实供应商、短信和设备联调记录单列,静态代码或模拟服务通过不等于实机通过。
## 15 关键代码依据
- [运维建档与预分配](E:/Codex/story/platform/internal/opsdevice/service.go:244)
- [当前设备签名验证](E:/Codex/story/platform/internal/deviceauth/verifier.go:69)
- [现有幂等响应直接重放](E:/Codex/story/platform/internal/idempotency/executor.go:145)
- [当前绑定码提交](E:/Codex/story/platform/internal/saas/device_claim.go:27)
- [当前解绑和转移](E:/Codex/story/platform/internal/saas/device_binding_service.go:67)
- [恢复出厂的解绑写入](E:/Codex/story/platform/internal/devicereset/service.go:168)
- [隐私删除中的解绑写入](E:/Codex/story/platform/internal/privacy/delete_processor.go:302)
- [供应商注册当前执行方式](E:/Codex/story/platform/internal/hardwareprovision/service.go:127)
- [可复用的事务 outbox](E:/Codex/story/platform/internal/outbox/store.go:40)
- [现有 OIDC 入口](E:/Codex/story/platform/web/src/auth/oidc.ts:6)
- [当前账号与租户映射](E:/Codex/story/platform/internal/identity/repository.go:71)
- [原始差异分析](E:/Codex/story/platform/docs/首次配网设备认证与绑定_SaaS支撑功能分析.md)
@@ -0,0 +1,278 @@
# 首次配网设备认证与绑定 SaaS 支撑功能分析
分析日期:2026 年 9 月 11 日
依据:附件《AI电话 首次配网 注册与激活 完整流程与详细设计》,整合日期 2026 年 9 月 8 日,公共契约 ONBOARDING-1.1;以及当前工作区 SaaS 的路由、服务、数据库迁移和前端代码。
结论:现有平台具备设备建档、独立密钥、设备鉴权、家庭和使用者权限、绑定关系、设备配置与命令、供应商凭据和审计等基础,但尚未实现附件要求的完整首次接入流程。主要新增工作是可信首次校时、设备注册协议、云端设置会话、手机入口交接、登录后按键确认绑定,以及绑定状态保存回执。激活任务也需要独立的持久化状态和异常恢复机制。
本分析只列 SaaS 要承担的工作及其设备协议边界。Wi-Fi 扫描、密码保存、AP、NAPT、GPIO 和设备存储的具体实现由设备承担。SaaS 不接收家庭 Wi-Fi 密码。本文区分附件要求、当前实现和实施建议;附件中的设计不代表这些功能已经上线。
核对方式:读取正文、附件 A 至 K 和 5 张流程图;静态检查实际路由和实现。附件列出的 18 个云端接口均未在当前 router 中以相同方法和路径注册,但部分能力有现有服务可复用。附件 H 的 HMAC 示例和三组热点名称向量已离线复算一致。没有进行真实设备、短信服务或供应商联调。
## 1 SaaS 应负责的完整业务顺序
1. 受控生产流程登记设备身份和密钥,建立唯一库存记录。
2. 设备联网后请求可信时间,再携带设备认证信息完成注册和状态恢复。
3. SaaS 为未绑定设备创建设置会话,签发短时手机交接入口。
4. 手机兑换入口并登录账号;服务端保存当前设备上下文,提供有权限的使用者列表。
5. 手机选择使用者并创建绑定申请,此时设备尚未归属于该账号。
6. SaaS 向设备下发当前申请的确认挑战;用户按键后,设备回传对应申请的确认。
7. SaaS 在一次事务内提交绑定、消费申请、增加绑定代数、更新状态版本、写入激活待办。
8. 设备保存绑定并回执;手机读取结果并回执。设置入口按收尾条件关闭。
9. AI 激活任务独立继续;失败、页面关闭或设置会话过期,都不撤销已提交绑定。
必须分别展示三个结果:设备身份已认证、账号已绑定、AI 服务是否可用。SaaS 收到 HTTP 请求成功、设备本地保存成功、真实首音频验证成功,也分别记录。
## 2 功能清单与现有实现对照
“可复用”表示存在相应基础逻辑,不表示已经符合 ONBOARDING-1.1 的报文和全部业务约束。
| SaaS 功能 | 附件要求和用途 | 当前实现 | 需要的工作 |
|---|---|---|---|
| 生产身份与库存 | 按完整设备 ID 和出厂 MAC 唯一登记;返修恢复原记录;密钥轮换留审计 | 运维接口可建档、生成随机 UUID 和密钥、轮换凭据 | 增加生产身份映射、MAC 唯一约束和幂等生产登记;区分库存与首次上线 |
| 设备认证 | 按设备和密钥版本验证 DEVREQ1 HMAC,检查时间、nonce、停用和吊销 | 已有 HMAC、时间窗口、防重放、加密密钥存储、状态限制 | 新增 ONBOARDING-1.1 认证适配;现有签名格式不能直接兼容 |
| 首次可信校时 | 用单设备密钥签名时间挑战,设备验签后再严格使用 HTTPS | bootstrap 返回服务器时间,但须先通过现有认证 | 新增 C01;不能把现有 bootstrap 当作无可信时间时的校时入口 |
| 设备注册与恢复 | 只接受已登记的生产身份;重复注册返回原设备、绑定和激活状态 | 设备由运维创建;bootstrap 更新在线报告并返回绑定 | 新增 C02、挑战消费、业务幂等及完整公共状态 |
| 云端设置会话 | 关联设备、启动、本地会话、期限和流程阶段 | 未发现 setup_session 完整实现 | 新增 C03 和设置会话;已绑定设备只进入恢复模式 |
| 手机入口交接 | handoff 与 verifier 摘要匹配,短时、一次性兑换,生成浏览器会话 | 未发现此交接流程 | 新增 C04、Web exchange 和 HTTPS 入口页 |
| 账号登录与使用者权限 | 短信登录或注册、保留 setup、校验有权管理的老人档案 | 已有 OIDC 登录、平台账号映射、家庭成员权限、使用者档案 | 若严格遵循附件需补短信入口与注册映射;也可复用 OIDC,但须修改双方认可的登录契约 |
| 绑定申请 | 登录后固定设备、账号、使用者、绑定代数和挑战;每设备一条有效申请 | 一次性绑定码表存在,但不绑定某次设备按键挑战 | 新增待确认申请;不能直接把现有 claim_code 当作新 claim |
| 设备确认后提交绑定 | 核验 claim、challenge、digest、按键事件和当前权限后原子提交 | 当前 POST /api/v1/device-claims 提交绑定码后直接写绑定 | 新增 C06;保留并扩展行锁、有效绑定唯一约束与审计 |
| 状态版本和保存回执 | 独立 binding_generation、device_revision、local_generation、state_hash;记录设备与页面 ACK | 已有 device.version、activation_revision、配置 revision | 新增明确的绑定代数和状态快照;现有版本不能直接互相替代 |
| 流程命令与进度查询 | CONFIRM_CLAIM、APPLY_ACTIVATION、UPDATE_STATE、CLOSE_SETUP | 已有配置、重启、诊断、OTA 命令 | 增加流程命令及权限、过期、去重规则;通用命令 ACK 不等于绑定确认 |
| 独立激活任务 | 固定设备供应商身份与任务;保存调用许可、尝试结果、未知状态和对账证据 | 已有平台代注册、供应商凭据加密保存和复用 | 新增任务和尝试记录;统一平台代注册与设备 SDK 注册的路线 |
| 中断恢复与设置收尾 | 页面关闭、断电、回包丢失后按原任务恢复;关闭 setup 不解除归属 | 有一般设备查询、解绑、重置和部分请求幂等基础 | 新增 C07/C10/C11、Web status/ack/cancel 及完整恢复规则 |
| 运维处理和验收 | 查阶段、绑定、ACK、激活和失败原因,处理停用、返修、对账 | 设备运维、状态迁移、审计和通用 outbox 已有 | 增加首次接入诊断视图和激活对账;将绑定事件接入 outbox |
## 3 认证和生产登记的具体要求
### 3.1 区分生产登记与设备首次注册
上一轮提取的 `POST /api/v1/ops/devices` 是运维建档入口。附件的 `POST /api/v1/device/register` 是设备证明已有身份并报告上线的入口。两者调用者、鉴权和数据用途不同,不能替换使用。
生产侧至少保存:设备固定编号、出厂 MAC、型号、硬件版本、凭据版本、受控密钥引用、生产及返修记录。设备注册请求不能仅凭公开 MAC 获取新密钥,也不能让请求方自行指定账号归属。
现有 Create 每次生成新 UUID 和随机 serial_digest,没有按输入 MAC 重复登记的能力。建议保留数据库内部 UUID,新增唯一的工厂编号与 MAC 映射,由新协议在边界解析 `KP01-84FCE6123456` 一类编号;无需为匹配外部编号直接重写全部内部外键。
现有 FACTORY 状态不允许通过通用设备认证。若生产登记先进入该状态,还需明确出厂放行到可认证状态的流程,或为新库存认证设置单独的准入规则,不能让新设备被自己的初始状态永久挡住。
### 3.2 HMAC 契约存在实质差异
| 项目 | 附件 ONBOARDING-1.1 | 当前平台 |
|---|---|---|
| 设备编号 | KP01- 加完整 12 位大写 MAC | UUID |
| HMAC 密钥字节 | 原始 32 字节 device_key | 当前运维 Create 返回 64 位 hex 字符串,并把该字符串的 UTF-8 字节作为 HMAC 密钥 |
| 时间字段 | X-Timestamp-Ms,Unix 毫秒 | X-Timestamp,RFC3339 |
| 密钥版本 | X-Key-Version,参与签名和密钥选择 | 密钥库有版本,但当前请求签名不包含版本头 |
| 业务请求号 | X-Request-ID,参与签名和幂等 | 现有五行签名不包含业务请求号;部分业务另用 Idempotency-Key |
| 签名正文 | DEVREQ1 开头的九行,包含设备 ID、版本、请求号;路径不含 query | 方法、包含 query 的 RequestURI、时间、nonce、body 哈希,共五行 |
| 签名传输 | Authorization: Device-HMAC-SHA256 加小写 hex | X-Signature,Base64url 无填充 |
| 时间容差 | ±120 秒 | 默认 ±5 分钟,可配置 |
| nonce | 16 随机字节的 Base64url,按设备和版本保存至少 5 分钟 | 接受 16 至 128 字符;当前按设备和 nonce 防重放 |
Header 名称的大小写不是差异重点;签名所包含的内容、编码、密钥字节和时间含义才是兼容性问题。
建议以独立协议适配层支持新路径,复用密钥加密存储和设备状态查询。旧设备继续使用旧契约,避免在同一入口中静默尝试不同签名算法。密钥迁移需要显式标记编码与版本,不能把当前返回的字符串自动解码后用于旧接口。
### 3.3 首次校时与注册提交
C01 接收公开 device_id、key_version、boot_id、client_nonce,返回只包含时间及挑战的签名响应。未知设备通用拒绝。挑战 120 秒有效,签名域为 `TIME1`,不签发账号、绑定或供应商凭据。
C02 验证设备请求后,原子消费与当前设备、启动关联的 challenge_id,并提交注册结果。同一 request_id、同一原始 body 的已成功重试应返回匹配结果;不同 body 返回冲突。已消费挑战不能阻止读取同一已提交结果,但不能授权一次新注册操作。
## 4 手机登录交接需要新增的功能
SaaS 必须维护独立的 setup 上下文,不能只把 device_id 放入登录页 URL。一次 setup 关联设备、boot_id、本地 local_setup_id、模式、期限和当前阶段;默认 15 分钟,绝对上限 30 分钟。
handoff 默认 90 秒有效,数据库保存 token 哈希,并绑定 setup 与 code_challenge。兑换时核对 verifier 摘要、过期状态和一次性消费状态,再建立浏览器会话。它只证明进入了某次设备流程,不授予设备所有权。
HTTPS 入口页从 URL fragment 读取 token 与 verifier,立即清理地址,再同源 POST 兑换。入口固定白名单、禁止任意跳转,页面不加载第三方统计脚本,凭据响应禁止缓存。SaaS HTTPS 页面不反向请求设备的本地 HTTP 接口。
登录前后都需保留服务端 setup;登录成功后更新会话身份并防止会话固定。按附件实现 Cookie 时,应配置 Secure、HttpOnly 和适合该跳转链路的 SameSite,并执行 Origin/CSRF 检查。短信需按手机号、IP、浏览器流程限流,约束验证码期限和尝试次数。
现有 OIDC 登录和家庭权限可以作为账号基础,但 OIDC 自带的 PKCE 不等于附件中的设备 handoff。若继续复用 OIDC,仍要实现“设备流程交接”和“登录后继续原 setup”。
新用户还需要能创建或进入家庭、创建或选择使用者。现有家庭与档案接口可复用:`POST /api/v1/households`、`GET/POST /api/v1/households/{id}/subjects`。附件没有完整规定账号首次加入租户和家庭的策略,这部分必须补入实施设计。
## 5 绑定事务与状态同步
### 5.1 一次绑定申请固定哪些数据
| 数据 | 为什么需要 |
|---|---|
| device_id、setup_id | 确定当前设备和操作流程 |
| 登录账号、tenant_id、household_id、subject_id | 确定归属范围和使用者;账号从登录态取得 |
| claim_id | 为一次明确申请提供长期业务去重标识 |
| challenge、expires_at | 限定本次物理确认窗口,默认 120 秒 |
| binding_digest | 固定被确认的账号和使用者内容;目标变化就重建申请 |
| expected_binding_generation | 防止旧申请在设备已经换绑后继续生效 |
| press_event_id | 防止旧按键事件重复确认另一笔操作 |
| 状态、取消与消费时间、提交结果引用 | 处理取消、确认、超时和网络重试竞争 |
服务端检查的是经过设备认证的“指定申请确认”,不是相信浏览器上传 `button_pressed=true`。配网前的按键授权也不能作为登录后绑定申请的确认。
### 5.2 在一次事务中完成绑定
建议事务顺序:锁定设备及申请 → 识别同申请的已提交结果 → 核验当前权限、代数和未过期挑战 → 检查唯一有效归属 → 写入绑定 → 绑定代数增加一次 → 消费 claim → 更新 device_revision → 写入激活 outbox 事件和审计 → 提交。
复用当前 device_binding 的有效记录唯一索引和行锁,同时为待确认申请增加唯一约束。取消与确认并发时以事务提交结果为准:取消未完成申请不能删除已提交绑定。
现有绑定码逻辑可以保留为独立入口,但不能绕过新协议的按键确认。新模式需明确哪些设备允许使用旧绑定入口,以及运维强制分配归属是否属于受控例外,否则只增加一个安全入口并不能保证所有绑定都经过同样规则。
重试判断不仅要比较原目标,还要确认绑定仍是当前有效归属。当前旧 claim 的已使用分支会读取原 used_binding_id;新流程必须补上绑定代数和当前状态检查,避免设备已解绑或换绑后再返回旧状态作为可用配置。
### 5.3 两种版本与两种回执
`binding_generation` 只在归属发生变化时递增;`device_revision` 在对设备公开的业务状态变化时递增。`local_generation` 是设备存储版本,由设备报告。普通网络更新不能增加绑定代数,普通配置 revision 也不能冒充绑定代数。
C07 验证 device_revision、binding_generation、local_generation 和 state_hash,记录设备已保存哪一版状态。Web ack 记录手机已看到哪次绑定结果。二者均不改变 owner;设备保存失败也不能回滚服务端绑定。
前台应分别显示“待按键确认”“账号已绑定,设备保存中”“账号已绑定,AI 服务准备中”“AI 服务待处理”“AI 服务可用”。轮询超时后仍能从账号设备列表恢复查看。
## 6 激活支撑与现有路线差异
### 6.1 绑定与激活分别提交
附件要求:一台设备一个当前 activation_job,固定 activation_id 和供应商 device_name;每次实际调用有 attempt_id。绑定事务只写待办,后台再处理外部服务。现有通用 outbox 可复用,但当前 ClaimDevice 并未写入这一类激活待办。
至少需要以下业务状态:NOT_STARTED、RESERVED、CONTACT_POSSIBLE、REGISTERED_OBSERVED、READY、RECONCILE_REQUIRED、BLOCKED。绑定状态则单独使用 UNBOUND、CLAIM_PENDING、BOUND、TRANSFER_PENDING。
当前 FACTORY/CLAIMABLE/BOUND/PROVISIONING/ACTIVE/LOST/REVOKED 是另一组设备生命周期状态。不能仅靠一张枚举映射表就认定两套状态相同,特别是当前 ACTIVE 并没有附件要求的真实首音频验收含义。
### 6.2 外部结果未知时的恢复
外部调用前持久化“可能已调用”的记录;记录许可原始有效期和调用尝试。超时、断电、回包丢失进入 RECONCILE_REQUIRED,禁止自动换 device_name、释放可能占用的名额或并行再次激活。结果成功也应区分 SDK 注册成功和真实通话验证成功。
现有 Provision 在数据库事务和锁持有期间调用 registrar.Register,然后存储凭据并提交。如果供应商已处理但回包丢失,事务可能回滚,尚无附件所需的持久化未知结果和对账任务。确定性的 device_name 与已保存凭据复用可以减少重复身份,但不能替代完整的外部调用结果恢复。
### 6.3 实施前需要统一供应商凭据路线
附件第 8 章和 E.2 描述向设备下发 ProductSecret,由设备执行动态注册。当前代码把 ProductSecret 保留在 SaaS,由平台注册后只向设备下发 DeviceSecret。这是执行位置与凭据边界的差异,不能用增加几个返回字段掩盖。
实施建议:优先在现有平台代注册方案上补持久化任务、尝试记录和对账,并同步修订附件中的激活契约。若最终明确选择设备执行注册,则按该路线重新设计调用许可、配置封装和结果回执。两种路线的供应商行为都需要针对实际 SDK/接口版本验证,本文不据文档宣称其已通过实机验证。
代码中还有 License 内容导入、分配和 ACK 相关模块;当前主 router 未接入相应 License ACK/分配闭环,不能视为已有 ONBOARDING-1.1 的授权对账能力。附件采用权益或名额管理,也没有定义向设备交付 License 文件。应先统一授权模型,再确定复用范围。
## 7 附件明确要求的云端接口
下列 18 条均为附件规定的目标接口,当前没有同路径路由。生产登记、运维管理以及已有家庭/使用者管理接口不包含在这个数量中。
### 7.1 设备调用 SaaS
除 C01 使用专用时间签名协议外,均按附件 B 使用设备 HMAC 和 HTTPS。表中字段为 body,设备 ID、密钥版本和请求号等还须按契约放入认证头。
| 编号 | POST 路径 | 核心输入 | SaaS 负责输出或提交 |
|---|---|---|---|
| C01 | /api/v1/device/time-challenges | device_id、key_version、boot_id、client_nonce | 签名时间与短时 challenge_id |
| C02 | /api/v1/device/register | challenge_id、boot_id、hardware_model、hardware_revision、firmware_version、sdk_version | 认证已有生产身份;返回当前状态和契约版本 |
| C03 | /api/v1/device/setup-sessions | boot_id、local_setup_id、transport_profile=LOCAL_WEB | setup_id、期限、轮询间隔、状态 |
| C04 | /api/v1/device/handoffs | setup_id、local_setup_id、code_challenge | 一次性 token、期限、固定 HTTPS 入口 |
| C05 | /api/v1/device/poll | setup_id、boot_id、known_revision | 当前状态、有效流程命令、轮询间隔 |
| C06 | /api/v1/device/claim-confirmations | claim_id、challenge、binding_digest、expected_binding_generation、press_event_id | 提交绑定或返回同一已提交结果 |
| C07 | /api/v1/device/state-acks | device_revision、binding_generation、local_generation、state_hash | 保存设备落盘证据及可否收尾 |
| C08 | /api/v1/device/activation-attempts | activation_id、config_version、attempt_id | 持久化可能调用状态,返回 30 秒执行许可 |
| C09 | /api/v1/device/activation-results | activation_id、attempt_id、stage、sdk_result、outcome | 更新注册/通话验收状态或转入对账 |
| C10 | /api/v1/device/activation-acks | activation_id、local_generation、config_version | 保存激活配置落盘回执 |
| C11 | /api/v1/device/setup-close | setup_id、reason、final_revision | 关闭临时流程,保留绑定和激活任务 |
公共状态包含 device_id、device_revision、binding_generation、binding_state、activation_state、next_action、state_hash。C05 的状态恢复权限不能随手机 token 或原 setup 过期而消失;需要时先创建仅用于恢复的新 setup。
### 7.2 手机调用 SaaS
| POST 路径 | 核心输入或上下文 | 用途 |
|---|---|---|
| /api/v1/web/handoffs/exchange | token、verifier | 登录前兑换一次性入口,创建当前 setup 的浏览器会话 |
| /api/v1/web/auth/sms/send | 手机号和当前流程;附件未完整定义字段 schema | 发送登录验证码并限流 |
| /api/v1/web/auth/sms/verify | 手机号、验证码和当前流程;附件未完整定义字段 schema | 验证、登录或注册,更新会话并保留 setup |
| /api/v1/web/claims | elder_id、expected_binding_generation、登录会话 | 创建待设备按键确认的绑定申请 |
| /api/v1/web/setup/status | 浏览器当前 setup | 查询自己的绑定和激活进度 |
| /api/v1/web/setup/ack | 浏览器当前 setup 及收到结果的上下文 | 确认页面已收到结果 |
| /api/v1/web/claims/cancel | 当前 setup 的待确认申请 | 取消待确认申请;不解绑已经归属的设备 |
设备收到的 masked_account 只用于提示核对,不接受它作为认证依据。用户身份从登录会话取得;不能信任浏览器提交的 owner_user_id,也不能允许浏览器仅凭任意 device_id 查询其他家庭的状态。
### 7.3 可复用的当前入口
| 当前接口或模块 | 可复用范围 | 无法直接替代的部分 |
|---|---|---|
| POST /api/v1/ops/devices | 运维建档、初始平台凭据 | 生产 MAC 幂等登记和 C02 设备认证注册 |
| POST /api/v1/ops/devices/{id}/credentials/rotate | 受控凭据更新 | ONBOARDING-1.1 按版本认证和产线恢复协议 |
| GET /device/v2/bootstrap | 在线报告、当前绑定和固件兼容检查 | 首次时间挑战、setup、绑定代数与完整恢复状态 |
| GET /device/v2/config | 配置查询与版本基础 | CFG1 配置、绑定代数和明确的本地保存 ACK |
| POST /api/v1/device-claims | 家庭权限、绑定码、绑定写入与唯一约束 | 登录后的待确认申请及实体确认挑战 |
| GET /device/v2/commands 及命令 ACK | 命令投递和回执基础 | 四种首次接入命令与具体绑定确认语义 |
| POST /device/v3/hardware-agent-credentials:provision | 平台代注册、固定设备名、加密凭据及复用 | 独立激活任务、执行许可、未知结果对账和 READY 验收 |
| binding-impact / binding-change | 当前家庭权限、解绑与变更前检查 | 新确认流程、绑定代数与所有旧入口失效策略 |
| OIDC、家庭和使用者服务 | 账号认证、成员权限、档案管理 | 手机 handoff、setup Cookie、附件指定短信 schema |
## 8 数据模型和后台页面
以下是逻辑记录划分,可按现有结构扩展,不要求每一项都另建一张表。
| 逻辑记录 | 关键数据和约束 |
|---|---|
| 生产库存和凭据 | 唯一工厂 ID/MAC、内部设备 UUID、租户归属、型号、密钥引用及版本、生产/返修状态 |
| 时间挑战 | 设备、版本、boot_id、client_nonce、签发/失效/消费时间及注册结果关联 |
| 设备当前状态 | 独立绑定和激活状态、device_revision、binding_generation、当前任务、最后在线时间 |
| 设置会话和交接入口 | setup/local_setup/boot 关联、模式、权限、绝对期限、token 哈希、verifier 摘要、消费状态 |
| 绑定申请和绑定历史 | 目标账号/家庭/使用者、挑战、digest、按键事件、期限、代数;唯一有效申请和归属 |
| 状态快照和回执 | 状态 hash、设备保存版本、页面看到的结果、ACK 时间;旧版本不能覆盖新版本 |
| 激活任务和尝试 | 固定供应商身份、权益预留、attempt、许可期限、调用阶段、结果证据、待对账原因 |
| 幂等结果和 outbox | 设备/接口/请求号作用域、原始 body 摘要、提交结果;长期业务唯一键;待办重试和消费去重 |
| 审计记录 | 操作者、设备、setup、claim、activation/attempt、旧/新状态及错误码;不记录密钥和验证码 |
用户侧至少需要:入口恢复页、登录页、家庭/使用者选择页、等待按键确认页、绑定完成及设备保存状态、AI 服务进度、正常账号设备列表。
运维侧至少需要:按工厂编号/MAC 查库存与设备、出厂放行与凭据轮换、首次接入阶段诊断、当前绑定及历史代数、设备/页面 ACK 状态、激活异常与人工对账记录、停用和报失处置。
## 9 附件在当前 SaaS 中还需明确的设计点
1. **租户和 owner 映射。** 当前设备、家庭和档案受租户约束,归属主要通过 household/subject 表达。附件没有规定新手机号注册到哪个租户、能否认领其他租户库存,以及 owner 是家庭还是单一账号。建议按生产归属解析租户,并继续验证家庭管理权限;不要直接让手机号或 elder_id 决定租户。
2. **身份编码迁移。** 外部 KP01 编号与现有 UUID 保持稳定映射;产线、认证、售后和旧客户端不得各自生成新身份。
3. **绑定代数的变更规则。** 附件规定归属变化递增,但需要进一步固定“解绑后再绑定”和“一步转移”的递增及历史记录规则。所有写入口必须统一。
4. **普通重新配网与恢复出厂。** 当前 /device/v3/factory-reset 会解除绑定并生成新绑定码。附件规定普通重配网保留 owner,转让走原账号授权。因此普通重配网不能调用此接口,完整恢复出厂的归属权限也需要单独定规则。
5. **激活执行位置与授权模型。** 统一平台代注册还是设备 SDK 注册、ProductSecret 的位置、名额与供应商权益的对应关系,以及什么证据足以解除未知结果状态。
6. **接口详细 schema。** 短信输入、取消目标、浏览器 ACK 的版本匹配、部分恢复错误及晚到结果规则仍需写入 OpenAPI。公共约定要求变更请求带 X-Request-ID,浏览器这些写操作也应明确其用法。
7. **状态快照和 ACK。** 明确 device_revision 对应的历史状态保留策略;当前状态继续推进时,设备应同步最新版本,不能把旧 ACK 写成新版本已保存。
8. **存量入口。** 现有绑定码、运维分配、解绑转移和恢复出厂均会影响归属。新协议不能只覆盖新页面而遗漏其他写入口。
## 10 建议开发顺序与验收
第一阶段先完成认证与绑定:生产身份映射 → 新鉴权与时间挑战 → 注册和公共状态 → setup/handoff → 登录与使用者选择 → 待确认申请 → 设备确认事务 → 两侧回执与收尾。此阶段可返回“已绑定,AI 服务待开通”,不阻塞于真实供应商联调。
第二阶段补齐激活:绑定 outbox → 固定激活任务与身份 → 调用尝试和许可 → 结果上报与对账 → 激活配置 ACK → 首音频验收后的 READY。
第三阶段完成运营闭环:停用和密钥轮换、返修、原账号解绑与新账号确认、旧代数隔离、失败恢复入口、诊断视图及生产环境限流监控。
必须覆盖的验收结果:
- 同 MAC/工厂 ID 重复生产登记和重复上线,不增加设备副本,也不清空已有绑定。
- 错密钥、错版本、过期时间、重放 nonce 被拒绝;同请求号不同 body 返回冲突。
- handoff 仅能兑换一次,过期不能复活;登录和浏览器切换后可恢复正确设备上下文。
- 无设备确认不能绑定;双账号并发仅一条有效申请和一个有效归属。
- 旧 challenge、旧按键、被取消 claim、旧绑定代数不能建立新归属。
- 绑定提交后回包丢失,返回同一有效结果;设备保存失败只补写和 ACK,不撤销绑定。
- 页面关闭、setup 到期或热点关闭后,设备仍能恢复原激活任务。
- 供应商结果未知保留原任务、设备名和可能占用的权益;不自动释放后再次分配。
- SDK 注册成功与真实通话 READY 分开;授权不足保留账号绑定。
- 普通重配网保留归属;正式解绑或转移使旧会话、旧申请和旧用户配置失效。
## 11 当前代码依据
- [实际路由与设备鉴权分组](E:/Codex/story/platform/internal/httpapi/router.go:297)
- [运维登记设备和生成平台凭据](E:/Codex/story/platform/internal/opsdevice/service.go:244)
- [当前设备 HMAC 验证和签名正文](E:/Codex/story/platform/internal/deviceauth/verifier.go:69)
- [设备凭据加密存储与轮换](E:/Codex/story/platform/internal/deviceauth/database_secret.go:115)
- [bootstrap 的现有状态字段和报告](E:/Codex/story/platform/internal/device/repository.go:32)
- [当前提交绑定码后直接绑定的逻辑](E:/Codex/story/platform/internal/saas/device_claim.go:27)
- [有效设备绑定唯一约束](E:/Codex/story/platform/migrations/000005_device_conversation.up.sql:65)
- [当前 claim 数据结构](E:/Codex/story/platform/migrations/000007_device_claim.up.sql:3)
- [当前设备生命周期状态](E:/Codex/story/platform/internal/deviceactivation/state.go:15)
- [当前解绑和转移事务](E:/Codex/story/platform/internal/saas/device_binding_service.go:67)
- [当前命令类型约束](E:/Codex/story/platform/migrations/000011_device_command.up.sql:8)
- [平台代注册与供应商凭据保存](E:/Codex/story/platform/internal/hardwareprovision/service.go:127)
- [License 模块](E:/Codex/story/platform/internal/hardwarelicense/service.go:250)
- [恢复出厂解除绑定](E:/Codex/story/platform/internal/devicereset/service.go:168)
- [现有前端 OIDC 登录](E:/Codex/story/platform/web/src/auth/oidc.ts:6)
- [通用 outbox 执行基础](E:/Codex/story/platform/internal/outbox/worker.go:1)
+24 -1
View File
@@ -36,6 +36,7 @@ import (
"data-terminal-cloud/internal/memorytool"
"data-terminal-cloud/internal/objectstore"
"data-terminal-cloud/internal/observability"
"data-terminal-cloud/internal/onboarding"
"data-terminal-cloud/internal/opsaccess"
"data-terminal-cloud/internal/opsconfig"
"data-terminal-cloud/internal/opsdevice"
@@ -130,7 +131,8 @@ func Run(ctx context.Context, serviceName string) error {
}
tokenVerifier := identity.NewOIDCVerifier(ctx, cfg.AuthIssuerURL, cfg.AuthJWKSURL, cfg.AuthAudience)
identityRepository := identity.NewRepository(databasePool)
var deviceVerifier *deviceauth.Verifier
var deviceVerifier httpapi.DeviceRequestVerifier
var onboardingService *onboarding.Service
var deviceRepository *device.Repository
var deviceTelemetryService *device.TelemetryService
var conversationCreator *orchestration.Service
@@ -344,6 +346,22 @@ func Run(ctx context.Context, serviceName string) error {
if err != nil {
return fmt.Errorf("create rate limiter: %w", err)
}
if (serviceName == "api" || serviceName == "worker") && cfg.Onboarding.MasterKey != "" {
var sms onboarding.SMSProvider
if cfg.Onboarding.SMSLoginEnabled {
sms, err = onboarding.NewHTTPSMSProvider(cfg.Onboarding.SMSGatewayURL, cfg.Onboarding.SMSGatewayToken)
if err != nil {
return err
}
}
onboardingService, err = onboarding.NewService(databasePool, redisClient, onboarding.Options{Enabled: cfg.Onboarding.Enabled, PauseClaims: cfg.Onboarding.PauseClaims, MasterKey: cfg.Onboarding.MasterKey, EntryURL: cfg.Onboarding.EntryURL, AllowLocalHTTP: cfg.Onboarding.AllowLocalHTTP, TrustedProxyCIDRs: cfg.Onboarding.TrustedProxyCIDRs, TokenVerifier: tokenVerifier, LegacyVerifier: deviceVerifier, ActivationEnabled: cfg.Onboarding.ActivationEnabled, ProviderMasterKey: cfg.VolcHardwareDeviceCredentialMasterKey, InstanceID: cfg.VolcHardwareInstanceID, ProductKey: cfg.VolcHardwareProductKey, ProductSecret: cfg.VolcHardwareProductSecret, BotID: cfg.VolcHardwareAgentBotID, Transport: cfg.VolcHardwareTransport, MinimumSDKVersion: cfg.VolcHardwareMinimumSDKVersion, SMS: sms, SMSLoginEnabled: cfg.Onboarding.SMSLoginEnabled, SelfSignupTenantIDs: cfg.Onboarding.SelfSignupTenantIDs, Logger: logger})
if err != nil {
return fmt.Errorf("create onboarding service: %w", err)
}
if serviceName == "api" {
deviceVerifier = onboardingService
}
}
if serviceName == "worker" {
webEventService = webevents.NewService(databasePool)
outbox.RegisterMetrics(databasePool)
@@ -354,6 +372,10 @@ func Run(ctx context.Context, serviceName string) error {
outbox.WorkerOptions{PollInterval: cfg.WorkerPollInterval},
)
outboxWorker.Register("platform.noop", func(context.Context, outbox.Event) error { return nil })
if onboardingService != nil {
outboxWorker.Register(onboarding.ActivationEvent, onboardingService.ProcessActivation)
go onboardingService.RunMaintenance(ctx)
}
alertWebhookProcessor, processorErr := alertnotification.NewProcessor(cfg.AlertWebhookURL, cfg.AlertWebhookSecret, cfg.AlertWebhookTimeout)
if processorErr != nil {
return fmt.Errorf("create alert webhook processor: %w", processorErr)
@@ -520,6 +542,7 @@ func Run(ctx context.Context, serviceName string) error {
}()
}
router := httpapi.NewRouter(httpapi.RouterOptions{
OnboardingService: onboardingService,
ServiceName: serviceName,
Logger: logger,
RateLimiter: rateLimiterAdapter{limiter},
+10 -1
View File
@@ -23,6 +23,7 @@ const (
)
type Config struct {
Onboarding OnboardingConfig
VikingResourceID string
VikingCollectionName string
VikingAPIKey string
@@ -254,6 +255,7 @@ func Load(serviceName string) (Config, error) {
}
environment := stringEnv("APP_ENV", EnvironmentLocal)
secretValues, err := loadSecretValues(
"ONBOARDING_MASTER_KEY", "ONBOARDING_SMS_GATEWAY_TOKEN",
"DATABASE_URL", "REDIS_PASSWORD", "DEVICE_LOCAL_SECRET", "DEVICE_CREDENTIAL_MASTER_KEY", "MODEL_CREDENTIAL_MASTER_KEY", "RTC_APP_KEY", "RTC_TOKEN_SIGNING_SECRET",
"CURSOR_SIGNING_SECRET", "SHARE_TOKEN_SIGNING_SECRET", "PROVIDER_CALLBACK_SECRET", "PLATFORM_HEALTH_CALLBACK_SECRET",
"VOLC_SUBTITLE_CALLBACK_SECRET", "VIKING_MEMORY_API_KEY",
@@ -485,6 +487,10 @@ func Load(serviceName string) (Config, error) {
return Config{}, err
}
cfg.Onboarding, err = loadOnboardingConfig(secretValues)
if err != nil {
return Config{}, err
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}
@@ -492,6 +498,9 @@ func Load(serviceName string) (Config, error) {
}
func (c Config) Validate() error {
if err := c.Onboarding.validate(c.Environment); err != nil {
return err
}
if c.SubtitleCallbackURL != "" || c.SubtitleCallbackSecret != "" || c.SubtitlePrimary {
u, err := url.Parse(c.SubtitleCallbackURL)
if err != nil || u.Scheme != "https" || u.Host == "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil || len(c.SubtitleCallbackSecret) < 32 {
@@ -607,7 +616,7 @@ func (c Config) Validate() error {
if c.VolcHardwareTransport != "high_quality" && c.VolcHardwareTransport != "low_load" {
return errors.New("VOLC_HARDWARE_AGENT_TRANSPORT must be high_quality or low_load")
}
if c.ServiceName == "api" && c.VolcHardwareProvisioningEnabled {
if (c.ServiceName == "api" && c.VolcHardwareProvisioningEnabled) || ((c.ServiceName == "api" || c.ServiceName == "worker") && c.Onboarding.ActivationEnabled) {
providerKey, err := hex.DecodeString(c.VolcHardwareDeviceCredentialMasterKey)
if err != nil || len(providerKey) != 32 {
return errors.New("VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY must be 32 bytes encoded as 64 hexadecimal characters")
+77
View File
@@ -0,0 +1,77 @@
package config
import (
"encoding/hex"
"errors"
"github.com/google/uuid"
"net/netip"
"net/url"
"os"
"strings"
)
type OnboardingConfig struct {
Enabled, PauseClaims, ActivationEnabled, SMSLoginEnabled, AllowLocalHTTP bool
MasterKey, EntryURL, SMSGatewayURL, SMSGatewayToken string
TrustedProxyCIDRs, SelfSignupTenantIDs []string
}
func loadOnboardingConfig(secrets map[string]string) (OnboardingConfig, error) {
c := OnboardingConfig{MasterKey: secrets["ONBOARDING_MASTER_KEY"], SMSGatewayToken: secrets["ONBOARDING_SMS_GATEWAY_TOKEN"], EntryURL: strings.TrimSpace(os.Getenv("ONBOARDING_ENTRY_URL")), SMSGatewayURL: strings.TrimSpace(os.Getenv("ONBOARDING_SMS_GATEWAY_URL"))}
for name, target := range map[string]*bool{"ONBOARDING_ENABLED": &c.Enabled, "ONBOARDING_PAUSE_CLAIMS": &c.PauseClaims, "ONBOARDING_ACTIVATION_ENABLED": &c.ActivationEnabled, "ONBOARDING_SMS_LOGIN_ENABLED": &c.SMSLoginEnabled, "ONBOARDING_ALLOW_LOCAL_HTTP": &c.AllowLocalHTTP} {
v, err := boolEnv(name, false)
if err != nil {
return c, err
}
*target = v
}
split := func(name string) []string {
items := []string{}
for _, v := range strings.Split(os.Getenv(name), ",") {
if strings.TrimSpace(v) != "" {
items = append(items, strings.TrimSpace(v))
}
}
return items
}
c.TrustedProxyCIDRs = split("ONBOARDING_TRUSTED_PROXY_CIDRS")
c.SelfSignupTenantIDs = split("ONBOARDING_SELF_SIGNUP_TENANT_IDS")
return c, nil
}
func (c OnboardingConfig) validate(environment string) error {
if c.AllowLocalHTTP && environment != EnvironmentLocal && environment != EnvironmentTest {
return errors.New("ONBOARDING_ALLOW_LOCAL_HTTP is limited to local/test")
}
if c.MasterKey == "" {
if c.Enabled || c.ActivationEnabled || c.SMSLoginEnabled {
return errors.New("onboarding requires ONBOARDING_MASTER_KEY")
}
return nil
}
key, err := hex.DecodeString(c.MasterKey)
if err != nil || len(key) != 32 {
return errors.New("ONBOARDING_MASTER_KEY must encode 32 bytes as hex")
}
u, err := url.Parse(c.EntryURL)
if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Scheme != "https" && !(c.AllowLocalHTTP && u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"))) {
return errors.New("ONBOARDING_ENTRY_URL must be a fixed HTTPS URL")
}
for _, cidr := range c.TrustedProxyCIDRs {
p, err := netip.ParsePrefix(cidr)
if err != nil || p.Bits() == 0 {
return errors.New("onboarding trusted proxies must be explicit non-global CIDRs")
}
}
for _, id := range c.SelfSignupTenantIDs {
if _, err := uuid.Parse(id); err != nil {
return errors.New("onboarding signup tenants must be UUIDs")
}
}
if c.SMSLoginEnabled {
u, err := url.Parse(c.SMSGatewayURL)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || c.SMSGatewayToken == "" {
return errors.New("onboarding SMS requires HTTPS gateway and token")
}
}
return nil
}
+7
View File
@@ -124,6 +124,13 @@ func Apply(ctx context.Context, tx pgx.Tx, input Input) (Result, error) {
if err != nil {
return Result{}, fmt.Errorf("parse stored device activation state: %w", err)
}
var enrollmentMode string
if err := tx.QueryRow(ctx, `SELECT enrollment_mode FROM device WHERE id=$1::uuid`, input.DeviceID).Scan(&enrollmentMode); err != nil {
return Result{}, err
}
if enrollmentMode == "physical_confirm" && input.Source != "onboarding" && (target == Active || target == Bound || target == Provisioning) {
return Result{}, ErrTransitionRejected
}
if input.ExpectedRevision != nil && revision != *input.ExpectedRevision {
return Result{}, ErrRevisionConflict
}
+3
View File
@@ -7,6 +7,9 @@ type contextKey string
const identityKey contextKey = "device-identity"
type Identity struct {
EnrollmentMode string
BindingGeneration int64
ActivationTest bool
ID string
PublicID string
TenantID string
+39
View File
@@ -0,0 +1,39 @@
// Package devicebinding owns compatibility gates shared by all ownership writers.
// Generation advancement and claim fencing are enforced by the database trigger
// so background deletion and operator mutations cannot omit invalidation.
package devicebinding
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
)
var ErrConfirmationRequired = errors.New("device physical confirmation workflow required")
type Queryer interface {
QueryRow(context.Context, string, ...any) pgx.Row
}
func RequireLegacy(ctx context.Context, q Queryer, deviceID string) error {
var mode string
err := q.QueryRow(ctx, `SELECT enrollment_mode FROM device WHERE id=$1::uuid`, deviceID).Scan(&mode)
if err != nil {
return err
}
if mode != "legacy" {
return ErrConfirmationRequired
}
return nil
}
func RequireUnboundForReset(ctx context.Context, q Queryer, deviceID string) error {
var allowed bool
err := q.QueryRow(ctx, `SELECT enrollment_mode='legacy' OR NOT EXISTS(SELECT 1 FROM device_binding WHERE device_id=d.id AND unbound_at IS NULL) FROM device d WHERE id=$1::uuid`, deviceID).Scan(&allowed)
if err != nil {
return err
}
if !allowed {
return ErrConfirmationRequired
}
return nil
}
+69 -5
View File
@@ -16,6 +16,7 @@ import (
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/deviceactivation"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/devicebinding"
"data-terminal-cloud/internal/idempotency"
"github.com/google/uuid"
@@ -48,6 +49,7 @@ type Claim struct {
}
type Response struct {
BindingGeneration int64 `json:"binding_generation,omitempty"`
ResetID string `json:"reset_id"`
DeviceID string `json:"device_id"`
ActivationState string `json:"activation_state"`
@@ -59,7 +61,7 @@ type Response struct {
EndedConversationCount int `json:"ended_conversation_count"`
CancelledCommandCount int64 `json:"cancelled_command_count"`
ConfigRevision int64 `json:"config_revision"`
Claim Claim `json:"claim"`
Claim *Claim `json:"claim,omitempty"`
LocalActions []string `json:"local_actions"`
ResetAt time.Time `json:"reset_at"`
}
@@ -102,11 +104,40 @@ func (s *Service) Reset(ctx context.Context, identity deviceauth.Identity, idemp
return Response{}, false, ErrPendingTranscripts
}
tenantID := identity.TenantID
result, err := s.executor.Execute(ctx, idempotency.Request{
if err := devicebinding.RequireUnboundForReset(ctx, s.pool, identity.ID); err != nil {
return Response{}, false, err
}
guard := func(ctx context.Context, tx pgx.Tx, cached *idempotency.Result) error {
var mode string
var generation, counter int64
if err := tx.QueryRow(ctx, `SELECT enrollment_mode,binding_generation,factory_reset_counter FROM device WHERE id=$1::uuid FOR UPDATE`, identity.ID).Scan(&mode, &generation, &counter); err != nil {
return err
}
if mode != "physical_confirm" {
return nil
}
if generation != identity.BindingGeneration {
return ErrStateConflict
}
if err := devicebinding.RequireUnboundForReset(ctx, tx, identity.ID); err != nil {
return err
}
if cached != nil {
var previous Response
if err := json.Unmarshal(cached.Body, &previous); err != nil {
return err
}
if counter != input.ResetCounter || generation != previous.BindingGeneration {
return ErrResetCounterConflict
}
}
return nil
}
result, err := s.executor.ExecuteGuarded(ctx, idempotency.Request{
TenantID: &tenantID, ActorKey: "device:" + identity.PublicID,
Scope: "POST /device/v3/factory-reset", Key: idempotencyKey,
RequestHash: idempotency.Hash(rawRequest), ExpiresAfter: 7 * 24 * time.Hour,
}, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
}, guard, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
response, resetErr := s.reset(operationContext, tx, identity, requestID, input)
if resetErr != nil {
return idempotency.Result{}, resetErr
@@ -145,6 +176,9 @@ func (s *Service) reset(ctx context.Context, tx pgx.Tx, identity deviceauth.Iden
if input.ResetCounter <= currentCounter {
return Response{}, ErrResetCounterConflict
}
if err := devicebinding.RequireUnboundForReset(ctx, tx, identity.ID); err != nil {
return Response{}, err
}
var activeFirmware int
if err := tx.QueryRow(ctx, `
@@ -233,10 +267,35 @@ func (s *Service) reset(ctx context.Context, tx pgx.Tx, identity deviceauth.Iden
return Response{}, fmt.Errorf("record factory reset counter: %w", err)
}
claim, err := issueClaim(ctx, tx, identity, now)
if err != nil {
var claim *Claim
var mode string
if err = tx.QueryRow(ctx, `SELECT enrollment_mode FROM device WHERE id=$1::uuid`, identity.ID).Scan(&mode); err != nil {
return Response{}, err
}
if mode == "legacy" {
value, e := issueClaim(ctx, tx, identity, now)
if e != nil {
return Response{}, e
}
claim = &value
} else {
_, err = tx.Exec(ctx, `UPDATE device SET binding_generation=binding_generation+1,onboarding_revision=onboarding_revision+1,onboarding_boot_id=NULL WHERE id=$1::uuid`, identity.ID)
if err != nil {
return Response{}, err
}
_, err = tx.Exec(ctx, `UPDATE device_setup_session SET closed_at=COALESCE(closed_at,now()),close_reason='FACTORY_RESET' WHERE device_id=$1::uuid`, identity.ID)
if err != nil {
return Response{}, err
}
_, err = tx.Exec(ctx, `UPDATE device_binding_claim SET status='CANCELLED',completed_at=now() WHERE device_id=$1::uuid AND status='PENDING'`, identity.ID)
if err != nil {
return Response{}, err
}
_, err = tx.Exec(ctx, `UPDATE onboarding_web_session SET revoked_at=COALESCE(revoked_at,now()) WHERE setup_id IN (SELECT id FROM device_setup_session WHERE device_id=$1::uuid)`, identity.ID)
if err != nil {
return Response{}, err
}
}
response := Response{
ResetID: input.ResetID, DeviceID: identity.PublicID, ActivationState: string(deviceactivation.Claimable),
ResetCounter: input.ResetCounter, BindingRemoved: bindingRemoved, PreviousBindingID: previousBindingID,
@@ -245,6 +304,11 @@ func (s *Service) reset(ctx context.Context, tx pgx.Tx, identity deviceauth.Iden
ConfigRevision: configRevision, Claim: claim, ResetAt: now,
LocalActions: []string{"clear_volc_credential", "clear_user_context", "clear_session_state", "clear_network_credentials", "reboot_to_claimable"},
}
if mode == "physical_confirm" {
if err = tx.QueryRow(ctx, `SELECT binding_generation FROM device WHERE id=$1::uuid`, identity.ID).Scan(&response.BindingGeneration); err != nil {
return Response{}, err
}
}
actorID := identity.PublicID
if err := s.audit.Record(ctx, tx, audit.Event{
TenantID: &identity.TenantID, ActorType: "device", ActorPublicID: &actorID,
+4
View File
@@ -16,6 +16,7 @@ import (
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/deviceactivation"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/devicebinding"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
@@ -140,6 +141,9 @@ func (s *Service) Provision(ctx context.Context, identity deviceauth.Identity, r
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, identity.ID); err != nil {
return ProvisionResponse{}, fmt.Errorf("lock hardware provider provisioning: %w", err)
}
if err := devicebinding.RequireLegacy(ctx, tx, identity.ID); err != nil {
return ProvisionResponse{}, err
}
activationState, err := s.prepareActivation(ctx, tx, identity, requestID)
if err != nil {
return ProvisionResponse{}, err
@@ -34,7 +34,7 @@ func TestAlertHandlersWriteAndReadLifecycle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
principal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
principal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
tenantID := "10000000-0000-0000-0000-000000000010"
routeKey := "http-alert-route-" + uuid.NewString()
deliveryTestKey := "http-alert-delivery-test-" + uuid.NewString()
@@ -35,8 +35,8 @@ func TestBillingHandlersPriceQuotaAndSummary(t *testing.T) {
if err != nil {
t.Fatal(err)
}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
approver := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000102"}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
approver := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000105"}
tenantID := "10000000-0000-0000-0000-000000000010"
priceKey, quotaKey := "http-billing-price-"+uuid.NewString(), "http-billing-quota-"+uuid.NewString()
sourceRef := "integration://http-billing/" + uuid.NewString()
@@ -488,6 +488,8 @@ func writeCreateConversationError(writer http.ResponseWriter, request *http.Requ
})
case errors.Is(err, orchestration.ErrDeviceNotBound):
WriteError(writer, request, http.StatusConflict, "DEVICE_NOT_BOUND", "设备尚未绑定有效长者")
case errors.Is(err,orchestration.ErrOnboardingState):
WriteError(writer,request,http.StatusConflict,"ONBOARDING_STATE_CONFLICT","绑定状态或首通许可已变化,请同步当前设备状态")
case errors.Is(err, orchestration.ErrConsentRequired):
WriteError(writer, request, http.StatusForbidden, "CONSENT_REQUIRED", "缺少本次语音通话所需授权")
case errors.Is(err, orchestration.ErrFirmwareUpdateRequired):
+1 -1
View File
@@ -47,7 +47,7 @@ func RequireDeviceActivation(allowed []string, next http.Handler) http.Handler {
if state == "" {
state = "ACTIVE"
}
if _, ok := allowedSet[state]; !ok {
if _, ok := allowedSet[state]; !ok && !(identity.EnrollmentMode == "physical_confirm" && identity.ActivationTest) {
WriteAPIError(w, r, http.StatusConflict, Error{Code: "DEVICE_ACTIVATION_REQUIRED", Message: "当前设备激活状态不能执行该操作", Details: map[string]any{"activation_state": state, "allowed_states": allowed}})
return
}
@@ -10,6 +10,7 @@ import (
"strings"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/devicebinding"
"data-terminal-cloud/internal/devicereset"
"data-terminal-cloud/internal/idempotency"
)
@@ -50,6 +51,8 @@ func DeviceFactoryResetHandler(service DeviceFactoryResetService) http.Handler {
}
if err != nil {
switch {
case errors.Is(err, devicebinding.ErrConfirmationRequired):
WriteError(writer, request, http.StatusConflict, "DEVICE_RESET_AUTHORIZATION_REQUIRED", "请先由当前家庭授权解绑,随后再执行设备重置")
case errors.Is(err, devicereset.ErrPendingTranscripts):
WriteError(writer, request, http.StatusConflict, "DEVICE_RESET_PENDING_TRANSCRIPTS", "设备仍有未上传的聊天记录")
case errors.Is(err, devicereset.ErrFirmwareInProgress):
@@ -36,7 +36,7 @@ func TestDeviceFactoryResetHandlerReturnsClaimableState(t *testing.T) {
ResetID: "10000000-0000-0000-0000-000000000901", DeviceID: "10000000-0000-0000-0000-000000000512",
ActivationState: "CLAIMABLE", ResetCounter: 2, BindingRemoved: true,
ProviderCredentialStatus: "SUSPENDED", LicenseAction: "RETAINED",
Claim: devicereset.Claim{Code: "ABCD-EFGH-IJKL-MNOP", IssuedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(24 * time.Hour)},
Claim: &devicereset.Claim{Code: "ABCD-EFGH-IJKL-MNOP", IssuedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(24 * time.Hour)},
}}
body := validDeviceFactoryResetBody()
request := httptest.NewRequest(http.MethodPost, "/device/v3/factory-reset", strings.NewReader(body))
@@ -33,7 +33,7 @@ func TestDiagnosticHandlersSearchRefreshJobsAndBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
tenantID := "10000000-0000-0000-0000-000000000010"
deviceID := "10000000-0000-0000-0000-000000000511"
refreshKey := "http-diagnostic-refresh-" + uuid.NewString()
+5 -2
View File
@@ -11,6 +11,7 @@ import (
"time"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/devicebinding"
"data-terminal-cloud/internal/hardwarelicense"
"data-terminal-cloud/internal/hardwareprovision"
"data-terminal-cloud/internal/orchestration"
@@ -88,6 +89,8 @@ func ProvisionHardwareAgentCredentialHandler(service HardwareAgentProvisioningSe
switch {
case errors.Is(err, hardwareprovision.ErrUnavailable):
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{Code: "HARDWARE_AGENT_PROVISIONING_UNAVAILABLE", Message: "平台尚未配置火山设备注册服务", Retryable: false})
case errors.Is(err, devicebinding.ErrConfirmationRequired):
WriteError(writer, request, http.StatusConflict, "ONBOARDING_ACTIVATION_REQUIRED", "请通过首次接入激活任务获取运行配置")
case errors.Is(err, hardwareprovision.ErrInvalid):
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "火山硬件标识不正确")
case errors.Is(err, hardwareprovision.ErrBindingMissing):
@@ -187,7 +190,7 @@ func HardwareAgentEventHandler(service HardwareAgentSessionService) http.Handler
return
}
switch input.EventType {
case "session_started", "session_ended", "session_failed", "session_updated":
case "session_started", "session_ended", "session_failed", "session_updated", "first_audio_received":
default:
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "不支持的硬件智能体事件类型")
return
@@ -204,7 +207,7 @@ func HardwareAgentEventHandler(service HardwareAgentSessionService) http.Handler
WriteError(writer, request, http.StatusNotFound, "CONVERSATION_NOT_FOUND", "通话不存在或不属于当前设备")
case errors.Is(err, orchestration.ErrHardwareSessionProof):
WriteError(writer, request, http.StatusUnauthorized, "HARDWARE_SESSION_PROOF_INVALID", "硬件智能体会话凭证无效")
case errors.Is(err, orchestration.ErrHardwareEventConflict), errors.Is(err, orchestration.ErrConversationStateConflict):
case errors.Is(err, orchestration.ErrHardwareEventConflict), errors.Is(err, orchestration.ErrConversationStateConflict), errors.Is(err,orchestration.ErrOnboardingState):
WriteError(writer, request, http.StatusConflict, "HARDWARE_AGENT_EVENT_CONFLICT", "硬件智能体事件与当前会话状态冲突")
default:
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{Code: "HARDWARE_AGENT_EVENT_UNAVAILABLE", Message: "暂时无法处理硬件智能体事件", Retryable: true})
@@ -0,0 +1,23 @@
package httpapi
import (
"data-terminal-cloud/internal/onboarding"
"net/http"
"strings"
)
func onboardingCustomerAuth(service *onboarding.Service, bearer, next http.Handler) http.Handler {
if service == nil {
return bearer
}
cookie := service.CustomerAuth(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
allowed := path == "/api/v1/me" || path == "/api/v1/households" || strings.HasPrefix(path, "/api/v1/households/") || strings.HasPrefix(path, "/api/v1/subjects/") || strings.HasPrefix(path, "/api/v1/devices/")
if r.Header.Get("Authorization") == "" && allowed {
cookie.ServeHTTP(w, r)
return
}
bearer.ServeHTTP(w, r)
})
}
@@ -42,8 +42,8 @@ func TestOpsAccessHandlersCompleteTwoPersonAccessFlow(t *testing.T) {
t.Fatal(err)
}
requester := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
approver := identity.Principal{Issuer: requester.Issuer, Subject: "00000000-0000-0000-0000-000000000102"}
requester := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
approver := identity.Principal{Issuer: requester.Issuer, Subject: "00000000-0000-0000-0000-000000000105"}
tenantPublicID, tenantID := "10000000-0000-0000-0000-000000000010", "10000000-0000-0000-0000-000000000001"
conversationID, conversationPublicID := uuid.NewString(), uuid.NewString()
consentSnapshotID, contextSnapshotID := uuid.NewString(), uuid.NewString()
@@ -33,8 +33,8 @@ func TestOpsConfigHandlersPublishAndRollback(t *testing.T) {
if err != nil {
t.Fatal(err)
}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
approver := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000102"}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
approver := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000105"}
tenantID := "10000000-0000-0000-0000-000000000010"
configKey := "http.integration.routing." + uuid.NewString()
createKey, evaluationKey, rollbackKey := "http-ops-create-"+uuid.NewString(), "http-ops-eval-"+uuid.NewString(), "http-ops-rollback-"+uuid.NewString()
+3
View File
@@ -6,6 +6,7 @@ import (
"strings"
"data-terminal-cloud/internal/concurrency"
"data-terminal-cloud/internal/devicebinding"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/opsdevice"
)
@@ -268,6 +269,8 @@ func opsDevicePrincipal(w http.ResponseWriter, r *http.Request) (principal ident
func writeOpsDeviceError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, devicebinding.ErrConfirmationRequired):
WriteError(w, r, http.StatusConflict, "DEVICE_CONFIRMATION_REQUIRED", "该设备使用生产凭据和按键确认流程,请使用首次接入管理入口")
case errors.Is(err, opsdevice.ErrInvalid):
writeValidation(w, r, "设备管理参数不正确", FieldError{Field: "device", Message: "设备字段或家庭/长者归属不正确"})
case errors.Is(err, opsdevice.ErrAccessDenied):
@@ -34,7 +34,7 @@ func TestOpsGateHandlersResourceReferencesAndDeviceGroups(t *testing.T) {
if err != nil {
t.Fatal(err)
}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
tenantID := "10000000-0000-0000-0000-000000000010"
deviceID := "10000000-0000-0000-0000-000000000511"
resourceKey := "http-ops-gates-resource-" + uuid.NewString()
+9 -2
View File
@@ -15,6 +15,7 @@ import (
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/media"
"data-terminal-cloud/internal/memorytool"
"data-terminal-cloud/internal/onboarding"
"data-terminal-cloud/internal/opsaccess"
"data-terminal-cloud/internal/opsconfig"
"data-terminal-cloud/internal/opsdevice"
@@ -24,6 +25,7 @@ import (
)
type RouterOptions struct {
OnboardingService *onboarding.Service
ServiceName string
Logger *slog.Logger
RateLimiter RateLimiter
@@ -71,6 +73,9 @@ func NewRouter(options RouterOptions) http.Handler {
logger = slog.Default()
}
mux := http.NewServeMux()
if options.ServiceName == "api" && options.OnboardingService != nil {
options.OnboardingService.RegisterRoutes(mux)
}
mux.Handle("GET /metrics", MetricsHandler())
mux.HandleFunc("GET /health/live", func(writer http.ResponseWriter, request *http.Request) {
WriteJSON(writer, request, http.StatusOK, map[string]string{
@@ -93,11 +98,13 @@ func NewRouter(options RouterOptions) http.Handler {
mux.Handle("POST /internal/v1/tools/memory-search", MemoryToolSearchHandler(options.MemoryToolService, options.MemoryToolSigner, options.RateLimiter, logger))
}
if options.ServiceName == "api" && options.TokenVerifier != nil && options.IdentityRepository != nil {
mux.Handle("GET /api/v1/me", BearerAuth(options.TokenVerifier, UserRateLimit(logger, options.RateLimiter, CurrentUserHandler(options.IdentityRepository))))
handler := UserRateLimit(logger, options.RateLimiter, CurrentUserHandler(options.IdentityRepository))
mux.Handle("GET /api/v1/me", onboardingCustomerAuth(options.OnboardingService, BearerAuth(options.TokenVerifier, handler), handler))
}
if options.ServiceName == "api" && options.TokenVerifier != nil && options.SaaSService != nil {
authenticated := func(handler http.Handler) http.Handler {
return BearerAuth(options.TokenVerifier, UserRateLimit(logger, options.RateLimiter, handler))
limited := UserRateLimit(logger, options.RateLimiter, handler)
return onboardingCustomerAuth(options.OnboardingService, BearerAuth(options.TokenVerifier, limited), limited)
}
if options.WebEventService != nil {
mux.Handle("GET /api/v1/events", authenticated(StreamWebEventsHandler(options.WebEventService)))
+3
View File
@@ -11,6 +11,7 @@ import (
"time"
"data-terminal-cloud/internal/concurrency"
"data-terminal-cloud/internal/devicebinding"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/pagination"
"data-terminal-cloud/internal/saas"
@@ -1882,6 +1883,8 @@ func writeETagError(writer http.ResponseWriter, request *http.Request, err error
func writeSaaSError(writer http.ResponseWriter, request *http.Request, err error) {
switch {
case errors.Is(err, devicebinding.ErrConfirmationRequired):
WriteError(writer, request, http.StatusConflict, "DEVICE_CONFIRMATION_REQUIRED", "请先授权解绑,再从设备设置入口申请并按键确认绑定")
case errors.Is(err, saas.ErrAccessDenied):
WriteError(writer, request, http.StatusForbidden, "RESOURCE_ACCESS_DENIED", "当前账号无权访问该资源")
case errors.Is(err, saas.ErrNotFound):
+20 -2
View File
@@ -81,6 +81,14 @@ func (e *Executor) Execute(
request Request,
operation func(context.Context, pgx.Tx) (Result, error),
) (Result, error) {
return e.ExecuteGuarded(ctx, request, nil, operation)
}
// Guard executes in the operation transaction before mutation or replay. On
// replay it also receives the stored resource for current authorization checks.
type Guard func(context.Context, pgx.Tx, *Result) error
func (e *Executor) ExecuteGuarded(ctx context.Context, request Request, guard Guard, operation func(context.Context, pgx.Tx) (Result, error)) (Result, error) {
if err := validateRequest(request); err != nil {
return Result{}, err
}
@@ -89,6 +97,11 @@ func (e *Executor) Execute(
return Result{}, fmt.Errorf("begin idempotency transaction: %w", err)
}
defer func() { _ = tx.Rollback(context.Background()) }()
if guard != nil {
if err := guard(ctx, tx, nil); err != nil {
return Result{}, err
}
}
tag, err := tx.Exec(ctx, `
INSERT INTO idempotency_record (
@@ -103,7 +116,7 @@ func (e *Executor) Execute(
return Result{}, fmt.Errorf("claim idempotency key: %w", err)
}
if tag.RowsAffected() == 0 {
return e.replay(ctx, tx, request)
return e.replay(ctx, tx, request, guard)
}
result, err := operation(ctx, tx)
@@ -129,7 +142,7 @@ func (e *Executor) Execute(
return result, nil
}
func (e *Executor) replay(ctx context.Context, tx pgx.Tx, request Request) (Result, error) {
func (e *Executor) replay(ctx context.Context, tx pgx.Tx, request Request, guard Guard) (Result, error) {
var storedHash, state string
var result Result
err := tx.QueryRow(ctx, `
@@ -148,6 +161,11 @@ func (e *Executor) replay(ctx context.Context, tx pgx.Tx, request Request) (Resu
return Result{}, ErrInProgress
}
result.Replayed = true
if guard != nil {
if err := guard(ctx, tx, &result); err != nil {
return Result{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return Result{}, fmt.Errorf("commit idempotency replay: %w", err)
}
@@ -12,6 +12,8 @@ import (
// operational alerting. It is registered by the single worker process so
// horizontally scaled API instances do not publish duplicate database gauges.
func RegisterBusinessMetrics(pool *pgxpool.Pool) {
registerStatusCounts(pool, "onboarding_activation_jobs", "Physical device activation jobs by durable status.", "device_activation_job", []string{"NOT_STARTED", "RESERVED", "CONTACT_POSSIBLE", "REGISTERED_OBSERVED", "READY", "RECONCILE_REQUIRED", "BLOCKED"})
registerStatusCounts(pool, "onboarding_claims", "Physical confirmation claims by status.", "device_binding_claim", []string{"PENDING", "CONFIRMED", "CANCELLED", "EXPIRED"})
registerStatusCounts(pool, "conversations", "Current conversations by lifecycle status.", "conversation", []string{"PREPARING", "ROOM_ISSUED", "STARTING_AGENT", "ACTIVE", "STOPPING", "COMPLETED", "FAILED", "EXPIRED"})
registerStatusCounts(pool, "privacy_requests", "Current privacy requests by status.", "privacy_request", []string{"SUBMITTED", "VERIFYING", "EXECUTING", "COMPLETED", "PARTIAL_FAILED"})
registerStatusCounts(pool, "firmware_rollouts", "Current firmware rollouts by status.", "firmware_rollout", []string{"DRAFT", "RUNNING", "PAUSED", "COMPLETED", "ROLLED_BACK"})
+82
View File
@@ -0,0 +1,82 @@
package onboarding
import (
"context"
"crypto/hmac"
"encoding/json"
"errors"
"fmt"
"time"
"data-terminal-cloud/internal/outbox"
"github.com/jackc/pgx/v5"
)
const ActivationEvent = "device.onboarding.activation.requested"
type activationJob struct {
ID, DeviceID, Status, Name, Instance, Product, Bot, Transport, MinimumSDK, RTCAppID string
Generation, ConfigVersion, Version int64
Secret, Config []byte
Digest, AttemptID string
Expires, Ack, Engine, Registered *time.Time
ReadyGeneration *int64
}
func readJob(ctx context.Context, tx pgx.Tx, device string) (activationJob, error) {
var j activationJob
err := tx.QueryRow(ctx, `SELECT id::text,device_id::text,status,provider_device_name,binding_generation,config_version,version,COALESCE(provider_instance_id,''),COALESCE(provider_product_key,''),COALESCE(provider_bot_id,''),COALESCE(provider_transport,''),COALESCE(provider_minimum_sdk,''),COALESCE(provider_rtc_app_id,''),provider_secret_ciphertext,config_ciphertext,COALESCE(config_digest,''),COALESCE(current_attempt_id::text,''),config_expires_at,config_ack_at,engine_ready_at,ready_binding_generation,sdk_registered_at FROM device_activation_job WHERE device_id=$1::uuid FOR UPDATE`, device).Scan(&j.ID, &j.DeviceID, &j.Status, &j.Name, &j.Generation, &j.ConfigVersion, &j.Version, &j.Instance, &j.Product, &j.Bot, &j.Transport, &j.MinimumSDK, &j.RTCAppID, &j.Secret, &j.Config, &j.Digest, &j.AttemptID, &j.Expires, &j.Ack, &j.Engine, &j.ReadyGeneration, &j.Registered)
return j, err
}
func (s *Service) enqueueActivation(ctx context.Context, tx pgx.Tx, d Device, generation int64, requestID string) error {
_, err := tx.Exec(ctx, `INSERT INTO device_activation_job(device_id,tenant_id,provider_device_name,binding_generation,provider_instance_id,provider_product_key,provider_bot_id,provider_transport,provider_minimum_sdk) VALUES($1::uuid,$2::uuid,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT(device_id) DO NOTHING`, d.ID, d.TenantID, d.FactoryID, generation, s.options.InstanceID, s.options.ProductKey, s.options.BotID, s.options.Transport, s.options.MinimumSDKVersion)
if err != nil {
return err
}
j, err := readJob(ctx, tx, d.ID)
if err != nil {
return err
}
if j.Generation != generation {
status := "NOT_STARTED"
if j.Status == "RECONCILE_REQUIRED" || j.Status == "CONTACT_POSSIBLE" {
status = "RECONCILE_REQUIRED"
} else if j.Registered != nil {
status = "REGISTERED_OBSERVED"
}
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET binding_generation=$2,config_version=config_version+1,config_ciphertext=NULL,config_digest=NULL,config_expires_at=NULL,config_ack_at=NULL,engine_ready_at=NULL,status=$3,current_attempt_id=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid`, j.ID, generation, status)
if err != nil {
return err
}
_, err = tx.Exec(ctx, `UPDATE device_activation_attempt SET status='DEFINITE_FAILURE',completed_at=now(),evidence='{"reason":"BINDING_CHANGED"}' WHERE activation_id=$1::uuid AND executor='DEVICE' AND status IN ('CONTACT_POSSIBLE','UNKNOWN')`, j.ID)
if err != nil {
return err
}
}
if err = bump(ctx, tx, d.ID); err != nil {
return err
}
dedup := fmt.Sprintf("%s:%d", d.ID, generation)
payload, _ := json.Marshal(map[string]any{"device_id": d.ID, "activation_id": j.ID, "binding_generation": generation})
_, err = s.outbox.Enqueue(ctx, tx, outbox.NewEvent{TenantID: &d.TenantID, AggregateType: "device", AggregateID: d.ID, EventType: ActivationEvent, DeduplicationKey: &dedup, Payload: payload})
return err
}
// Keep the established credential encryption format for existing subtitle and
// session services. The onboarding job remains the authority for READY.
func (s *Service) storeRuntimeCredential(ctx context.Context, tx pgx.Tx, d Device, j activationJob, secret []byte) error {
if s.providerCrypt.aead == nil {
return errors.New("provider credential master key unavailable")
}
ref := "volc-hardware://" + d.ID
aad := fmt.Sprintf("hardware-provider-credential-v1\n%s\n%d\n%s\n%s\n%s", ref, 1, j.Instance, j.Product, j.Name)
encrypted := s.providerCrypt.seal(secret, aad)
n := s.providerCrypt.aead.NonceSize()
_, err := tx.Exec(ctx, `INSERT INTO hardware_agent_provider_credential(tenant_id,device_id,credential_ref,instance_id,product_key,provider_device_name,hardware_id,encrypted_secret,nonce) VALUES($1::uuid,$2::uuid,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT(device_id) DO UPDATE SET encrypted_secret=EXCLUDED.encrypted_secret,nonce=EXCLUDED.nonce,status='ACTIVE',updated_at=now() WHERE hardware_agent_provider_credential.credential_ref=EXCLUDED.credential_ref AND hardware_agent_provider_credential.provider_device_name=EXCLUDED.provider_device_name`, d.TenantID, d.ID, ref, j.Instance, j.Product, j.Name, d.FactoryID, encrypted[n:], encrypted[:n])
if err != nil {
return err
}
_, err = tx.Exec(ctx, `INSERT INTO hardware_agent_device_binding(tenant_id,device_id,provider,product_key,bot_id,transport,minimum_sdk_version,status) VALUES($1::uuid,$2::uuid,'volcengine',$3,$4,$5,$6,'ACTIVE') ON CONFLICT(device_id) DO UPDATE SET product_key=EXCLUDED.product_key,bot_id=EXCLUDED.bot_id,transport=EXCLUDED.transport,minimum_sdk_version=EXCLUDED.minimum_sdk_version,status='ACTIVE',updated_at=now()`, d.TenantID, d.ID, j.Product, j.Bot, j.Transport, j.MinimumSDK)
return err
}
func sameSecret(a, b string) bool { return hmac.Equal([]byte(a), []byte(b)) }
+184
View File
@@ -0,0 +1,184 @@
package onboarding
import (
"bytes"
"context"
"crypto/hmac"
"encoding/json"
"errors"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"data-terminal-cloud/internal/deviceauth"
"github.com/jackc/pgx/v5"
)
var factoryPattern = regexp.MustCompile(`^KP01-[0-9A-F]{12}$`)
type deviceContextKey struct{}
func DeviceFromContext(ctx context.Context) (Device, bool) {
d, ok := ctx.Value(deviceContextKey{}).(Device)
return d, ok
}
func (s *Service) authenticate(r *http.Request) (Device, []byte, error) {
id := r.Header.Get("X-Device-ID")
nonce := r.Header.Get("X-Nonce")
requestID := r.Header.Get("X-Request-ID")
version, ve := strconv.ParseInt(r.Header.Get("X-Key-Version"), 10, 64)
timestamp, te := strconv.ParseInt(r.Header.Get("X-Timestamp-Ms"), 10, 64)
if strconv.FormatInt(version, 10) != r.Header.Get("X-Key-Version") || strconv.FormatInt(timestamp, 10) != r.Header.Get("X-Timestamp-Ms") {
return Device{}, nil, errAuth
}
signature := strings.TrimPrefix(r.Header.Get("Authorization"), "Device-HMAC-SHA256 ")
if !factoryPattern.MatchString(id) || ve != nil || te != nil || version < 1 || !validB64(nonce, 16) || !uuidV4(requestID) || !validDigest(signature) || signature == r.Header.Get("Authorization") || r.URL.RawQuery != "" || strings.ContainsAny(r.URL.EscapedPath(), "\r\n") {
return Device{}, nil, errAuth
}
raw, err := io.ReadAll(io.LimitReader(r.Body, maxBody+1))
if err != nil || len(raw) > maxBody {
return Device{}, nil, fail(413, "REQUEST_TOO_LARGE", "请求正文过长")
}
r.Body = io.NopCloser(bytes.NewReader(raw))
d, err := s.findFactoryDevice(r.Context(), id, version)
if err != nil {
return Device{}, nil, err
}
expected := RequestMAC(d.Key, r.Method, r.URL.EscapedPath(), id, version, timestamp, nonce, requestID, raw)
if !hmac.Equal([]byte(expected), []byte(signature)) || time.UnixMilli(timestamp).Before(s.now().Add(-120*time.Second)) || time.UnixMilli(timestamp).After(s.now().Add(120*time.Second)) {
return Device{}, nil, errAuth
}
fresh, err := s.redis.SetNX(r.Context(), "onboarding:nonce:"+id+":"+strconv.FormatInt(version, 10)+":"+nonce, "1", 5*time.Minute).Result()
if err != nil {
return Device{}, nil, fail(503, "AUTHENTICATION_UNAVAILABLE", "认证服务暂不可用")
}
if !fresh {
return Device{}, nil, errAuth
}
if !deviceAllowed(d) {
return Device{}, nil, errAuth
}
return d, raw, nil
}
func (s *Service) findFactoryDevice(ctx context.Context, factoryID string, version int64) (Device, error) {
var id string
err := s.pool.QueryRow(ctx, `SELECT id::text FROM device WHERE factory_device_id=$1 AND enrollment_mode='physical_confirm'`, factoryID).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return Device{}, errAuth
}
if err != nil {
return Device{}, err
}
d, err := readDevice(ctx, s.pool, id, false)
if err != nil {
return d, err
}
var ciphertext []byte
err = s.pool.QueryRow(ctx, `SELECT ciphertext FROM device_onboarding_key WHERE device_id=$1::uuid AND key_version=$2 AND status='ACTIVE'`, id, version).Scan(&ciphertext)
if errors.Is(err, pgx.ErrNoRows) {
return d, errAuth
}
if err != nil {
return d, err
}
d.Key, err = s.crypt.open(ciphertext, keyAAD(id, version))
if err != nil || len(d.Key) != 32 {
return d, errAuth
}
d.KeyVersion = version
return d, nil
}
// Verify adapts explicitly enrolled devices for the existing voice interfaces.
// A failed signature is never retried with the other signing algorithm.
func (s *Service) Verify(r *http.Request) (deviceauth.Identity, error) {
id := r.Header.Get("X-Device-ID")
if factoryPattern.MatchString(id) {
if !s.secure(r) {
return deviceauth.Identity{}, deviceauth.ErrCredentialInvalid
}
d, raw, err := s.authenticate(r)
if err != nil {
return deviceauth.Identity{}, deviceauth.ErrCredentialInvalid
}
if len(raw) > 0 {
var body map[string]any
if err = StrictJSON(raw, &body); err != nil {
return deviceauth.Identity{}, deviceauth.ErrCredentialInvalid
}
}
test, err := s.runtimeAdmission(r, d, raw)
if err != nil {
return deviceauth.Identity{}, err
}
return deviceauth.Identity{ID: d.ID, PublicID: d.PublicID, TenantID: d.TenantID, Status: d.Status, ActivationState: d.Lifecycle, VoiceRuntime: "volc_hardware_agent", CredentialRef: "onboarding://" + d.FactoryID, EnrollmentMode: d.Mode, BindingGeneration: d.Generation, ActivationTest: test}, nil
}
if s.options.LegacyVerifier == nil {
return deviceauth.Identity{}, deviceauth.ErrCredentialInvalid
}
legacy, err := s.options.LegacyVerifier.Verify(r)
if err != nil {
return legacy, err
}
var mode string
if err = s.pool.QueryRow(r.Context(), `SELECT enrollment_mode FROM device WHERE id=$1::uuid`, legacy.ID).Scan(&mode); err != nil {
return legacy, err
}
if mode != "legacy" {
return deviceauth.Identity{}, deviceauth.ErrCredentialInvalid
}
return legacy, nil
}
type TimeChallengeInput struct {
DeviceID string `json:"device_id"`
KeyVersion int64 `json:"key_version"`
BootID string `json:"boot_id"`
ClientNonce string `json:"client_nonce"`
}
func (s *Service) TimeChallenge(ctx context.Context, input TimeChallengeInput) (map[string]any, error) {
if !factoryPattern.MatchString(input.DeviceID) || input.KeyVersion < 1 || !uuidV4(input.BootID) || !validB64(input.ClientNonce, 16) {
return nil, errAuth
}
d, err := s.findFactoryDevice(ctx, input.DeviceID, input.KeyVersion)
if err != nil {
return nil, err
}
if !deviceAllowed(d) {
return nil, errAuth
}
if err := s.limit(ctx, "clock:"+d.ID, 30, time.Minute); err != nil {
return nil, err
}
var challenge string
var timestamp int64
var expires time.Time
err = s.pool.QueryRow(ctx, `INSERT INTO device_time_challenge(device_id,key_version,boot_id,client_nonce,server_time_ms,expires_at) VALUES($1::uuid,$2,$3::uuid,$4,$5,$6) ON CONFLICT(device_id,key_version,boot_id,client_nonce) DO UPDATE SET client_nonce=EXCLUDED.client_nonce RETURNING id::text,server_time_ms,expires_at`, d.ID, d.KeyVersion, input.BootID, input.ClientNonce, s.now().UnixMilli(), s.now().Add(120*time.Second)).Scan(&challenge, &timestamp, &expires)
if err != nil {
return nil, err
}
if !expires.After(s.now()) {
return nil, fail(410, "CHALLENGE_EXPIRED", "请使用新的校时随机数")
}
raw, _ := json.Marshal(map[string]any{"device_id": d.FactoryID, "boot_id": input.BootID, "client_nonce": input.ClientNonce, "challenge_id": challenge, "server_time_ms": timestamp, "expires_at_ms": expires.UnixMilli()})
encoded := encode64(raw)
return map[string]any{"payload_b64": encoded, "mac_hex": domainMAC(d.Key, "TIME1\n"+encoded)}, nil
}
func (s *Service) limit(ctx context.Context, key string, max int64, window time.Duration) error {
// Atomic expiry: a process failure between INCR and EXPIRE must not leave
// a permanently exhausted rate-limit key.
n, err := s.redis.Eval(ctx, `local n=redis.call('INCR',KEYS[1]); if n==1 then redis.call('PEXPIRE',KEYS[1],ARGV[1]); end; return n`, []string{"onboarding:limit:" + key}, window.Milliseconds()).Int64()
if err != nil {
return fail(503, "RATE_LIMIT_UNAVAILABLE", "访问控制暂不可用")
}
if n > max {
return &Error{Status: 429, Code: "RATE_LIMITED", Message: "请求过于频繁,请稍后重试", Recovery: "RETRY"}
}
return nil
}
+48
View File
@@ -0,0 +1,48 @@
package onboarding
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"time"
)
func (s *Service) stateAckCanClose(ctx context.Context, tx pgx.Tx, d Device) (bool, error) {
var setupID string
err := tx.QueryRow(ctx, `SELECT id::text FROM device_setup_session WHERE device_id=$1::uuid AND boot_id=$2::uuid AND closed_at IS NULL ORDER BY created_at DESC LIMIT 1`, d.ID, d.BootID).Scan(&setupID)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
_, allowed, err := s.closeDelay(ctx, tx, d, setupID)
return allowed, err
}
func (s *Service) closeDelay(ctx context.Context, tx pgx.Tx, d Device, setupID string) (int64, bool, error) {
var saved time.Time
var web *time.Time
err := tx.QueryRow(ctx, `SELECT a.accepted_at,CASE WHEN ss.web_ack_binding_id=b.id AND ss.web_ack_generation=b.generation THEN ss.web_ack_at END FROM device_state_ack a JOIN device_binding b ON b.device_id=a.device_id AND b.generation=a.binding_generation AND b.unbound_at IS NULL JOIN device_setup_session ss ON ss.device_id=b.device_id AND ss.id=$4::uuid WHERE a.device_id=$1::uuid AND a.binding_generation=$2 AND a.boot_id=$3::uuid`, d.ID, d.Generation, d.BootID, setupID).Scan(&saved, &web)
if errors.Is(err, pgx.ErrNoRows) {
return 30000, false, nil
}
if err != nil {
return 0, false, err
}
deadline := saved.Add(30 * time.Second)
if web != nil {
both := saved
if web.After(both) {
both = *web
}
if short := both.Add(3 * time.Second); short.Before(deadline) {
deadline = short
}
}
delay := deadline.Sub(s.now()).Milliseconds()
if delay < 0 {
delay = 0
}
return delay, delay == 0, nil
}
+158
View File
@@ -0,0 +1,158 @@
package onboarding
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"strings"
"unicode/utf8"
"github.com/google/uuid"
)
const ContractVersion = "ONBOARDING-1.1"
const maxBody = 8 << 10
func digest(b []byte) string { sum := sha256.Sum256(b); return hex.EncodeToString(sum[:]) }
func randomToken(n int) string { return base64.RawURLEncoding.EncodeToString(randomBytes(n)) }
func randomBytes(n int) []byte {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic("secure random source unavailable")
}
return b
}
func uuidV4(s string) bool {
u, e := uuid.Parse(s)
return e == nil && u.Version() == 4 && u.String() == s
}
func uuidValid(s string) bool { u, e := uuid.Parse(s); return e == nil && u.String() == s }
func validB64(s string, n int) bool {
b, e := base64.RawURLEncoding.DecodeString(s)
return e == nil && len(b) == n && base64.RawURLEncoding.EncodeToString(b) == s
}
func validDigest(s string) bool {
b, e := hex.DecodeString(s)
return e == nil && len(b) == 32 && strings.ToLower(s) == s
}
func RequestMAC(key []byte, method, path, deviceID string, version, timestamp int64, nonce, requestID string, body []byte) string {
canonical := strings.Join([]string{"DEVREQ1", strings.ToUpper(method), path, deviceID, strconv.FormatInt(version, 10), strconv.FormatInt(timestamp, 10), nonce, requestID, digest(body)}, "\n")
return domainMAC(key, canonical)
}
func domainMAC(key []byte, message string) string {
m := hmac.New(sha256.New, key)
m.Write([]byte(message))
return hex.EncodeToString(m.Sum(nil))
}
type cryptor struct {
aead cipher.AEAD
hashKey []byte
}
func newCryptor(master string) (cryptor, error) {
key, err := hex.DecodeString(master)
if err != nil || len(key) != 32 {
return cryptor{}, errors.New("onboarding master key must be 32 bytes encoded as hex")
}
block, err := aes.NewCipher(key)
if err != nil {
return cryptor{}, err
}
aead, err := cipher.NewGCM(block)
derived := sha256.Sum256(append([]byte("ONBOARDING-HASH-KEY1:"), key...))
return cryptor{aead: aead, hashKey: derived[:]}, err
}
func (c cryptor) seal(plain []byte, aad string) []byte {
nonce := randomBytes(c.aead.NonceSize())
return c.aead.Seal(nonce, nonce, plain, []byte(aad))
}
func (c cryptor) open(value []byte, aad string) ([]byte, error) {
n := c.aead.NonceSize()
if len(value) < n {
return nil, errors.New("invalid encrypted onboarding record")
}
return c.aead.Open(nil, value[:n], value[n:], []byte(aad))
}
func keyAAD(device string, version int64) string {
return fmt.Sprintf("ONBOARDING-KEY1\n%s\n%d", device, version)
}
// Validate JSON before decoding structs: Go's regular decoder accepts duplicate
// keys and repairs invalid UTF-8, both of which are forbidden by this contract.
func StrictJSON(raw []byte, target any) error {
if len(raw) == 0 || len(raw) > maxBody || !utf8.Valid(raw) {
return fail(400, "INVALID_JSON", "请求正文不合法")
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var visit func(int) error
visit = func(depth int) error {
if depth > 12 {
return errors.New("JSON nesting exceeds 12")
}
token, err := dec.Token()
if err != nil {
return err
}
delim, ok := token.(json.Delim)
if !ok {
return nil
}
switch delim {
case '{':
seen := map[string]bool{}
for dec.More() {
k, err := dec.Token()
if err != nil {
return err
}
name, ok := k.(string)
if !ok || seen[name] {
return errors.New("duplicate JSON key")
}
seen[name] = true
if err := visit(depth + 1); err != nil {
return err
}
}
case '[':
for dec.More() {
if err := visit(depth + 1); err != nil {
return err
}
}
default:
return errors.New("invalid JSON delimiter")
}
_, err = dec.Token()
return err
}
if err := visit(1); err != nil {
return fail(400, "INVALID_JSON", "JSON格式、深度或字段重复")
}
if _, err := dec.Token(); err != io.EOF {
return fail(400, "INVALID_JSON", "只能提交一个JSON对象")
}
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || trimmed[0] != '{' {
return fail(400, "INVALID_JSON", "必须提交JSON对象")
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return fail(400, "VALIDATION_FAILED", "请求字段或类型不正确")
}
return nil
}
+35
View File
@@ -0,0 +1,35 @@
package onboarding
import (
"encoding/hex"
"encoding/json"
"os"
"testing"
)
func TestDEVREQ1IndependentVector(t *testing.T) {
raw, err := os.ReadFile("../../test/fixtures/onboarding-devreq1.json")
must(t, err)
var v struct {
KeyHex, Method, Path, DeviceID, Nonce, RequestID, Body, Signature string
KeyVersion, Timestamp int64
}
must(t, json.Unmarshal(raw, &v))
key, err := hex.DecodeString(v.KeyHex)
must(t, err)
got := RequestMAC(key, v.Method, v.Path, v.DeviceID, v.KeyVersion, v.Timestamp, v.Nonce, v.RequestID, []byte(v.Body))
if got != v.Signature {
t.Fatalf("independent vector mismatch: %s", got)
}
if RequestMAC([]byte(v.KeyHex), v.Method, v.Path, v.DeviceID, v.KeyVersion, v.Timestamp, v.Nonce, v.RequestID, []byte(v.Body)) == v.Signature {
t.Fatal("text key accepted as raw key")
}
}
func TestStrictJSONRejectsAmbiguousBodies(t *testing.T) {
for _, raw := range []string{`{"boot_id":"a","boot_id":"b"}`, `{"boot_id":"a","unknown":true}`, `[]`, `{} {}`, "{\"boot_id\":\"\xff\"}"} {
var out RegisterInput
if StrictJSON([]byte(raw), &out) == nil {
t.Fatalf("accepted invalid body %q", raw)
}
}
}
+423
View File
@@ -0,0 +1,423 @@
package onboarding
import (
"context"
"encoding/json"
"errors"
"time"
"data-terminal-cloud/internal/deviceactivation"
"data-terminal-cloud/internal/outbox"
"github.com/jackc/pgx/v5"
)
// The worker prepares encrypted configuration. Only the device executes SDK
// registration, after a durable C08 permit. An outbox retry makes no SDK call.
func (s *Service) ProcessActivation(ctx context.Context, event outbox.Event) error {
if !s.options.ActivationEnabled {
return outbox.Retryable(errors.New("onboarding activation paused"))
}
var in struct {
DeviceID string `json:"device_id"`
ActivationID string `json:"activation_id"`
Generation int64 `json:"binding_generation"`
}
if json.Unmarshal(event.Payload, &in) != nil || !uuidValid(in.DeviceID) {
return errors.New("invalid activation event")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
d, err := readDevice(ctx, tx, in.DeviceID, true)
if err != nil {
return err
}
j, err := readJob(ctx, tx, d.ID)
if err != nil {
return err
}
if !deviceAllowed(d) || d.Generation != in.Generation || j.Generation != d.Generation || j.ID != in.ActivationID {
return nil
}
var bound bool
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND generation=$2 AND unbound_at IS NULL)`, d.ID, d.Generation).Scan(&bound); err != nil {
return err
}
if !bound {
return nil
}
if err = s.expireAttempt(ctx, tx, d, &j); err != nil {
return err
}
if oneOf(j.Status, "NOT_STARTED", "RESERVED", "REGISTERED_OBSERVED", "READY") {
if err = s.ensureConfig(ctx, tx, d, &j); err != nil {
return err
}
}
return tx.Commit(ctx)
}
func (s *Service) expireAttempt(ctx context.Context, tx pgx.Tx, d Device, j *activationJob) error {
// Permit expiry means a call may already have started. It is never proof of
// failure. Leave room for a 60-second conversation and late result delivery.
tag, err := tx.Exec(ctx, `UPDATE device_activation_attempt SET status='UNKNOWN' WHERE activation_id=$1::uuid AND status='CONTACT_POSSIBLE' AND permit_expires_at < $2 AND executor='DEVICE'`, j.ID, s.now().Add(-90*time.Second))
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return nil
}
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET status='RECONCILE_REQUIRED',last_error_code='DEVICE_RESULT_UNCERTAIN',version=version+1,updated_at=now() WHERE id=$1::uuid`, j.ID)
if err != nil {
return err
}
j.Status = "RECONCILE_REQUIRED"
return bump(ctx, tx, d.ID)
}
func (s *Service) ensureConfig(ctx context.Context, tx pgx.Tx, d Device, j *activationJob) error {
if j.Generation != d.Generation || !oneOf(j.Status, "NOT_STARTED", "RESERVED", "REGISTERED_OBSERVED", "READY") {
return errState
}
if len(j.Config) > 0 && j.Expires != nil && j.Expires.After(s.now()) {
return nil
}
if !s.options.ActivationEnabled {
return fail(503, "ACTIVATION_PAUSED", "激活暂未开放")
}
// Never rotate the configuration under a potentially running SDK call.
var busy bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_activation_attempt WHERE activation_id=$1::uuid AND status IN ('CONTACT_POSSIBLE','UNKNOWN'))`, j.ID).Scan(&busy); err != nil {
return err
}
if busy {
return fail(409, "ATTEMPT_RECONCILIATION_REQUIRED", "请先确认原执行结果")
}
if j.Instance == "" {
j.Instance = s.options.InstanceID
}
if j.Product == "" {
j.Product = s.options.ProductKey
}
if j.Bot == "" {
j.Bot = s.options.BotID
}
if j.Product != s.options.ProductKey || j.Instance != s.options.InstanceID {
return fail(409, "ACTIVATION_PRODUCT_CHANGED", "设备所属产品配置已变化,请运维核对")
}
if len(j.Config) > 0 {
j.ConfigVersion++
}
expires := s.now().Add(24 * time.Hour)
raw, err := json.Marshal(map[string]any{"contract_version": ContractVersion, "device_id": d.FactoryID, "activation_id": j.ID, "config_version": j.ConfigVersion, "binding_generation": d.Generation, "expires_at_ms": expires.UnixMilli(), "iot": map[string]any{"instance_id": j.Instance, "product_key": j.Product, "product_secret": s.options.ProductSecret, "device_name": j.Name}, "bot_id": j.Bot})
if err != nil {
return err
}
if len(raw) > 32<<10 {
return errors.New("activation configuration exceeds limit")
}
j.Config, j.Digest, j.Expires, j.Ack = s.crypt.seal(raw, "config:"+j.ID), digest(raw), &expires, nil
j.Status = "RESERVED"
if j.Registered != nil {
j.Status = "REGISTERED_OBSERVED"
j.Engine = j.Registered
}
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET provider_instance_id=$2,provider_product_key=$3,provider_bot_id=$4,config_version=$5,config_ciphertext=$6,config_digest=$7,config_expires_at=$8,config_ack_at=NULL,engine_ready_at=$9,status=$10,version=version+1,updated_at=now() WHERE id=$1::uuid`, j.ID, j.Instance, j.Product, j.Bot, j.ConfigVersion, j.Config, j.Digest, expires, j.Engine, j.Status)
if err != nil {
return err
}
if d.Lifecycle == "BOUND" {
if err = requireLifecycle(ctx, tx, d, deviceactivation.Provisioning, j.ID); err != nil {
return err
}
}
// Reuse an existing platform credential when available. The C09 contract
// deliberately does not ask a device to upload its supplier secret.
if len(j.Secret) > 0 {
secret, e := s.crypt.open(j.Secret, "provider:"+j.ID)
if e != nil {
return e
}
if err = s.storeRuntimeCredential(ctx, tx, d, *j, secret); err != nil {
return err
}
}
return bump(ctx, tx, d.ID)
}
func (s *Service) activationCommand(ctx context.Context, tx pgx.Tx, d Device) (any, error) {
j, err := readJob(ctx, tx, d.ID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
if j.Generation != d.Generation {
return nil, nil
}
if err = s.expireAttempt(ctx, tx, d, &j); err != nil {
return nil, err
}
if !s.options.ActivationEnabled || !oneOf(j.Status, "NOT_STARTED", "RESERVED", "REGISTERED_OBSERVED", "READY") {
return nil, nil
}
if err = s.ensureConfig(ctx, tx, d, &j); err != nil {
return nil, err
}
raw, err := s.crypt.open(j.Config, "config:"+j.ID)
if err != nil {
return nil, err
}
var version int64
var encrypted []byte
if err = tx.QueryRow(ctx, `SELECT key_version,ciphertext FROM device_onboarding_key WHERE device_id=$1::uuid AND status='ACTIVE'`, d.ID).Scan(&version, &encrypted); err != nil {
return nil, err
}
key, err := s.crypt.open(encrypted, keyAAD(d.ID, version))
if err != nil {
return nil, err
}
encoded := encode64(raw)
return map[string]any{"command_id": j.ID, "type": "APPLY_ACTIVATION", "expires_at_ms": j.Expires.UnixMilli(), "payload": map[string]any{"config_b64": encoded, "config_mac": domainMAC(key, "CFG1\n"+encoded)}}, nil
}
func validConfig(s *Service, d Device, j activationJob, id string, version int64) error {
if j.ID != id || j.Generation != d.Generation || j.ConfigVersion != version || j.Status == "BLOCKED" || j.Expires == nil || !j.Expires.After(s.now()) {
return fail(409, "ACTIVATION_CONFIG_CHANGED", "请拉取当前激活配置")
}
return nil
}
type ActivationAckInput struct {
ActivationID string `json:"activation_id"`
LocalGeneration int64 `json:"local_generation"`
ConfigVersion int64 `json:"config_version"`
}
func (s *Service) ActivationAck(ctx context.Context, d Device, id string, raw []byte, in ActivationAckInput) (map[string]any, error) {
if !uuidV4(in.ActivationID) || in.LocalGeneration < 0 || in.ConfigVersion < 1 {
return nil, fail(400, "VALIDATION_FAILED", "激活保存回执格式不正确")
}
return s.mutate(ctx, d, "device:"+d.ID, "activation-ack", id, raw, 24*time.Hour, func(tx pgx.Tx, current Device) (map[string]any, error) {
j, err := readJob(ctx, tx, d.ID)
if err != nil {
return nil, err
}
if err = validConfig(s, current, j, in.ActivationID, in.ConfigVersion); err != nil {
return nil, err
}
if j.Registered == nil || !oneOf(j.Status, "REGISTERED_OBSERVED", "READY") {
return nil, fail(409, "ACTIVATION_RESULT_NOT_READY", "尚无已确认的激活结果")
}
var previous int64
if err = tx.QueryRow(ctx, `SELECT CASE WHEN config_ack_boot_id=$2::uuid THEN COALESCE(config_local_generation,0) ELSE 0 END FROM device_activation_job WHERE id=$1::uuid`, j.ID, current.BootID).Scan(&previous); err != nil {
return nil, err
}
if in.LocalGeneration < previous {
return nil, errState
}
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET config_ack_at=COALESCE(config_ack_at,now()),config_local_generation=$2,config_ack_boot_id=$3::uuid,updated_at=now() WHERE id=$1::uuid`, j.ID, in.LocalGeneration, current.BootID)
if err != nil {
return nil, err
}
if j.Status != "READY" && j.ReadyGeneration != nil && *j.ReadyGeneration == current.Generation {
if _, err = tx.Exec(ctx, `UPDATE device_activation_job SET status='READY',version=version+1 WHERE id=$1::uuid`, j.ID); err != nil {
return nil, err
}
if err = bump(ctx, tx, d.ID); err != nil {
return nil, err
}
}
st, err := s.state(ctx, tx, current)
return map[string]any{"accepted": true, "state": st}, err
})
}
type AttemptInput struct {
ActivationID string `json:"activation_id"`
ConfigVersion int64 `json:"config_version"`
AttemptID string `json:"attempt_id"`
}
func (s *Service) Attempt(ctx context.Context, d Device, id string, raw []byte, in AttemptInput) (map[string]any, error) {
if !uuidV4(in.ActivationID) || !uuidV4(in.AttemptID) || in.ConfigVersion < 1 {
return nil, fail(400, "VALIDATION_FAILED", "执行许可参数不正确")
}
return s.mutate(ctx, d, "device:"+d.ID, "attempt", id, raw, 30*time.Second, func(tx pgx.Tx, current Device) (map[string]any, error) {
j, err := readJob(ctx, tx, d.ID)
if err != nil {
return nil, err
}
if err = validConfig(s, current, j, in.ActivationID, in.ConfigVersion); err != nil {
return nil, err
}
if !s.options.ActivationEnabled {
return nil, fail(503, "ACTIVATION_PAUSED", "激活暂未开放")
}
var status string
var version, gen int64
var expires time.Time
err = tx.QueryRow(ctx, `SELECT status,config_version,binding_generation,permit_expires_at FROM device_activation_attempt WHERE id=$1::uuid AND activation_id=$2::uuid AND executor='DEVICE'`, in.AttemptID, j.ID).Scan(&status, &version, &gen, &expires)
if err == nil {
if version != j.ConfigVersion || gen != current.Generation {
return nil, errState
}
if status != "CONTACT_POSSIBLE" || !expires.After(s.now()) {
return nil, fail(409, "ATTEMPT_ALREADY_ISSUED", "执行编号已使用或过期,请上报原执行结果")
}
return s.attemptResponse(ctx, tx, current, in.AttemptID, expires)
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
var busy bool
var recent int
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_activation_attempt WHERE activation_id=$1::uuid AND status IN ('CONTACT_POSSIBLE','UNKNOWN')),count(*) FILTER(WHERE created_at>now()-interval '1 hour') FROM device_activation_attempt WHERE activation_id=$1::uuid AND executor='DEVICE'`, j.ID).Scan(&busy, &recent); err != nil {
return nil, err
}
if busy || oneOf(j.Status, "RECONCILE_REQUIRED", "CONTACT_POSSIBLE") {
return nil, fail(409, "ATTEMPT_RECONCILIATION_REQUIRED", "已有执行尚未确认结果")
}
if recent >= 10 {
return nil, fail(429, "ACTIVATION_ATTEMPT_LIMIT", "激活尝试过于频繁")
}
stage := "SDK_REGISTER"
if j.Registered != nil {
if j.Ack == nil {
return nil, fail(409, "ACTIVATION_STAGE_NOT_READY", "请先保存并确认注册结果")
}
if j.Status == "READY" {
return nil, fail(409, "ACTIVATION_ALREADY_READY", "本代绑定已完成激活")
}
stage = "CONVERSATION_TEST"
}
expires = s.now().Add(30 * time.Second)
_, err = tx.Exec(ctx, `INSERT INTO device_activation_attempt(id,activation_id,executor,stage,binding_generation,config_version,status,permit_expires_at) VALUES($1::uuid,$2::uuid,'DEVICE',$3,$4,$5,'CONTACT_POSSIBLE',$6)`, in.AttemptID, j.ID, stage, current.Generation, j.ConfigVersion, expires)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET status='CONTACT_POSSIBLE',current_attempt_id=$2::uuid,version=version+1,updated_at=now() WHERE id=$1::uuid`, j.ID, in.AttemptID)
if err != nil {
return nil, err
}
if err = bump(ctx, tx, d.ID); err != nil {
return nil, err
}
return s.attemptResponse(ctx, tx, current, in.AttemptID, expires)
})
}
func (s *Service) attemptResponse(ctx context.Context, tx pgx.Tx, d Device, id string, expires time.Time) (map[string]any, error) {
st, err := s.state(ctx, tx, d)
return map[string]any{"allowed": true, "attempt_id": id, "permit_expires_at_ms": expires.UnixMilli(), "state": st}, err
}
type ResultInput struct {
ActivationID string `json:"activation_id"`
AttemptID string `json:"attempt_id"`
Stage string `json:"stage"`
SDKResult int `json:"sdk_result"`
Outcome string `json:"outcome"`
}
func (s *Service) ActivationResult(ctx context.Context, d Device, id string, raw []byte, in ResultInput) (map[string]any, error) {
if !uuidV4(in.ActivationID) || !uuidV4(in.AttemptID) || !oneOf(in.Stage, "SDK_REGISTER", "CONVERSATION_TEST") || !oneOf(in.Outcome, "SUCCESS_OBSERVED", "DEFINITE_FAILURE", "UNKNOWN") {
return nil, fail(400, "VALIDATION_FAILED", "激活结果格式不正确")
}
if in.Outcome == "SUCCESS_OBSERVED" && in.SDKResult != 0 {
return nil, fail(400, "SDK_RESULT_CONFLICT", "成功结果需要SDK成功码")
}
return s.mutate(ctx, d, "device:"+d.ID, "activation-result", id, raw, 24*time.Hour, func(tx pgx.Tx, current Device) (map[string]any, error) {
j, err := readJob(ctx, tx, d.ID)
if err != nil {
return nil, err
}
var stage, status string
var gen, version int64
var stored, conversation *string
err = tx.QueryRow(ctx, `SELECT stage,status,binding_generation,config_version,result_digest,conversation_id::text FROM device_activation_attempt WHERE id=$1::uuid AND activation_id=$2::uuid AND executor='DEVICE' FOR UPDATE`, in.AttemptID, in.ActivationID).Scan(&stage, &status, &gen, &version, &stored, &conversation)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errAccess
}
if err != nil {
return nil, err
}
// Result delivery can occur after permit/config expiry, but never after
// configuration replacement or a binding change.
if j.ID != in.ActivationID || j.Generation != current.Generation || gen != current.Generation || version != j.ConfigVersion || stage != in.Stage || j.Status == "BLOCKED" {
return nil, errState
}
if stored != nil {
if *stored != digest(raw) {
return nil, fail(409, "ATTEMPT_RESULT_CONFLICT", "执行已有不同结果,请运维对账")
}
return s.activationResultResponse(ctx, tx, current, j.ID)
}
if !oneOf(status, "CONTACT_POSSIBLE", "UNKNOWN") {
return nil, errState
}
if in.Stage == "CONVERSATION_TEST" && in.Outcome == "SUCCESS_OBSERVED" {
if j.Registered == nil || j.Ack == nil {
return nil, errState
}
// Direct SDK calls report their observation in C09. If the test used
// SaaS orchestration, validate the already stored events by linkage.
if conversation != nil {
if err = validateLinkedTest(ctx, tx, current, *conversation); err != nil {
return nil, err
}
}
}
_, err = tx.Exec(ctx, `UPDATE device_activation_attempt SET status=$2,sdk_result=$3,result_digest=$4,completed_at=now() WHERE id=$1::uuid`, in.AttemptID, in.Outcome, in.SDKResult, digest(raw))
if err != nil {
return nil, err
}
target := "RESERVED"
if j.Registered != nil {
target = "REGISTERED_OBSERVED"
}
if in.Outcome == "UNKNOWN" {
target = "RECONCILE_REQUIRED"
}
if in.Outcome == "SUCCESS_OBSERVED" {
if in.Stage == "SDK_REGISTER" {
target = "REGISTERED_OBSERVED"
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET sdk_registered_at=COALESCE(sdk_registered_at,now()),engine_ready_at=now() WHERE id=$1::uuid`, j.ID)
} else {
target = "READY"
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET ready_binding_generation=$2 WHERE id=$1::uuid`, j.ID, current.Generation)
if err == nil {
err = requireLifecycle(ctx, tx, current, deviceactivation.Active, id)
}
}
}
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET status=$2,last_error_code=CASE WHEN $2='RECONCILE_REQUIRED' THEN 'DEVICE_RESULT_UNCERTAIN' ELSE NULL END,version=version+1,updated_at=now() WHERE id=$1::uuid`, j.ID, target)
if err != nil {
return nil, err
}
if err = bump(ctx, tx, d.ID); err != nil {
return nil, err
}
return s.activationResultResponse(ctx, tx, current, j.ID)
})
}
func (s *Service) activationResultResponse(ctx context.Context, tx pgx.Tx, d Device, id string) (map[string]any, error) {
st, err := s.state(ctx, tx, d)
return map[string]any{"activation_id": id, "state": st, "reconcile_required": st.ActivationState == "RECONCILE_REQUIRED"}, err
}
func validateLinkedTest(ctx context.Context, tx pgx.Tx, d Device, conversation string) error {
var valid bool
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM conversation c JOIN hardware_agent_event a ON a.conversation_id=c.id AND a.event_type='session_started' JOIN hardware_agent_event b ON b.conversation_id=c.id AND b.event_type='first_audio_received' JOIN hardware_agent_event z ON z.conversation_id=c.id AND z.event_type='session_ended' WHERE c.id=$1::uuid AND c.device_id=$2::uuid AND c.onboarding_generation=$3 AND c.status='COMPLETED' AND a.sequence_no<b.sequence_no AND b.sequence_no<z.sequence_no AND z.created_at-a.created_at<=interval '90 seconds')`, conversation, d.ID, d.Generation).Scan(&valid)
if err != nil {
return err
}
if !valid {
return fail(409, "TEST_EVIDENCE_REQUIRED", "关联首通尚未完成连接、首音频及结束")
}
return nil
}
+222
View File
@@ -0,0 +1,222 @@
package onboarding
import (
"bytes"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/google/uuid"
)
func (f *fixture) deviceCall(path string, body any, id string, secure bool) (int, map[string]any) {
f.t.Helper()
raw, ok := body.([]byte)
if !ok {
raw = requestJSON(body)
}
if id == "" {
id = uuid.NewString()
}
path = "/api/v1/device/" + path
r := httptest.NewRequest("POST", "https://platform.example"+path, bytes.NewReader(raw))
if !secure {
r.TLS = nil
}
nonce, timestamp := randomToken(16), time.Now().UnixMilli()
r.Header.Set("Content-Type", "application/json")
if !strings.HasSuffix(path, "time-challenges") {
r.Header.Set("X-Device-ID", f.d.FactoryID)
r.Header.Set("X-Key-Version", "1")
r.Header.Set("X-Timestamp-Ms", strconv.FormatInt(timestamp, 10))
r.Header.Set("X-Nonce", nonce)
r.Header.Set("X-Request-ID", id)
r.Header.Set("Authorization", "Device-HMAC-SHA256 "+RequestMAC(f.d.Key, "POST", path, f.d.FactoryID, 1, timestamp, nonce, id, raw))
}
mux := http.NewServeMux()
f.s.RegisterRoutes(mux)
w := httptest.NewRecorder()
mux.ServeHTTP(w, r)
var result map[string]any
must(f.t, json.Unmarshal(w.Body.Bytes(), &result))
return w.Code, result
}
func fieldsEqual(t *testing.T, v map[string]any, fields string) {
t.Helper()
names := strings.Fields(fields)
if len(v) != len(names) {
t.Fatalf("unexpected fields: %v; expected %s", mapKeys(v), fields)
}
for _, name := range names {
if _, ok := v[name]; !ok {
t.Fatalf("missing %s", name)
}
}
}
func mapKeys(v map[string]any) []string {
keys := []string{}
for k := range v {
keys = append(keys, k)
}
return keys
}
func dataOK(t *testing.T, status int, v map[string]any, fields string) map[string]any {
t.Helper()
if status != 200 {
t.Fatalf("status=%d code=%v", status, v["code"])
}
fieldsEqual(t, v, "ok code request_id server_time_ms data")
d := v["data"].(map[string]any)
fieldsEqual(t, d, fields)
if state, ok := d["state"].(map[string]any); ok {
fieldsEqual(t, state, "device_id device_revision binding_generation binding_state activation_state state_hash")
}
return d
}
func TestIntegrationDeviceWireContract(t *testing.T) {
f := newFixture(t)
// C01 uses plain HTTP without a device Authorization header. Only a signed
// time packet is returned; key material and business data are absent.
in := TimeChallengeInput{DeviceID: f.d.FactoryID, KeyVersion: 1, BootID: f.d.BootID, ClientNonce: randomToken(16)}
status, response := f.deviceCall("time-challenges", in, "", false)
if status != 200 {
t.Fatal(status)
}
fieldsEqual(t, response, "payload_b64 mac_hex")
if !sameSecret(domainMAC(f.d.Key, "TIME1\n"+response["payload_b64"].(string)), response["mac_hex"].(string)) {
t.Fatal("time MAC")
}
decoded, err := base64.RawURLEncoding.DecodeString(response["payload_b64"].(string))
must(t, err)
var packet map[string]any
must(t, json.Unmarshal(decoded, &packet))
fieldsEqual(t, packet, "device_id boot_id client_nonce challenge_id server_time_ms expires_at_ms")
if packet["client_nonce"] != in.ClientNonce || packet["boot_id"] != in.BootID || packet["device_id"] != in.DeviceID {
t.Fatal("time echo")
}
reg := RegisterInput{ChallengeID: packet["challenge_id"].(string), BootID: f.d.BootID, Model: f.d.Model, HardwareRevision: f.d.HardwareRevision, FirmwareVersion: "1.2.0", SDKVersion: "1.0.0"}
status, response = f.deviceCall("register", reg, "", true)
dataOK(t, status, response, "state contract_version")
setup := SetupInput{BootID: f.d.BootID, LocalSetupID: uuid.NewString(), Transport: "LOCAL_WEB"}
// Keep the established fixture setup: a repeated creation with the same
// local identity is independently checked in the setup/recovery tests.
must(t, f.s.pool.QueryRow(f.ctx, `SELECT local_setup_id::text FROM device_setup_session WHERE id=$1::uuid`, f.setup).Scan(&setup.LocalSetupID))
status, response = f.deviceCall("setup-sessions", setup, "", true)
dataOK(t, status, response, "setup_id expires_at_ms poll_after_ms state")
handoff := HandoffInput{SetupID: f.setup, LocalID: setup.LocalSetupID, Challenge: encode64(make([]byte, 32))}
status, response = f.deviceCall("handoffs", handoff, "", true)
dataOK(t, status, response, "handoff_token expires_at_ms entry_url")
f.pending()
status, response = f.deviceCall("claim-confirmations", f.confirm, "", true)
dataOK(t, status, response, "claim_id state already_applied")
f.refresh()
poll := PollInput{SetupID: f.setup, BootID: f.d.BootID}
status, response = f.deviceCall("poll", poll, "", true)
data := dataOK(t, status, response, "state command poll_after_ms")
st := data["state"].(map[string]any)
ack := StateAckInput{Revision: int64(st["device_revision"].(float64)), Generation: f.d.Generation, Hash: st["state_hash"].(string), LocalGeneration: 1}
ackID := uuid.NewString()
status, response = f.deviceCall("state-acks", ack, ackID, true)
dataOK(t, status, response, "accepted_revision setup_can_close")
f.s.now = func() time.Time { return time.Now().Add(31 * time.Second) }
status, response = f.deviceCall("state-acks", ack, ackID, true)
if !dataOK(t, status, response, "accepted_revision setup_can_close")["setup_can_close"].(bool) {
t.Fatal("ACK replay kept stale close result")
}
f.s.now = time.Now
j := f.job()
attempt := AttemptInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, AttemptID: uuid.NewString()}
status, response = f.deviceCall("activation-attempts", attempt, "", true)
dataOK(t, status, response, "allowed attempt_id permit_expires_at_ms state")
result := ResultInput{ActivationID: j.ID, AttemptID: attempt.AttemptID, Stage: "SDK_REGISTER", Outcome: "SUCCESS_OBSERVED"}
status, response = f.deviceCall("activation-results", result, "", true)
dataOK(t, status, response, "activation_id state reconcile_required")
activationAck := ActivationAckInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, LocalGeneration: 2}
status, response = f.deviceCall("activation-acks", activationAck, "", true)
dataOK(t, status, response, "accepted state")
attempt.AttemptID = uuid.NewString()
status, response = f.deviceCall("activation-attempts", attempt, "", true)
dataOK(t, status, response, "allowed attempt_id permit_expires_at_ms state")
result.AttemptID = attempt.AttemptID
result.Stage = "CONVERSATION_TEST"
status, response = f.deviceCall("activation-results", result, "", true)
data = dataOK(t, status, response, "activation_id state reconcile_required")
st = data["state"].(map[string]any)
if st["activation_state"] != "READY" {
t.Fatal("direct SDK first call not READY")
}
closeInput := CloseInput{SetupID: f.setup, Reason: "CANCELLED", Revision: int64(st["device_revision"].(float64))}
status, response = f.deviceCall("setup-close", closeInput, "", true)
dataOK(t, status, response, "close_allowed state")
}
func TestIntegrationDeviceRejectsRemovedAndMissingFields(t *testing.T) {
f := newFixture(t)
for _, body := range []map[string]any{
{"setup_id": f.setup, "boot_id": f.d.BootID},
{"setup_id": f.setup, "boot_id": f.d.BootID, "known_revision": nil},
{"setup_id": f.setup, "boot_id": f.d.BootID, "known_revision": 2147483648},
} {
status, r := f.deviceCall("poll", body, "", true)
if status != 400 {
t.Fatal("invalid required counter", status)
}
fieldsEqual(t, r, "ok code request_id message")
}
for path, body := range map[string]map[string]any{
"activation-attempts": {"activation_id": uuid.NewString(), "config_version": 1, "attempt_id": uuid.NewString(), "stage": "SDK_REGISTER"},
"activation-results": {"activation_id": uuid.NewString(), "attempt_id": uuid.NewString(), "stage": "SDK_REGISTER", "sdk_result": 0, "outcome": "SUCCESS_OBSERVED", "evidence": map[string]any{}},
"activation-acks": {"activation_id": uuid.NewString(), "config_version": 1, "local_generation": 1, "config_digest": strings.Repeat("a", 64)},
} {
status, _ := f.deviceCall(path, body, "", true)
if status != 400 {
t.Fatal(path, status)
}
}
status, r := f.deviceCall("poll", PollInput{SetupID: f.setup, BootID: f.d.BootID}, "", false)
if status != 400 || r["code"] != "HTTPS_REQUIRED" {
t.Fatal("business HTTP accepted")
}
}
func TestIntegrationRebootInvalidatesUncertainPermit(t *testing.T) {
f := newFixture(t)
f.bound()
must(t, f.s.ProcessActivation(f.ctx, f.event()))
f.refresh()
j := f.job()
input := AttemptInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, AttemptID: uuid.NewString()}
id := uuid.NewString()
_, err := f.s.Attempt(f.ctx, f.d, id, requestJSON(input), input)
must(t, err)
boot := uuid.NewString()
challenge, err := f.s.TimeChallenge(f.ctx, TimeChallengeInput{DeviceID: f.d.FactoryID, KeyVersion: 1, BootID: boot, ClientNonce: randomToken(16)})
must(t, err)
raw, err := base64.RawURLEncoding.DecodeString(challenge["payload_b64"].(string))
must(t, err)
var packet map[string]any
must(t, json.Unmarshal(raw, &packet))
reg := RegisterInput{ChallengeID: packet["challenge_id"].(string), BootID: boot, Model: f.d.Model, HardwareRevision: f.d.HardwareRevision, FirmwareVersion: "1.2", SDKVersion: "1.0"}
_, err = f.s.Register(f.ctx, f.d, uuid.NewString(), requestJSON(reg), reg)
must(t, err)
f.d, err = f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 1)
must(t, err)
_, err = f.s.Attempt(f.ctx, f.d, id, requestJSON(input), input)
if err == nil {
t.Fatal("reboot replayed an uncertain SDK permit")
}
input.AttemptID = uuid.NewString()
_, err = f.s.Attempt(f.ctx, f.d, uuid.NewString(), requestJSON(input), input)
if err == nil {
t.Fatal("reboot issued another SDK permit")
}
if f.job().Status != "RECONCILE_REQUIRED" {
t.Fatal("reboot lost uncertainty")
}
}
+42
View File
@@ -0,0 +1,42 @@
package onboarding
import (
"encoding/json"
"reflect"
"strings"
)
// Device tables define every request field as required, including zero-valued
// counters. Go's JSON decoder alone cannot distinguish zero from omission.
func decodeDeviceCall[T any](raw []byte, fn func(T) (map[string]any, error)) (map[string]any, error) {
var in T
if err := StrictJSON(raw, &in); err != nil {
return nil, err
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
return nil, err
}
v := reflect.ValueOf(in)
for i := 0; i < v.NumField(); i++ {
name := strings.Split(v.Type().Field(i).Tag.Get("json"), ",")[0]
if name == "-" || name == "" {
continue
}
value, present := fields[name]
if !present || string(value) == "null" {
return nil, fail(400, "VALIDATION_FAILED", "缺少必填字段:"+name)
}
if v.Field(i).Kind() == reflect.Int64 || v.Field(i).Kind() == reflect.Int {
n := v.Field(i).Int()
minimum := int64(0)
if name == "sdk_result" {
minimum = -2147483648
}
if n < minimum || n > 2147483647 {
return nil, fail(400, "VALIDATION_FAILED", "整数超出范围:"+name)
}
}
}
return fn(in)
}
+126
View File
@@ -0,0 +1,126 @@
package onboarding
import (
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/embeddingprovider"
"data-terminal-cloud/internal/idempotency"
"data-terminal-cloud/internal/orchestration"
"data-terminal-cloud/internal/outbox"
"data-terminal-cloud/internal/rtc"
"github.com/google/uuid"
"testing"
"time"
)
func TestIntegrationFullFirstCallThroughOrchestration(t *testing.T) {
f := newFixture(t)
f.bound()
j := f.registerSDK()
// This test exercises the existing SaaS call path for a device whose supplier
// credential is already available in the platform credential store.
tx, err := f.s.pool.Begin(f.ctx)
must(t, err)
must(t, f.s.storeRuntimeCredential(f.ctx, tx, f.d, j, []byte("fixture-device-secret-at-least-32-bytes")))
must(t, tx.Commit(f.ctx))
ack := ActivationAckInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, LocalGeneration: 1}
_, err = f.s.ActivationAck(f.ctx, f.d, uuid.NewString(), requestJSON(ack), ack)
must(t, err)
attempt := AttemptInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, AttemptID: uuid.NewString()}
_, err = f.s.Attempt(f.ctx, f.d, uuid.NewString(), requestJSON(attempt), attempt)
must(t, err)
tokens, err := rtc.NewLocalTokenIssuer([]byte("first-call-fixture-token-key-32-bytes-minimum"))
must(t, err)
leases, err := orchestration.NewRedisLeaseCache(f.s.redis, "onboarding-test:"+uuid.NewString())
must(t, err)
sessions, err := orchestration.NewService(idempotency.NewExecutor(f.s.pool), orchestration.NewRepository(), audit.NewRepository(), outbox.NewStore(f.s.pool), tokens, leases, orchestration.ServiceOptions{RTCAppID: "test-app", VoiceProvider: "mock", EngineMode: "s2s_hybrid", AllocationTTL: time.Minute, TokenTTL: time.Hour, HeartbeatInterval: 10, HardwareBotID: j.Bot, HardwareTransport: j.Transport})
must(t, err)
must(t, sessions.ConfigureMemoryEmbedding(f.s.pool, embeddingprovider.Local{}))
identity := deviceauth.Identity{ID: f.d.ID, PublicID: f.d.PublicID, TenantID: f.d.TenantID, Status: f.d.Status, ActivationState: f.d.Lifecycle, EnrollmentMode: "physical_confirm", BindingGeneration: f.d.Generation, ActivationTest: true}
var revision int64
must(t, f.s.pool.QueryRow(f.ctx, `SELECT desired_revision FROM device_twin WHERE device_id=$1::uuid`, f.d.ID).Scan(&revision))
input := orchestration.HardwareAgentCreateInput{DeviceTime: time.Now(), ConfigRevision: revision, RequestedGoal: "首次通话测试", Capabilities: orchestration.HardwareAgentCapabilities{HardwareAgent: true, SDKVersion: "1.0.0", Transports: []string{"high_quality"}, DynamicSessionConfig: true, ServerEventCallback: true}}
_, _, err = sessions.CreateHardwareAgent(f.ctx, identity, uuid.NewString(), uuid.NewString(), requestJSON(input), input)
if err != orchestration.ErrOnboardingState {
t.Fatalf("normal call before READY: %v", err)
}
input.Intent = "activation_test"
input.ActivationID = j.ID
input.AttemptID = attempt.AttemptID
input.PermitToken = ""
key := uuid.NewString()
created, replayed, err := sessions.CreateHardwareAgent(f.ctx, identity, key, uuid.NewString(), requestJSON(input), input)
must(t, err)
if replayed || created.Session.MaximumDurationSeconds != 60 {
t.Fatal("test allocation missing duration")
}
again, replayed, err := sessions.CreateHardwareAgent(f.ctx, identity, key, uuid.NewString(), requestJSON(input), input)
must(t, err)
if !replayed || again.ConversationID != created.ConversationID {
t.Fatal("test retry allocated another conversation")
}
_, _, err = sessions.CreateHardwareAgent(f.ctx, identity, uuid.NewString(), uuid.NewString(), requestJSON(input), input)
if err == nil {
t.Fatal("same permit created two conversations")
}
startedID, audioID, endedID := uuid.NewString(), uuid.NewString(), uuid.NewString()
event := orchestration.HardwareAgentEventInput{EventID: startedID, SequenceNo: 1, EventType: "session_started", OccurredAt: time.Now(), ProviderSessionRef: created.Runtime.ProviderSessionRef, SessionNonce: created.Runtime.SessionNonce, ProviderConnectionRef: "fixture-connection", BotID: created.Runtime.BotID, Transport: created.Runtime.Transport, ConfigDigest: created.Runtime.ConfigDigest, DynamicConfigApplied: true}
_, err = sessions.ProcessHardwareAgentEvent(f.ctx, identity, created.ConversationID, requestJSON(event), event)
must(t, err)
event.EventID = audioID
event.SequenceNo = 2
event.EventType = "first_audio_received"
event.OccurredAt = time.Now()
_, err = sessions.ProcessHardwareAgentEvent(f.ctx, identity, created.ConversationID, requestJSON(event), event)
must(t, err)
event.EventID = endedID
event.SequenceNo = 3
event.EventType = "session_ended"
event.OccurredAt = time.Now()
_, err = sessions.ProcessHardwareAgentEvent(f.ctx, identity, created.ConversationID, requestJSON(event), event)
must(t, err)
result := ResultInput{ActivationID: j.ID, AttemptID: attempt.AttemptID, Stage: "CONVERSATION_TEST", Outcome: "SUCCESS_OBSERVED"}
_, err = f.s.ActivationResult(f.ctx, f.d, uuid.NewString(), requestJSON(result), result)
must(t, err)
f.refresh()
if f.d.Lifecycle != "ACTIVE" {
t.Fatalf("lifecycle=%s", f.d.Lifecycle)
}
var state string
must(t, f.s.pool.QueryRow(f.ctx, `SELECT status FROM device_activation_job WHERE id=$1::uuid`, j.ID).Scan(&state))
if state != "READY" {
t.Fatal(state)
}
// Renewing an otherwise unchanged configuration requires durable ACK, but
// reuses this generation's observed first-call evidence.
_, err = f.s.pool.Exec(f.ctx, `UPDATE device_activation_job SET config_expires_at=now()-interval '1 second' WHERE id=$1::uuid`, j.ID)
must(t, err)
tx, err = f.s.pool.Begin(f.ctx)
must(t, err)
currentJob, err := readJob(f.ctx, tx, f.d.ID)
must(t, err)
must(t, f.s.ensureConfig(f.ctx, tx, f.d, &currentJob))
must(t, tx.Commit(f.ctx))
if currentJob.ConfigVersion <= j.ConfigVersion || currentJob.Status != "REGISTERED_OBSERVED" || currentJob.Ack != nil || currentJob.Engine == nil {
t.Fatal("invalid renewal state")
}
ack.ConfigVersion = currentJob.ConfigVersion
ack.LocalGeneration++
_, err = f.s.ActivationAck(f.ctx, f.d, uuid.NewString(), requestJSON(ack), ack)
must(t, err)
must(t, f.s.pool.QueryRow(f.ctx, `SELECT status FROM device_activation_job WHERE id=$1::uuid`, j.ID).Scan(&state))
if state != "READY" {
t.Fatal("renewal ACK did not restore READY")
}
if f.registrar.calls.Load() != 0 {
t.Fatal("renewal repeated provider registration")
}
input.Intent = ""
input.ActivationID = ""
input.AttemptID = ""
input.PermitToken = ""
identity.ActivationTest = false
identity.ActivationState = "ACTIVE"
_, _, err = sessions.CreateHardwareAgent(f.ctx, identity, uuid.NewString(), uuid.NewString(), requestJSON(input), input)
must(t, err)
}
+444
View File
@@ -0,0 +1,444 @@
package onboarding
import (
"data-terminal-cloud/internal/identity"
"encoding/json"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
)
func (s *Service) cookieName(account bool) string {
n := "__Host-onboarding"
if account {
n = "__Host-platform-onboarding"
}
if s.options.AllowLocalHTTP {
return strings.TrimPrefix(n, "__Host-")
}
return n
}
func (s *Service) secure(r *http.Request) bool {
if r.TLS != nil {
return true
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip, err := netip.ParseAddr(host)
if err != nil {
return false
}
if s.options.AllowLocalHTTP && ip.IsLoopback() {
return true
}
for _, cidr := range s.options.TrustedProxyCIDRs {
prefix, err := netip.ParsePrefix(cidr)
if err == nil && prefix.Contains(ip) && r.Header.Get("X-Forwarded-Proto") == "https" {
return true
}
}
return false
}
func (s *Service) sameOrigin(r *http.Request) bool {
entry, _ := url.Parse(s.options.EntryURL)
return r.Header.Get("Origin") == entry.Scheme+"://"+entry.Host
}
func (s *Service) RegisterRoutes(mux *http.ServeMux) {
for _, name := range []string{"time-challenges", "register", "setup-sessions", "handoffs", "poll", "claim-confirmations", "state-acks", "activation-attempts", "activation-results", "activation-acks", "setup-close"} {
s.mount(mux, "POST /api/v1/device/"+name, "device")
}
for _, name := range []string{"handoffs/exchange", "auth/sms/send", "auth/sms/verify", "setup/auth-link", "claims", "setup/status", "setup/ack", "claims/cancel", "profiles", "profiles/create"} {
s.mount(mux, "POST /api/v1/web/"+name, "web")
}
s.mount(mux, "GET /api/v1/web/setup/session", "bootstrap")
s.mount(mux, "GET /api/v1/web/onboarding/devices", "account")
s.mount(mux, "GET /api/v1/web/account/session", "account-session")
s.mount(mux, "POST /api/v1/web/auth/logout", "logout")
s.mount(mux, "POST /api/v1/ops/device-inventory", "ops")
s.mount(mux, "GET /api/v1/ops/device-inventory", "ops")
for _, name := range []string{"credential-deliveries", "release", "credential-rotations"} {
s.mount(mux, "POST /api/v1/ops/device-inventory/{id}/"+name, "ops")
}
s.mount(mux, "POST /api/v1/ops/onboarding-activations/{id}/reconcile", "ops")
}
func (s *Service) mount(mux *http.ServeMux, pattern, kind string) {
mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { s.serve(w, r, kind) })
}
func (s *Service) serve(w http.ResponseWriter, r *http.Request, kind string) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
id := r.Header.Get("X-Request-ID")
if id == "" && (r.Method == http.MethodGet || r.URL.Path == "/api/v1/device/time-challenges") {
id = uuid.NewString()
}
if !uuidV4(id) {
s.write(w, id, nil, fail(400, "REQUEST_ID_REQUIRED", "X-Request-ID必须是UUID v4"))
return
}
if r.URL.Path != "/api/v1/device/time-challenges" && !s.secure(r) {
s.write(w, id, nil, fail(400, "HTTPS_REQUIRED", "此接口必须通过HTTPS访问"))
return
}
host, _, _ := net.SplitHostPort(r.RemoteAddr)
if host == "" {
host = "unknown"
}
if err := s.limit(r.Context(), "ip:"+host, 300, time.Minute); err != nil {
s.write(w, id, nil, err)
return
}
if strings.HasSuffix(r.URL.Path, "/auth/sms/send") {
if err := s.limit(r.Context(), "sms:ip:"+host, 20, time.Hour); err != nil {
s.write(w, id, nil, err)
return
}
}
var data any
var err error
switch kind {
case "device":
data, err = s.deviceHTTP(r, id)
case "web":
data, err = s.webHTTP(w, r, id)
case "bootstrap":
data, err = s.bootstrapHTTP(r)
case "ops":
data, err = s.opsHTTP(r, id)
case "account":
var p identity.Principal
p, err = s.browserPrincipal(r, false)
if err == nil {
data, err = s.AccountDevices(r.Context(), p)
}
case "account-session":
data, err = s.accountSessionHTTP(r)
case "logout":
data, err = s.logoutHTTP(w, r)
}
if r.URL.Path == "/api/v1/device/time-challenges" && err == nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(data)
return
}
s.write(w, id, data, err)
}
func (s *Service) write(w http.ResponseWriter, id string, data any, err error) {
status := 200
code, message := "OK", ""
if err != nil {
data = nil
var e *Error
if errors.As(err, &e) {
status = e.Status
code = e.Code
message = e.Message
} else {
var db *pgconn.PgError
if errors.As(err, &db) && (db.Code == "23505" || db.Code == "P0001") {
status = 409
code = "RESOURCE_STATE_CONFLICT"
message = "资源状态已变化,请刷新后重试"
} else {
status = 500
code = "INTERNAL_ERROR"
message = "服务暂不可用,请按原请求号重试"
kind := "internal"
if errors.As(err, &db) {
kind = "postgres:" + db.Code
}
s.options.Logger.Error("onboarding request failed", "request_id", id, "error_type", kind)
}
}
}
if status == 429 {
w.Header().Set("Retry-After", "60")
}
if status == 503 {
w.Header().Set("Retry-After", "2")
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err != nil {
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "code": code, "message": message, "request_id": id})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "code": code, "request_id": id, "server_time_ms": s.now().UnixMilli(), "data": data})
}
func readBody(r *http.Request) ([]byte, error) {
if !strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "application/json") {
return nil, fail(415, "JSON_REQUIRED", "请使用application/json")
}
raw, err := io.ReadAll(io.LimitReader(r.Body, maxBody+1))
if err != nil {
return nil, fail(400, "INVALID_JSON", "请求正文读取失败")
}
if len(raw) > maxBody {
return nil, fail(413, "REQUEST_TOO_LARGE", "请求正文过长")
}
return raw, nil
}
func decodeCall[T any](raw []byte, fn func(T) (map[string]any, error)) (map[string]any, error) {
var in T
if err := StrictJSON(raw, &in); err != nil {
return nil, err
}
return fn(in)
}
func (s *Service) deviceHTTP(r *http.Request, id string) (any, error) {
if r.URL.RawQuery != "" {
return nil, fail(400, "QUERY_FORBIDDEN", "设备请求不接受query参数")
}
ctx := r.Context()
path := strings.TrimPrefix(r.URL.Path, "/api/v1/device/")
if path == "time-challenges" {
raw, err := readBody(r)
if err != nil {
return nil, err
}
return decodeDeviceCall(raw, func(in TimeChallengeInput) (map[string]any, error) { return s.TimeChallenge(ctx, in) })
}
d, raw, err := s.authenticate(r)
if err != nil {
return nil, err
}
if err = s.limit(ctx, "device:"+d.ID, 180, time.Minute); err != nil {
return nil, err
}
if !strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "application/json") {
return nil, fail(415, "JSON_REQUIRED", "请使用application/json")
}
switch path {
case "register":
return decodeDeviceCall(raw, func(in RegisterInput) (map[string]any, error) { return s.Register(ctx, d, id, raw, in) })
case "setup-sessions":
return decodeDeviceCall(raw, func(in SetupInput) (map[string]any, error) { return s.Setup(ctx, d, id, raw, in) })
case "handoffs":
if !s.Enabled() {
return nil, fail(503, "ENROLLMENT_PAUSED", "首次绑定入口暂时暂停")
}
return decodeDeviceCall(raw, func(in HandoffInput) (map[string]any, error) { return s.Handoff(ctx, d, id, raw, in) })
case "poll":
return decodeDeviceCall(raw, func(in PollInput) (map[string]any, error) { return s.Poll(ctx, d, in) })
case "claim-confirmations":
return decodeDeviceCall(raw, func(in ConfirmationInput) (map[string]any, error) { return s.Confirm(ctx, d, id, raw, in) })
case "state-acks":
return decodeDeviceCall(raw, func(in StateAckInput) (map[string]any, error) { return s.StateAck(ctx, d, id, raw, in) })
case "activation-attempts":
return decodeDeviceCall(raw, func(in AttemptInput) (map[string]any, error) { return s.Attempt(ctx, d, id, raw, in) })
case "activation-results":
return decodeDeviceCall(raw, func(in ResultInput) (map[string]any, error) { return s.ActivationResult(ctx, d, id, raw, in) })
case "activation-acks":
return decodeDeviceCall(raw, func(in ActivationAckInput) (map[string]any, error) { return s.ActivationAck(ctx, d, id, raw, in) })
case "setup-close":
return decodeDeviceCall(raw, func(in CloseInput) (map[string]any, error) { return s.Close(ctx, d, id, raw, in) })
}
return nil, fail(404, "NOT_FOUND", "接口不存在")
}
func (s *Service) browserSession(r *http.Request) (WebSession, error) {
c, err := r.Cookie(s.cookieName(false))
if err != nil || !validB64(c.Value, 32) {
return WebSession{}, fail(401, "WEB_SESSION_EXPIRED", "请从设备打开设置入口")
}
return s.webSession(r.Context(), s.pool, digest([]byte(c.Value)))
}
func (s *Service) issueCookies(w http.ResponseWriter, out BrowserCredentials) {
for _, item := range []struct {
value string
account bool
age int
}{{out.Token, false, 1800}, {out.AccountToken, true, 86400}} {
if item.value != "" {
http.SetCookie(w, &http.Cookie{Name: s.cookieName(item.account), Value: item.value, Path: "/", Secure: !s.options.AllowLocalHTTP, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: item.age})
}
}
}
func (s *Service) bearer(r *http.Request) (identity.Principal, error) {
if s.options.TokenVerifier == nil || !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
return identity.Principal{}, fail(401, "LOGIN_REQUIRED", "请先登录")
}
p, err := s.options.TokenVerifier.Verify(r.Context(), strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if err != nil {
return p, fail(401, "LOGIN_REQUIRED", "登录已失效,请重新登录")
}
return p, nil
}
func (s *Service) browserPrincipal(r *http.Request, write bool) (identity.Principal, error) {
if r.Header.Get("Authorization") != "" {
return s.bearer(r)
}
if write && !s.sameOrigin(r) {
return identity.Principal{}, errAccess
}
c, err := r.Cookie(s.cookieName(true))
if err != nil {
return identity.Principal{}, fail(401, "LOGIN_REQUIRED", "请先登录")
}
return s.AccountPrincipal(r.Context(), c.Value, r.Header.Get("X-CSRF-Token"), write)
}
// Only explicitly selected customer routes receive this cookie adapter.
func (s *Service) CustomerAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.secure(r) {
s.write(w, r.Header.Get("X-Request-ID"), nil, fail(400, "HTTPS_REQUIRED", "此接口必须通过HTTPS访问"))
return
}
p, err := s.browserPrincipal(r, r.Method != http.MethodGet && r.Method != http.MethodHead)
if err != nil {
s.write(w, r.Header.Get("X-Request-ID"), nil, err)
return
}
next.ServeHTTP(w, r.WithContext(identity.WithPrincipal(r.Context(), p)))
})
}
func (s *Service) webHTTP(w http.ResponseWriter, r *http.Request, id string) (any, error) {
if !s.sameOrigin(r) || r.URL.RawQuery != "" {
return nil, errAccess
}
raw, err := readBody(r)
if err != nil {
return nil, err
}
path := strings.TrimPrefix(r.URL.Path, "/api/v1/web/")
ctx := r.Context()
if path == "handoffs/exchange" {
if !s.Enabled() {
return nil, fail(503, "ENROLLMENT_PAUSED", "首次绑定入口暂时暂停")
}
var in ExchangeInput
if err = StrictJSON(raw, &in); err != nil {
return nil, err
}
out, e := s.Exchange(ctx, in)
if e == nil {
s.issueCookies(w, out)
}
return out, e
}
ws, err := s.browserSession(r)
if err != nil {
return nil, err
}
if err = s.checkCSRF(ctx, ws.Hash, r.Header.Get("X-CSRF-Token")); err != nil {
return nil, err
}
switch path {
case "setup/auth-link":
if err = StrictJSON(raw, &struct{}{}); err != nil {
return nil, err
}
p, e := s.bearer(r)
if e != nil {
return nil, e
}
out, e := s.AuthLink(ctx, ws, p)
if e == nil {
s.issueCookies(w, out)
}
return out, e
case "auth/sms/send":
return decodeCall(raw, func(in SMSSendInput) (map[string]any, error) { return s.SendSMS(ctx, ws, id, in) })
case "auth/sms/verify":
var in SMSVerifyInput
if err = StrictJSON(raw, &in); err != nil {
return nil, err
}
out, e := s.VerifySMS(ctx, ws, in)
if e == nil {
s.issueCookies(w, out)
}
return out, e
case "claims":
if !s.Enabled() || s.options.PauseClaims {
return nil, fail(503, "CLAIMS_PAUSED", "新的绑定申请暂时暂停")
}
return decodeCall(raw, func(in ClaimInput) (map[string]any, error) { return s.Claim(ctx, ws, id, raw, in) })
case "claims/cancel":
return decodeCall(raw, func(in CancelInput) (map[string]any, error) { return s.Cancel(ctx, ws, id, raw, in) })
case "setup/status":
return decodeCall(raw, func(in struct{}) (map[string]any, error) { return s.WebStatus(ctx, ws) })
case "setup/ack":
return decodeCall(raw, func(in WebAckInput) (map[string]any, error) { return s.WebAck(ctx, ws, id, raw, in) })
case "profiles":
return decodeCall(raw, func(in struct{}) (map[string]any, error) { return s.Profiles(ctx, ws) })
case "profiles/create":
return decodeCall(raw, func(in ProfileInput) (map[string]any, error) { return s.CreateProfile(ctx, ws, id, raw, in) })
}
return nil, fail(404, "NOT_FOUND", "接口不存在")
}
func (s *Service) bootstrapHTTP(r *http.Request) (any, error) {
if r.URL.RawQuery != "" || r.Header.Get("Sec-Fetch-Site") == "cross-site" {
return nil, errAccess
}
ws, err := s.browserSession(r)
if err != nil {
return nil, err
}
token := randomToken(32)
_, err = s.pool.Exec(r.Context(), `UPDATE onboarding_web_session SET csrf_hash=$2 WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at>now()`, ws.Hash, digest([]byte(token)))
if err != nil {
return nil, err
}
data, err := s.WebStatus(r.Context(), ws)
if err != nil {
return nil, err
}
if c, e := r.Cookie(s.cookieName(true)); e == nil && validB64(c.Value, 32) && ws.UserID != "" {
_, err = s.pool.Exec(r.Context(), `UPDATE onboarding_account_session SET csrf_hash=$2 WHERE token_hash=$1 AND user_id=$3::uuid AND expires_at>now() AND revoked_at IS NULL`, digest([]byte(c.Value)), digest([]byte(token)), ws.UserID)
if err != nil {
return nil, err
}
}
return map[string]any{"csrf_token": token, "data": data}, nil
}
func (s *Service) opsHTTP(r *http.Request, id string) (any, error) {
p, err := s.bearer(r)
if err != nil {
return nil, err
}
tenant := r.Header.Get("X-Tenant-ID")
ctx := r.Context()
if r.Method == http.MethodGet {
return s.OpsList(ctx, p, tenant)
}
raw, err := readBody(r)
if err != nil {
return nil, err
}
device := r.PathValue("id")
switch {
case r.URL.Path == "/api/v1/ops/device-inventory":
if !s.Enabled() {
return nil, fail(503, "ENROLLMENT_PAUSED", "生产登记入口暂停")
}
return decodeCall(raw, func(in InventoryInput) (map[string]any, error) {
return s.CreateInventory(ctx, p, tenant, id, r.Header.Get("Idempotency-Key"), raw, in)
})
case strings.HasSuffix(r.URL.Path, "/credential-deliveries"):
return decodeCall(raw, func(in DeliveryInput) (map[string]any, error) {
return s.DeliverCredential(ctx, p, tenant, device, id, in)
})
case strings.HasSuffix(r.URL.Path, "/release"):
return decodeCall(raw, func(in ReleaseInput) (map[string]any, error) {
return s.ReleaseInventory(ctx, p, tenant, device, id, in)
})
case strings.HasSuffix(r.URL.Path, "/credential-rotations"):
return decodeCall(raw, func(in RotationInput) (map[string]any, error) {
return s.Rotate(ctx, p, tenant, device, r.Header.Get("Idempotency-Key"), id, raw, in)
})
case strings.HasSuffix(r.URL.Path, "/reconcile"):
return decodeCall(raw, func(in ReconcileInput) (map[string]any, error) { return s.Reconcile(ctx, p, tenant, device, id, in) })
}
return nil, fail(404, "NOT_FOUND", "接口不存在")
}
+407
View File
@@ -0,0 +1,407 @@
package onboarding
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"data-terminal-cloud/internal/hardwareprovision"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/outbox"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
)
type testRegistrar struct {
calls atomic.Int32
err error
started chan struct{}
release chan struct{}
}
func (r *testRegistrar) Register(ctx context.Context, in hardwareprovision.RegisterInput) (hardwareprovision.RegisterResult, error) {
r.calls.Add(1)
if r.started != nil {
close(r.started)
select {
case <-r.release:
case <-ctx.Done():
return hardwareprovision.RegisterResult{}, ctx.Err()
}
}
return hardwareprovision.RegisterResult{DeviceSecret: "fixture-device-secret-at-least-32-bytes", RTCAppID: "test-app"}, r.err
}
type fixture struct {
t *testing.T
s *Service
ctx context.Context
tenant, tenantPublic, operatorID string
p, user identity.Principal
d Device
web WebSession
setup, elder, claim string
confirm ConfirmationInput
registrar *testRegistrar
}
func newFixture(t *testing.T) *fixture {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL required")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
must(t, err)
t.Cleanup(pool.Close)
addr := os.Getenv("TEST_REDIS_ADDR")
if addr == "" {
addr = "127.0.0.1:56379"
}
cache := redis.NewClient(&redis.Options{Addr: addr})
must(t, cache.Ping(ctx).Err())
t.Cleanup(func() { _ = cache.Close() })
f := &fixture{t: t, ctx: ctx, tenant: uuid.NewString(), tenantPublic: uuid.NewString(), operatorID: uuid.NewString(), p: identity.Principal{Issuer: "urn:onboarding:test", Subject: uuid.NewString()}, user: identity.Principal{Issuer: "urn:onboarding:test", Subject: uuid.NewString(), DisplayName: "测试用户1234"}, registrar: &testRegistrar{}}
f.s, err = NewService(pool, cache, Options{Enabled: true, MasterKey: strings.Repeat("15", 32), ProviderMasterKey: strings.Repeat("25", 32), EntryURL: "https://platform.example/onboarding/entry", SelfSignupTenantIDs: []string{f.tenantPublic}, ActivationEnabled: true, Registrar: f.registrar, InstanceID: "test-instance", ProductKey: "test-product", ProductSecret: "fixture-product-secret-at-least-32-bytes", BotID: "test-bot", Transport: "high_quality", MinimumSDKVersion: "1.0.0"})
must(t, err)
_, err = pool.Exec(ctx, `INSERT INTO tenant(id,public_id,name) VALUES($1::uuid,$2::uuid,'onboarding integration')`, f.tenant, f.tenantPublic)
must(t, err)
_, err = pool.Exec(ctx, `INSERT INTO user_account(id,tenant_id,oidc_issuer,oidc_subject,display_name,platform_role,account_kind) VALUES($1::uuid,$2::uuid,$3,$4,'生产管理员','ops_admin','operator')`, f.operatorID, f.tenant, f.p.Issuer, f.p.Subject)
must(t, err)
mac := strings.ToUpper(strings.ReplaceAll(uuid.NewString(), "-", ""))[:12]
in := InventoryInput{FactoryMAC: mac, Model: "KP01", HardwareRevision: "A1", BatchNo: "test"}
raw := requestJSON(in)
key := uuid.NewString()
inv, err := f.s.CreateInventory(ctx, f.p, f.tenantPublic, uuid.NewString(), key, raw, in)
must(t, err)
again, err := f.s.CreateInventory(ctx, f.p, f.tenantPublic, uuid.NewString(), key, raw, in)
must(t, err)
if inv["platform_device_id"] != again["platform_device_id"] {
t.Fatal("inventory identity changed")
}
public := inv["platform_device_id"].(string)
job := inv["enrollment_job_id"].(string)
receiver, err := rsa.GenerateKey(rand.Reader, 2048)
must(t, err)
pub, err := x509.MarshalPKIXPublicKey(&receiver.PublicKey)
must(t, err)
_, err = f.s.DeliverCredential(ctx, f.p, f.tenantPublic, public, uuid.NewString(), DeliveryInput{JobID: job, ReceiverKey: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pub}))})
must(t, err)
_, err = f.s.ReleaseInventory(ctx, f.p, f.tenantPublic, public, uuid.NewString(), ReleaseInput{JobID: job, Evidence: "fixture-burn-readback", ExpectedVersion: 1})
must(t, err)
f.d, err = f.s.findFactoryDevice(ctx, "KP01-"+mac, 1)
must(t, err)
boot := uuid.NewString()
challenge, err := f.s.TimeChallenge(ctx, TimeChallengeInput{DeviceID: f.d.FactoryID, KeyVersion: 1, BootID: boot, ClientNonce: randomToken(16)})
must(t, err)
decoded, err := base64.RawURLEncoding.DecodeString(challenge["payload_b64"].(string))
must(t, err)
var packet map[string]any
must(t, json.Unmarshal(decoded, &packet))
reg := RegisterInput{ChallengeID: packet["challenge_id"].(string), BootID: boot, Model: f.d.Model, HardwareRevision: f.d.HardwareRevision, FirmwareVersion: "1.2.0", SDKVersion: "1.0.0"}
requestID := uuid.NewString()
_, err = f.s.Register(ctx, f.d, requestID, requestJSON(reg), reg)
must(t, err)
f.refresh()
_, err = f.s.Register(ctx, f.d, requestID, requestJSON(reg), reg)
must(t, err)
setupIn := SetupInput{BootID: boot, LocalSetupID: uuid.NewString(), Transport: "LOCAL_WEB"}
setup, err := f.s.Setup(ctx, f.d, uuid.NewString(), requestJSON(setupIn), setupIn)
must(t, err)
f.setup = setup["setup_id"].(string)
verifier := randomToken(32)
sum := sha256.Sum256([]byte(verifier))
handoffIn := HandoffInput{SetupID: f.setup, LocalID: setupIn.LocalSetupID, Challenge: encode64(sum[:])}
handoff, err := f.s.Handoff(ctx, f.d, uuid.NewString(), requestJSON(handoffIn), handoffIn)
must(t, err)
credentials, err := f.s.Exchange(ctx, ExchangeInput{Token: handoff["handoff_token"].(string), Verifier: verifier})
must(t, err)
_, err = f.s.Exchange(ctx, ExchangeInput{Token: handoff["handoff_token"].(string), Verifier: verifier})
if err == nil {
t.Fatal("handoff redeemed twice")
}
ws, err := f.s.webSession(ctx, pool, digest([]byte(credentials.Token)))
must(t, err)
credentials, err = f.s.AuthLink(ctx, ws, f.user)
must(t, err)
f.web, err = f.s.webSession(ctx, pool, digest([]byte(credentials.Token)))
must(t, err)
profile := ProfileInput{HouseholdName: "测试家庭", Name: "奶奶"}
result, err := f.s.CreateProfile(ctx, f.web, uuid.NewString(), requestJSON(profile), profile)
must(t, err)
f.elder = result["elder_id"].(string)
return f
}
func must(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
func (f *fixture) refresh() {
f.t.Helper()
var err error
f.d, err = f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 1)
must(f.t, err)
if f.web.Hash != "" {
f.web, err = f.s.webSession(f.ctx, f.s.pool, f.web.Hash)
must(f.t, err)
}
}
func (f *fixture) pending() {
f.t.Helper()
in := ClaimInput{ElderID: f.elder, Generation: f.d.Generation}
result, err := f.s.Claim(f.ctx, f.web, uuid.NewString(), requestJSON(in), in)
must(f.t, err)
f.claim = result["claim_id"].(string)
poll, err := f.s.Poll(f.ctx, f.d, PollInput{SetupID: f.setup, BootID: f.d.BootID})
must(f.t, err)
payload := poll["command"].(map[string]any)["payload"].(map[string]any)
f.confirm = ConfirmationInput{ClaimID: f.claim, Challenge: payload["challenge"].(string), Digest: payload["binding_digest"].(string), ExpectedGeneration: f.d.Generation, PressEventID: uuid.NewString()}
}
func (f *fixture) bound() {
f.t.Helper()
f.pending()
_, err := f.s.Confirm(f.ctx, f.d, uuid.NewString(), requestJSON(f.confirm), f.confirm)
must(f.t, err)
f.refresh()
}
func (f *fixture) event() outbox.Event {
f.t.Helper()
var e outbox.Event
err := f.s.pool.QueryRow(f.ctx, `SELECT id::text,payload FROM outbox_event WHERE aggregate_id=$1::uuid AND event_type=$2 ORDER BY created_at DESC LIMIT 1`, f.d.ID, ActivationEvent).Scan(&e.ID, &e.Payload)
must(f.t, err)
return e
}
func TestIntegrationPhysicalConfirmationAndReplay(t *testing.T) {
f := newFixture(t)
f.pending()
var count int
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM device_binding WHERE device_id=$1::uuid`, f.d.ID).Scan(&count))
if count != 0 {
t.Fatal("claim established ownership before physical confirmation")
}
id := uuid.NewString()
_, err := f.s.Confirm(f.ctx, f.d, id, requestJSON(f.confirm), f.confirm)
must(t, err)
f.refresh()
if f.d.Generation != 1 {
t.Fatal("wrong binding generation")
}
_, err = f.s.Confirm(f.ctx, f.d, id, requestJSON(f.confirm), f.confirm)
must(t, err)
status, err := f.s.WebStatus(f.ctx, f.web)
must(t, err)
if status["binding_id"] == nil {
t.Fatal("binding result not recoverable")
}
_, err = f.s.pool.Exec(f.ctx, `UPDATE household_member SET status='removed' WHERE user_id=$1::uuid`, f.web.UserID)
must(t, err)
_, err = f.s.Confirm(f.ctx, f.d, id, requestJSON(f.confirm), f.confirm)
if !errors.Is(err, errAccess) {
t.Fatalf("replay bypassed revoked permission: %v", err)
}
_, err = f.s.pool.Exec(f.ctx, `UPDATE device_binding SET unbound_at=now() WHERE device_id=$1::uuid AND unbound_at IS NULL`, f.d.ID)
must(t, err)
current, err := readDevice(f.ctx, f.s.pool, f.d.ID, false)
must(t, err)
if current.Generation != 2 {
t.Fatal("unbind did not increment generation")
}
_, err = f.s.Confirm(f.ctx, current, id, requestJSON(f.confirm), f.confirm)
if err == nil {
t.Fatal("old confirmation revived unbound owner")
}
}
func TestIntegrationConcurrentClaimsAndCancel(t *testing.T) {
f := newFixture(t)
in := ClaimInput{ElderID: f.elder, Generation: 0}
var winners atomic.Int32
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, err := f.s.Claim(f.ctx, f.web, uuid.NewString(), requestJSON(in), in)
if err == nil {
winners.Add(1)
}
}()
}
wg.Wait()
if winners.Load() != 1 {
t.Fatalf("claim winners=%d", winners.Load())
}
var id string
must(t, f.s.pool.QueryRow(f.ctx, `SELECT id::text FROM device_binding_claim WHERE device_id=$1::uuid AND status='PENDING'`, f.d.ID).Scan(&id))
cancel := CancelInput{ClaimID: id, Generation: 0}
_, err := f.s.Cancel(f.ctx, f.web, uuid.NewString(), requestJSON(cancel), cancel)
must(t, err)
var challenge, hash string
var encrypted []byte
must(t, f.s.pool.QueryRow(f.ctx, `SELECT challenge_ciphertext,binding_digest FROM device_binding_claim WHERE id=$1::uuid`, id).Scan(&encrypted, &hash))
plain, err := f.s.crypt.open(encrypted, "claim:"+id)
must(t, err)
challenge = string(plain)
confirm := ConfirmationInput{ClaimID: id, Challenge: challenge, Digest: hash, ExpectedGeneration: 0, PressEventID: uuid.NewString()}
_, err = f.s.Confirm(f.ctx, f.d, uuid.NewString(), requestJSON(confirm), confirm)
if err == nil {
t.Fatal("cancelled claim confirmed")
}
}
func (f *fixture) job() activationJob {
f.t.Helper()
tx, err := f.s.pool.Begin(f.ctx)
must(f.t, err)
defer tx.Rollback(f.ctx)
j, err := readJob(f.ctx, tx, f.d.ID)
must(f.t, err)
return j
}
func (f *fixture) registerSDK() activationJob {
f.t.Helper()
must(f.t, f.s.ProcessActivation(f.ctx, f.event()))
f.refresh()
j := f.job()
attempt := AttemptInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, AttemptID: uuid.NewString()}
_, err := f.s.Attempt(f.ctx, f.d, uuid.NewString(), requestJSON(attempt), attempt)
must(f.t, err)
result := ResultInput{ActivationID: j.ID, AttemptID: attempt.AttemptID, Stage: "SDK_REGISTER", Outcome: "SUCCESS_OBSERVED"}
_, err = f.s.ActivationResult(f.ctx, f.d, uuid.NewString(), requestJSON(result), result)
must(f.t, err)
return f.job()
}
func TestIntegrationDeviceUnknownDoesNotRegisterAgain(t *testing.T) {
f := newFixture(t)
f.bound()
must(t, f.s.ProcessActivation(f.ctx, f.event()))
f.refresh()
j := f.job()
attempt := AttemptInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, AttemptID: uuid.NewString()}
permit, err := f.s.Attempt(f.ctx, f.d, uuid.NewString(), requestJSON(attempt), attempt)
must(t, err)
if !permit["allowed"].(bool) || time.UnixMilli(permit["permit_expires_at_ms"].(int64)).Sub(time.Now()) > 30*time.Second {
t.Fatal("permit contract")
}
result := ResultInput{ActivationID: j.ID, AttemptID: attempt.AttemptID, Stage: "SDK_REGISTER", SDKResult: -1, Outcome: "UNKNOWN"}
response, err := f.s.ActivationResult(f.ctx, f.d, uuid.NewString(), requestJSON(result), result)
must(t, err)
if response["reconcile_required"] != true {
t.Fatal("unknown must require reconciliation")
}
attempt.AttemptID = uuid.NewString()
_, err = f.s.Attempt(f.ctx, f.d, uuid.NewString(), requestJSON(attempt), attempt)
if err == nil {
t.Fatal("unknown issued another execution")
}
must(t, f.s.ProcessActivation(f.ctx, f.event()))
must(t, f.s.ProcessActivation(f.ctx, f.event()))
if f.registrar.calls.Load() != 0 {
t.Fatal("SaaS invoked supplier registration")
}
var bound bool
must(t, f.s.pool.QueryRow(f.ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL)`, f.d.ID).Scan(&bound))
if !bound {
t.Fatal("unknown removed binding")
}
}
func TestIntegrationDeviceConfigAndRegistration(t *testing.T) {
f := newFixture(t)
f.bound()
must(t, f.s.ProcessActivation(f.ctx, f.event()))
f.refresh()
response, err := f.s.Poll(f.ctx, f.d, PollInput{SetupID: f.setup, BootID: f.d.BootID})
must(t, err)
payload := response["command"].(map[string]any)["payload"].(map[string]any)
if len(payload) != 2 {
t.Fatal("extra activation command fields")
}
raw, err := base64.RawURLEncoding.DecodeString(payload["config_b64"].(string))
must(t, err)
var config map[string]any
must(t, json.Unmarshal(raw, &config))
if len(config) != 8 || config["contract_version"] != "ONBOARDING-1.1" {
t.Fatal("wrong activation config")
}
iot := config["iot"].(map[string]any)
if len(iot) != 4 || iot["product_secret"] != f.s.options.ProductSecret || iot["device_name"] != f.d.FactoryID {
t.Fatal("wrong SDK registration config")
}
if !sameSecret(domainMAC(f.d.Key, "CFG1\n"+payload["config_b64"].(string)), payload["config_mac"].(string)) {
t.Fatal("config MAC")
}
j := f.job()
ack := ActivationAckInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, LocalGeneration: 1}
_, err = f.s.ActivationAck(f.ctx, f.d, uuid.NewString(), requestJSON(ack), ack)
if err == nil {
t.Fatal("ACK before registration was accepted")
}
f.registerSDK()
_, err = f.s.ActivationAck(f.ctx, f.d, uuid.NewString(), requestJSON(ack), ack)
must(t, err)
if f.job().Status != "REGISTERED_OBSERVED" {
t.Fatal("registration falsely implies first call")
}
if f.registrar.calls.Load() != 0 {
t.Fatal("server performed SDK registration")
}
}
func TestIntegrationDEVREQReplayAndTLS(t *testing.T) {
f := newFixture(t)
in := PollInput{SetupID: f.setup, BootID: f.d.BootID}
raw := requestJSON(in)
path := "/api/v1/device/poll"
id := uuid.NewString()
nonce := randomToken(16)
timestamp := time.Now().UnixMilli()
request := func() *http.Request {
r := httptest.NewRequest("POST", "https://platform.example"+path, bytes.NewReader(raw))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Device-ID", f.d.FactoryID)
r.Header.Set("X-Key-Version", "1")
r.Header.Set("X-Timestamp-Ms", strconv.FormatInt(timestamp, 10))
r.Header.Set("X-Nonce", nonce)
r.Header.Set("X-Request-ID", id)
r.Header.Set("Authorization", "Device-HMAC-SHA256 "+RequestMAC(f.d.Key, "POST", path, f.d.FactoryID, 1, timestamp, nonce, id, raw))
return r
}
mux := http.NewServeMux()
f.s.RegisterRoutes(mux)
w := httptest.NewRecorder()
mux.ServeHTTP(w, request())
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
w = httptest.NewRecorder()
mux.ServeHTTP(w, request())
if w.Code != 401 {
t.Fatal("nonce replay was accepted", w.Code)
}
r := request()
r.TLS = nil
r.Header.Set("X-Forwarded-Proto", "https")
w = httptest.NewRecorder()
mux.ServeHTTP(w, r)
if w.Code != 400 {
t.Fatal("untrusted forwarded HTTPS accepted")
}
}
+299
View File
@@ -0,0 +1,299 @@
package onboarding
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"regexp"
"strings"
"time"
"data-terminal-cloud/internal/deviceactivation"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func encode64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
type operator struct{ ID, PublicID, TenantID, Role string }
func operatorAccess(ctx context.Context, q queryer, principal identity.Principal, tenantPublicID string, write bool) (operator, error) {
if !uuidValid(tenantPublicID) {
return operator{}, errAccess
}
var a operator
err := q.QueryRow(ctx, `SELECT ua.id::text,ua.public_id::text,t.id::text,ua.platform_role FROM user_account ua JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE t.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active' AND ua.account_kind='operator'`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&a.ID, &a.PublicID, &a.TenantID, &a.Role)
if errors.Is(err, pgx.ErrNoRows) {
return a, errAccess
}
if err != nil {
return a, err
}
if write && a.Role != "ops_admin" && a.Role != "ops_operator" {
return a, errAccess
}
return a, nil
}
type InventoryInput struct {
FactoryMAC string `json:"factory_mac"`
Model string `json:"hardware_model"`
HardwareRevision string `json:"hardware_revision"`
BatchNo string `json:"batch_no"`
}
func (s *Service) CreateInventory(ctx context.Context, principal identity.Principal, tenant, requestID, idempotencyKey string, raw []byte, input InventoryInput) (map[string]any, error) {
if len(idempotencyKey) < 16 || len(idempotencyKey) > 160 || !regexp.MustCompile(`^[0-9A-F]{12}$`).MatchString(input.FactoryMAC) || len(input.Model) < 1 || len(input.Model) > 32 || len(input.HardwareRevision) < 1 || len(input.HardwareRevision) > 16 || len(input.BatchNo) < 1 || len(input.BatchNo) > 120 {
return nil, fail(400, "VALIDATION_FAILED", "生产登记字段不正确")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
a, err := operatorAccess(ctx, tx, principal, tenant, true)
if err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "inventory-request:"+a.TenantID+":"+a.ID+":"+idempotencyKey); err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "inventory:"+input.FactoryMAC); err != nil {
return nil, err
}
var jobID, deviceID, storedDigest string
err = tx.QueryRow(ctx, `SELECT id::text,device_id::text,request_digest FROM device_enrollment_job WHERE tenant_id=$1::uuid AND actor_id=$2::uuid AND request_key=$3`, a.TenantID, a.ID, idempotencyKey).Scan(&jobID, &deviceID, &storedDigest)
if err == nil {
if storedDigest != digest(raw) {
return nil, fail(409, "REQUEST_BODY_CONFLICT", "生产请求号已用于其他内容")
}
d, e := readDevice(ctx, tx, deviceID, false)
if e != nil {
return nil, e
}
return inventoryResult(d, jobID), tx.Commit(ctx)
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
var existingTenant, model, revision string
err = tx.QueryRow(ctx, `SELECT id::text,tenant_id::text,model,COALESCE(hardware_revision,'') FROM device WHERE factory_mac=$1 FOR UPDATE`, input.FactoryMAC).Scan(&deviceID, &existingTenant, &model, &revision)
var d Device
if err == nil {
if existingTenant != a.TenantID || model != input.Model || revision != input.HardwareRevision {
return nil, fail(409, "INVENTORY_CONFLICT", "设备已有不同的生产或租户记录")
}
d, err = readDevice(ctx, tx, deviceID, false)
if err != nil {
return nil, err
}
// A repair reuses the latest credential job. It never generates a key.
err = tx.QueryRow(ctx, `SELECT id::text FROM device_enrollment_job WHERE device_id=$1::uuid ORDER BY created_at DESC,id DESC LIMIT 1`, deviceID).Scan(&jobID)
if err != nil {
return nil, err
}
return inventoryResult(d, jobID), tx.Commit(ctx)
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
d = Device{ID: uuid.NewString(), PublicID: uuid.NewString(), TenantID: a.TenantID, FactoryID: "KP01-" + input.FactoryMAC, Model: input.Model, HardwareRevision: input.HardwareRevision, Mode: "physical_confirm"}
_, err = tx.Exec(ctx, `INSERT INTO device(id,public_id,tenant_id,serial_digest,model,hardware_revision,credential_ref,activation_state,activation_reason,enrollment_mode,factory_device_id,factory_mac) VALUES($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,'FACTORY','controlled-production','physical_confirm',$8,$9)`, d.ID, d.PublicID, d.TenantID, digest([]byte(d.FactoryID)), d.Model, d.HardwareRevision, "onboarding://"+d.FactoryID, d.FactoryID, input.FactoryMAC)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `INSERT INTO device_twin(device_id,tenant_id,desired_config) VALUES($1::uuid,$2::uuid,'{"voice_runtime":"volc_hardware_agent"}'::jsonb)`, d.ID, d.TenantID)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `INSERT INTO device_onboarding_key(device_id,tenant_id,key_version,ciphertext,status) VALUES($1::uuid,$2::uuid,1,$3,'PENDING')`, d.ID, d.TenantID, s.crypt.seal(randomBytes(32), keyAAD(d.ID, 1)))
if err != nil {
return nil, err
}
err = tx.QueryRow(ctx, `INSERT INTO device_enrollment_job(device_id,tenant_id,actor_id,request_key,request_digest,batch_no) VALUES($1::uuid,$2::uuid,$3::uuid,$4,$5,$6) RETURNING id::text`, d.ID, d.TenantID, a.ID, idempotencyKey, digest(raw), input.BatchNo).Scan(&jobID)
if err != nil {
return nil, err
}
if err = s.auditEvent(ctx, tx, d, "user", a.PublicID, "device.inventory.create", requestID, map[string]any{"factory_device_id": d.FactoryID, "enrollment_job_id": jobID}); err != nil {
return nil, err
}
return inventoryResult(d, jobID), tx.Commit(ctx)
}
func inventoryResult(d Device, job string) map[string]any {
return map[string]any{"device_id": d.FactoryID, "platform_device_id": d.PublicID, "enrollment_job_id": job, "contract_version": ContractVersion, "released": d.Released}
}
type DeliveryInput struct {
JobID string `json:"enrollment_job_id"`
ReceiverKey string `json:"receiver_public_key_pem"`
}
func (s *Service) DeliverCredential(ctx context.Context, p identity.Principal, tenant, publicID, requestID string, input DeliveryInput) (map[string]any, error) {
if !uuidValid(publicID) || !uuidV4(input.JobID) {
return nil, fail(400, "VALIDATION_FAILED", "交付任务编号不正确")
}
block, _ := pem.Decode([]byte(input.ReceiverKey))
if block == nil {
return nil, fail(400, "INVALID_RECEIVER_KEY", "需要RSA公钥")
}
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fail(400, "INVALID_RECEIVER_KEY", "需要PKIX格式的RSA公钥")
}
key, ok := parsed.(*rsa.PublicKey)
if !ok || key.N.BitLen() < 2048 || key.N.BitLen() > 4096 || key.E != 65537 {
return nil, fail(400, "INVALID_RECEIVER_KEY", "需要2048至4096位RSA公钥")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
a, err := operatorAccess(ctx, tx, p, tenant, true)
if err != nil {
return nil, err
}
var id string
err = tx.QueryRow(ctx, `SELECT id::text FROM device WHERE public_id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, publicID, a.TenantID).Scan(&id)
if err != nil {
return nil, errAccess
}
d, err := readDevice(ctx, tx, id, false)
if err != nil {
return nil, err
}
var version int64
var storedKey, status string
var delivered []byte
var expiry *time.Time
err = tx.QueryRow(ctx, `SELECT key_version,COALESCE(receiver_key,''),delivery_ciphertext,delivery_expires_at,status FROM device_enrollment_job WHERE id=$1::uuid AND device_id=$2::uuid AND tenant_id=$3::uuid FOR UPDATE`, input.JobID, id, a.TenantID).Scan(&version, &storedKey, &delivered, &expiry, &status)
if err != nil {
return nil, errAccess
}
if storedKey != "" && storedKey != input.ReceiverKey {
return nil, fail(409, "RECEIVER_CONFLICT", "同一交付任务不能更换接收方")
}
if status == "RELEASED" || status == "CANCELLED" {
return nil, fail(409, "DELIVERY_CLOSED", "该交付任务已经关闭")
}
if len(delivered) > 0 && expiry != nil && expiry.After(s.now()) {
var result map[string]any
if err = json.Unmarshal(delivered, &result); err != nil {
return nil, err
}
return result, tx.Commit(ctx)
}
var encrypted []byte
err = tx.QueryRow(ctx, `SELECT ciphertext FROM device_onboarding_key WHERE device_id=$1::uuid AND key_version=$2 AND status='PENDING'`, id, version).Scan(&encrypted)
if err != nil {
return nil, err
}
secret, err := s.crypt.open(encrypted, keyAAD(id, version))
if err != nil {
return nil, err
}
// Envelope encryption supports a full manufacturing payload independently
// of the RSA OAEP plaintext size. The receiver unwraps a random AES key.
envelopeKey := randomBytes(32)
ec, _ := newCryptor(fmtHex(envelopeKey))
until := s.now().Add(15 * time.Minute)
plain, _ := json.Marshal(map[string]any{"device_id": d.FactoryID, "platform_device_id": d.PublicID, "device_key_b64": encode64(secret), "key_version": version, "contract_version": ContractVersion, "expires_at_ms": until.UnixMilli()})
wrapped, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, key, envelopeKey, []byte(input.JobID))
if err != nil {
return nil, err
}
result := map[string]any{"algorithm": "RSA-OAEP-SHA256+A256GCM", "enrollment_job_id": input.JobID, "wrapped_key_b64": encode64(wrapped), "ciphertext_b64": encode64(ec.seal(plain, input.JobID)), "expires_at_ms": until.UnixMilli()}
saved, _ := json.Marshal(result)
_, err = tx.Exec(ctx, `UPDATE device_enrollment_job SET receiver_key=$2,delivery_ciphertext=$3,delivery_expires_at=$4,status='DELIVERED' WHERE id=$1::uuid`, input.JobID, input.ReceiverKey, saved, until)
if err != nil {
return nil, err
}
if err = s.auditEvent(ctx, tx, d, "user", a.PublicID, "device.credential.deliver", requestID, map[string]any{"job_id": input.JobID, "key_version": version}); err != nil {
return nil, err
}
return result, tx.Commit(ctx)
}
func fmtHex(b []byte) string {
const alphabet = "0123456789abcdef"
out := make([]byte, len(b)*2)
for i, v := range b {
out[i*2] = alphabet[v>>4]
out[i*2+1] = alphabet[v&15]
}
return string(out)
}
type ReleaseInput struct {
JobID string `json:"enrollment_job_id"`
Evidence string `json:"evidence_ref"`
ExpectedVersion int64 `json:"expected_key_version"`
}
func (s *Service) ReleaseInventory(ctx context.Context, p identity.Principal, tenant, publicID, requestID string, in ReleaseInput) (map[string]any, error) {
if !uuidValid(publicID) || !uuidV4(in.JobID) || len(strings.TrimSpace(in.Evidence)) < 3 || len(in.Evidence) > 500 || in.ExpectedVersion < 1 {
return nil, fail(400, "VALIDATION_FAILED", "需要烧录校验记录和凭据版本")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
a, err := operatorAccess(ctx, tx, p, tenant, true)
if err != nil {
return nil, err
}
var id string
err = tx.QueryRow(ctx, `SELECT id::text FROM device WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND enrollment_mode='physical_confirm' AND status='registered' AND activation_state NOT IN ('LOST','REVOKED') FOR UPDATE`, publicID, a.TenantID).Scan(&id)
if err != nil {
return nil, errAccess
}
d, err := readDevice(ctx, tx, id, false)
if err != nil {
return nil, err
}
var status string
var version int64
err = tx.QueryRow(ctx, `SELECT status,key_version FROM device_enrollment_job WHERE id=$1::uuid AND device_id=$2::uuid FOR UPDATE`, in.JobID, id).Scan(&status, &version)
if err != nil {
return nil, errAccess
}
if version != in.ExpectedVersion || (status != "DELIVERED" && status != "RELEASED") {
return nil, fail(409, "ENROLLMENT_NOT_DELIVERED", "请先完成对应版本凭据交付")
}
if status == "RELEASED" {
return inventoryResult(d, in.JobID), tx.Commit(ctx)
}
_, err = tx.Exec(ctx, `UPDATE device_onboarding_key SET status='REVOKED' WHERE device_id=$1::uuid AND status='ACTIVE'`, id)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `UPDATE device_onboarding_key SET status='ACTIVE',activated_at=now() WHERE device_id=$1::uuid AND key_version=$2 AND status='PENDING'`, id, version)
if err != nil {
return nil, err
}
if d.Lifecycle == "FACTORY" {
if err = requireLifecycle(ctx, tx, d, deviceactivation.Claimable, requestID); err != nil {
return nil, err
}
}
_, err = tx.Exec(ctx, `UPDATE device SET onboarding_released_at=COALESCE(onboarding_released_at,now()),onboarding_revision=onboarding_revision+1,version=version+1,updated_at=now() WHERE id=$1::uuid`, id)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `UPDATE device_enrollment_job SET status='RELEASED',evidence_ref=$2,delivery_ciphertext=NULL WHERE id=$1::uuid`, in.JobID, in.Evidence)
if err != nil {
return nil, err
}
if err = s.auditEvent(ctx, tx, d, "user", a.PublicID, "device.inventory.release", requestID, map[string]any{"job_id": in.JobID, "key_version": version, "evidence_ref": in.Evidence}); err != nil {
return nil, err
}
d.Released = true
return inventoryResult(d, in.JobID), tx.Commit(ctx)
}
+142
View File
@@ -0,0 +1,142 @@
package onboarding
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"github.com/google/uuid"
"testing"
"time"
)
func TestIntegrationCredentialRotationAndReboot(t *testing.T) {
f := newFixture(t)
f.pending()
rotation := RotationInput{Reason: "test rotation", ExpectedVersion: 1}
req := uuid.NewString()
rotated, err := f.s.Rotate(f.ctx, f.p, f.tenantPublic, f.d.PublicID, req, req, requestJSON(rotation), rotation)
must(t, err)
_, err = f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 2)
if err == nil {
t.Fatal("pending key became active")
}
receiver, err := rsa.GenerateKey(rand.Reader, 2048)
must(t, err)
pub, err := x509.MarshalPKIXPublicKey(&receiver.PublicKey)
must(t, err)
job := rotated["enrollment_job_id"].(string)
input := DeliveryInput{JobID: job, ReceiverKey: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pub}))}
first, err := f.s.DeliverCredential(f.ctx, f.p, f.tenantPublic, f.d.PublicID, uuid.NewString(), input)
must(t, err)
_, err = f.s.pool.Exec(f.ctx, `UPDATE device_enrollment_job SET delivery_expires_at=now()-interval '1 second' WHERE id=$1::uuid`, job)
must(t, err)
renewed, err := f.s.DeliverCredential(f.ctx, f.p, f.tenantPublic, f.d.PublicID, uuid.NewString(), input)
must(t, err)
if first["ciphertext_b64"] == renewed["ciphertext_b64"] {
t.Fatal("expired envelope reused")
}
release := ReleaseInput{JobID: job, ExpectedVersion: 2, Evidence: "fixture readback"}
_, err = f.s.ReleaseInventory(f.ctx, f.p, f.tenantPublic, f.d.PublicID, uuid.NewString(), release)
must(t, err)
_, err = f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 1)
if err == nil {
t.Fatal("old key still active")
}
f.d, err = f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 2)
must(t, err)
boot := uuid.NewString()
challenge, err := f.s.TimeChallenge(f.ctx, TimeChallengeInput{DeviceID: f.d.FactoryID, KeyVersion: 2, BootID: boot, ClientNonce: randomToken(16)})
must(t, err)
raw, err := base64.RawURLEncoding.DecodeString(challenge["payload_b64"].(string))
must(t, err)
var packet map[string]any
must(t, json.Unmarshal(raw, &packet))
reg := RegisterInput{ChallengeID: packet["challenge_id"].(string), BootID: boot, Model: f.d.Model, HardwareRevision: f.d.HardwareRevision, FirmwareVersion: "1.2", SDKVersion: "1.0"}
_, err = f.s.Register(f.ctx, f.d, uuid.NewString(), requestJSON(reg), reg)
must(t, err)
f.d, err = f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 2)
must(t, err)
_, err = f.s.Confirm(f.ctx, f.d, uuid.NewString(), requestJSON(f.confirm), f.confirm)
if err == nil {
t.Fatal("pre-reboot confirmation accepted")
}
}
func TestIntegrationRegistrationSurvivesRebindAndRejectsOldResult(t *testing.T) {
f := newFixture(t)
f.bound()
j := f.registerSDK()
var status string
_, err := f.s.pool.Exec(f.ctx, `UPDATE device_binding SET unbound_at=now() WHERE device_id=$1::uuid AND unbound_at IS NULL`, f.d.ID)
must(t, err)
current, err := f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 1)
must(t, err)
late := ResultInput{ActivationID: j.ID, AttemptID: j.AttemptID, Stage: "SDK_REGISTER", Outcome: "SUCCESS_OBSERVED"}
_, err = f.s.ActivationResult(f.ctx, current, uuid.NewString(), requestJSON(late), late)
if err == nil {
t.Fatal("old binding result was accepted")
}
f.d, err = f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 1)
must(t, err)
setupIn := SetupInput{BootID: f.d.BootID, LocalSetupID: uuid.NewString(), Transport: "LOCAL_WEB"}
setup, err := f.s.Setup(f.ctx, f.d, uuid.NewString(), requestJSON(setupIn), setupIn)
must(t, err)
f.setup = setup["setup_id"].(string)
verifier := randomToken(32)
sum := sha256.Sum256([]byte(verifier))
handoffIn := HandoffInput{SetupID: f.setup, LocalID: setupIn.LocalSetupID, Challenge: encode64(sum[:])}
handoff, err := f.s.Handoff(f.ctx, f.d, uuid.NewString(), requestJSON(handoffIn), handoffIn)
must(t, err)
browser, err := f.s.Exchange(f.ctx, ExchangeInput{Token: handoff["handoff_token"].(string), Verifier: verifier})
must(t, err)
f.web, err = f.s.webSession(f.ctx, f.s.pool, digest([]byte(browser.Token)))
must(t, err)
browser, err = f.s.AuthLink(f.ctx, f.web, f.user)
must(t, err)
f.web, err = f.s.webSession(f.ctx, f.s.pool, digest([]byte(browser.Token)))
must(t, err)
f.bound()
must(t, f.s.ProcessActivation(f.ctx, f.event()))
if f.registrar.calls.Load() != 0 {
t.Fatal("rebind repeated supplier registration")
}
must(t, f.s.pool.QueryRow(f.ctx, `SELECT status FROM device_activation_job WHERE device_id=$1::uuid`, f.d.ID).Scan(&status))
if status != "REGISTERED_OBSERVED" {
t.Fatal("rebind did not resume activation")
}
}
func TestIntegrationCloseRequiresDurableACKAndGrace(t *testing.T) {
f := newFixture(t)
f.bound()
poll, err := f.s.Poll(f.ctx, f.d, PollInput{SetupID: f.setup, BootID: f.d.BootID})
must(t, err)
st := poll["state"].(State)
ack := StateAckInput{Revision: st.Revision, Generation: st.Generation, Hash: st.Hash, LocalGeneration: 1}
_, err = f.s.StateAck(f.ctx, f.d, uuid.NewString(), requestJSON(ack), ack)
must(t, err)
var saved time.Time
must(t, f.s.pool.QueryRow(f.ctx, `SELECT accepted_at FROM device_state_ack WHERE device_id=$1::uuid`, f.d.ID).Scan(&saved))
_, err = f.s.StateAck(f.ctx, f.d, uuid.NewString(), requestJSON(ack), ack)
must(t, err)
var again time.Time
must(t, f.s.pool.QueryRow(f.ctx, `SELECT accepted_at FROM device_state_ack WHERE device_id=$1::uuid`, f.d.ID).Scan(&again))
if !again.Equal(saved) {
t.Fatal("ACK retry extended close grace")
}
closeIn := CloseInput{SetupID: f.setup, Reason: "SUCCESS", Revision: st.Revision}
_, err = f.s.Close(f.ctx, f.d, uuid.NewString(), requestJSON(closeIn), closeIn)
if err == nil {
t.Fatal("closed before 30 second grace")
}
var binding string
must(t, f.s.pool.QueryRow(f.ctx, `SELECT public_id::text FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL`, f.d.ID).Scan(&binding))
webAck := WebAckInput{BindingID: binding, Generation: st.Generation, Revision: st.Revision}
_, err = f.s.WebAck(f.ctx, f.web, uuid.NewString(), requestJSON(webAck), webAck)
must(t, err)
f.s.now = func() time.Time { return time.Now().Add(4 * time.Second) }
_, err = f.s.Close(f.ctx, f.d, uuid.NewString(), requestJSON(closeIn), closeIn)
must(t, err)
}
+84
View File
@@ -0,0 +1,84 @@
package onboarding
import "net/http"
func (s *Service) accountSessionHTTP(r *http.Request) (any, error) {
if r.Header.Get("Sec-Fetch-Site") == "cross-site" || r.URL.RawQuery != "" {
return nil, errAccess
}
cookie, err := r.Cookie(s.cookieName(true))
if err != nil {
return nil, fail(401, "LOGIN_REQUIRED", "请先登录")
}
p, err := s.AccountPrincipal(r.Context(), cookie.Value, "", false)
if err != nil {
return nil, err
}
token := randomToken(32)
_, err = s.pool.Exec(r.Context(), `UPDATE onboarding_account_session SET csrf_hash=$2 WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at>now()`, digest([]byte(cookie.Value)), digest([]byte(token)))
if err != nil {
return nil, err
}
if c, e := r.Cookie(s.cookieName(false)); e == nil && validB64(c.Value, 32) {
_, err = s.pool.Exec(r.Context(), `UPDATE onboarding_web_session SET csrf_hash=$2 WHERE token_hash=$1 AND issuer=$3 AND subject=$4 AND revoked_at IS NULL AND expires_at>now()`, digest([]byte(c.Value)), digest([]byte(token)), p.Issuer, p.Subject)
if err != nil {
return nil, err
}
}
return map[string]any{"csrf_token": token, "authenticated": true}, nil
}
func (s *Service) logoutHTTP(w http.ResponseWriter, r *http.Request) (any, error) {
if !s.sameOrigin(r) {
return nil, errAccess
}
raw, err := readBody(r)
if err != nil {
return nil, err
}
if err = StrictJSON(raw, &struct{}{}); err != nil {
return nil, err
}
// Expired cookies can be cleared without treating them as a live login.
for _, account := range []bool{true, false} {
c, e := r.Cookie(s.cookieName(account))
if e != nil {
continue
}
table := "onboarding_web_session"
if account {
table = "onboarding_account_session"
}
var live bool
err = s.pool.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM `+table+` WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at>now())`, digest([]byte(c.Value))).Scan(&live)
if err != nil {
return nil, err
}
if live {
var valid bool
err = s.pool.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM `+table+` WHERE token_hash=$1 AND csrf_hash=$2)`, digest([]byte(c.Value)), digest([]byte(r.Header.Get("X-CSRF-Token")))).Scan(&valid)
if err != nil {
return nil, err
}
if !valid {
return nil, errAccess
}
}
}
for _, account := range []bool{true, false} {
c, e := r.Cookie(s.cookieName(account))
if e != nil {
continue
}
table := "onboarding_web_session"
if account {
table = "onboarding_account_session"
}
if _, err = s.pool.Exec(r.Context(), `UPDATE `+table+` SET revoked_at=COALESCE(revoked_at,now()) WHERE token_hash=$1`, digest([]byte(c.Value))); err != nil {
return nil, err
}
}
for _, account := range []bool{false, true} {
http.SetCookie(w, &http.Cookie{Name: s.cookieName(account), Value: "", Path: "/", HttpOnly: true, Secure: !s.options.AllowLocalHTTP, SameSite: http.SameSiteLaxMode, MaxAge: -1})
}
return map[string]any{"signed_out": true}, nil
}
+65
View File
@@ -0,0 +1,65 @@
package onboarding
import (
"context"
"data-terminal-cloud/internal/outbox"
"encoding/json"
"github.com/google/uuid"
"time"
)
// Recover durable jobs after an outbox retry budget was exhausted during a
// pause. ProcessActivation owns the device lock and the no-repeat fence.
func (s *Service) RunMaintenance(ctx context.Context) {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
if err := s.MaintainOnce(ctx); err != nil && ctx.Err() == nil {
s.options.Logger.Error("onboarding recovery scan failed")
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (s *Service) MaintainOnce(ctx context.Context) error {
rows, err := s.pool.Query(ctx, `SELECT j.device_id::text,j.id::text,j.binding_generation FROM device_activation_job j JOIN device d ON d.id=j.device_id JOIN device_binding b ON b.device_id=d.id AND b.unbound_at IS NULL AND b.generation=j.binding_generation WHERE d.status='registered' AND d.activation_state NOT IN ('LOST','REVOKED') AND j.binding_generation=d.binding_generation AND j.updated_at<now()-interval '3 minutes' AND (j.status IN ('NOT_STARTED','RESERVED','CONTACT_POSSIBLE') OR (j.status='REGISTERED_OBSERVED' AND j.config_ciphertext IS NULL)) ORDER BY j.updated_at LIMIT 10`)
if err != nil {
return err
}
type item struct {
DeviceID string `json:"device_id"`
ActivationID string `json:"activation_id"`
Generation int64 `json:"binding_generation"`
}
var items []item
for rows.Next() {
var v item
if err = rows.Scan(&v.DeviceID, &v.ActivationID, &v.Generation); err != nil {
rows.Close()
return err
}
items = append(items, v)
}
rows.Close()
if err = rows.Err(); err != nil {
return err
}
if s.options.ActivationEnabled {
for _, v := range items {
raw, _ := json.Marshal(v)
if err = s.ProcessActivation(ctx, outbox.Event{ID: uuid.NewString(), Payload: raw}); err != nil {
return err
}
}
}
// Retain business attempts and idempotency tombstones for replay protection.
_, err = s.pool.Exec(ctx, `DELETE FROM device_handoff WHERE expires_at<now()-interval '1 day'`)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `DELETE FROM device_time_challenge WHERE expires_at<now()-interval '1 day'`)
return err
}
+271
View File
@@ -0,0 +1,271 @@
package onboarding
import (
"context"
"data-terminal-cloud/internal/deviceactivation"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/outbox"
"encoding/json"
"errors"
"github.com/jackc/pgx/v5"
)
type RotationInput struct {
Reason string `json:"reason"`
ExpectedVersion int64 `json:"expected_key_version"`
}
func (s *Service) Rotate(ctx context.Context, p identity.Principal, tenant, device, idempotencyKey, requestID string, raw []byte, in RotationInput) (map[string]any, error) {
if !uuidValid(device) || !nonempty(in.Reason, 200) || in.ExpectedVersion < 1 || len(idempotencyKey) < 16 || len(idempotencyKey) > 160 {
return nil, fail(400, "VALIDATION_FAILED", "密钥轮换参数不正确")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
a, err := operatorAccess(ctx, tx, p, tenant, true)
if err != nil {
return nil, err
}
var internal string
err = tx.QueryRow(ctx, `SELECT id::text FROM device WHERE public_id=$1::uuid AND tenant_id=$2::uuid`, device, a.TenantID).Scan(&internal)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errAccess
}
if err != nil {
return nil, err
}
d, err := readDevice(ctx, tx, internal, true)
if err != nil {
return nil, err
}
if !deviceAllowed(d) {
return nil, errAccess
}
var job, stored string
err = tx.QueryRow(ctx, `SELECT id::text,request_digest FROM device_enrollment_job WHERE device_id=$1::uuid AND actor_id=$2::uuid AND request_key=$3`, d.ID, a.ID, idempotencyKey).Scan(&job, &stored)
if err == nil {
if stored != digest(raw) {
return nil, fail(409, "REQUEST_BODY_CONFLICT", "轮换请求号已用于其他内容")
}
return inventoryResult(d, job), tx.Commit(ctx)
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
var active, latest int64
var pending bool
err = tx.QueryRow(ctx, `SELECT COALESCE(max(key_version) FILTER(WHERE status='ACTIVE'),0),max(key_version),bool_or(status='PENDING') FROM device_onboarding_key WHERE device_id=$1::uuid`, d.ID).Scan(&active, &latest, &pending)
if err != nil {
return nil, err
}
if active != in.ExpectedVersion {
return nil, fail(409, "KEY_VERSION_CONFLICT", "当前凭据版本已变化")
}
if pending {
return nil, fail(409, "DELIVERY_PENDING", "已有凭据交付任务待完成")
}
_, err = tx.Exec(ctx, `INSERT INTO device_onboarding_key(device_id,tenant_id,key_version,ciphertext,status) VALUES($1::uuid,$2::uuid,$3,$4,'PENDING')`, d.ID, d.TenantID, latest+1, s.crypt.seal(randomBytes(32), keyAAD(d.ID, latest+1)))
if err != nil {
return nil, err
}
err = tx.QueryRow(ctx, `INSERT INTO device_enrollment_job(tenant_id,device_id,actor_id,request_key,request_digest,batch_no,key_version) VALUES($1::uuid,$2::uuid,$3::uuid,$4,$5,'credential-rotation',$6) RETURNING id::text`, d.TenantID, d.ID, a.ID, idempotencyKey, digest(raw), latest+1).Scan(&job)
if err != nil {
return nil, err
}
if err = s.auditEvent(ctx, tx, d, "user", a.PublicID, "onboarding.key.rotation.prepare", requestID, map[string]any{"job_id": job, "key_version": latest + 1, "reason": in.Reason}); err != nil {
return nil, err
}
result := inventoryResult(d, job)
result["key_version"] = latest + 1
return result, tx.Commit(ctx)
}
func (s *Service) OpsList(ctx context.Context, p identity.Principal, tenant string) (map[string]any, error) {
a, err := operatorAccess(ctx, s.pool, p, tenant, false)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `SELECT d.public_id::text,d.factory_device_id,d.model,d.binding_generation,d.onboarding_revision,d.onboarding_released_at IS NOT NULL,COALESCE(j.id::text,''),COALESCE(j.status,'NOT_STARTED'),COALESCE(j.provider_device_name,''),COALESCE(j.last_error_code,''),COALESCE(j.version,0),COALESCE((SELECT a.id::text FROM device_activation_attempt a WHERE a.activation_id=j.id AND a.executor='DEVICE' AND a.status IN ('CONTACT_POSSIBLE','UNKNOWN') ORDER BY a.created_at DESC LIMIT 1),j.current_attempt_id::text,''),COALESCE((SELECT max(key_version) FROM device_onboarding_key k WHERE k.device_id=d.id AND k.status='PENDING'),0),COALESCE((SELECT max(key_version) FROM device_onboarding_key k WHERE k.device_id=d.id AND k.status='ACTIVE'),0),COALESCE((SELECT ej.id::text FROM device_enrollment_job ej WHERE ej.device_id=d.id ORDER BY created_at DESC LIMIT 1),'') FROM device d LEFT JOIN device_activation_job j ON j.device_id=d.id WHERE d.tenant_id=$1::uuid AND d.enrollment_mode='physical_confirm' ORDER BY d.created_at DESC LIMIT 200`, a.TenantID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id, factory, model, job, status, name, code, enrollment, attempt string
var gen, rev, ver, key, pendingKey int64
var released bool
if err = rows.Scan(&id, &factory, &model, &gen, &rev, &released, &job, &status, &name, &code, &ver, &attempt, &pendingKey, &key, &enrollment); err != nil {
return nil, err
}
items = append(items, map[string]any{"platform_device_id": id, "device_id": factory, "model": model, "binding_generation": gen, "device_revision": rev, "released": released, "activation_id": job, "activation_state": status, "provider_device_name": name, "last_error_code": code, "activation_version": ver, "key_version": key, "pending_key_version": pendingKey, "current_attempt_id": attempt, "enrollment_job_id": enrollment})
}
return map[string]any{"items": items, "limit": 200}, rows.Err()
}
type ReconcileInput struct {
ExpectedVersion int64 `json:"expected_version"`
Conclusion string `json:"conclusion"`
EvidenceRef string `json:"evidence_ref"`
Reason string `json:"reason"`
AttemptID string `json:"attempt_id,omitempty"`
DeviceSecret string `json:"device_secret,omitempty"`
RTCAppID string `json:"rtc_app_id,omitempty"`
}
func (s *Service) Reconcile(ctx context.Context, p identity.Principal, tenant, activationID, requestID string, in ReconcileInput) (map[string]any, error) {
if !uuidV4(activationID) || in.ExpectedVersion < 1 || !nonempty(in.EvidenceRef, 500) || !nonempty(in.Reason, 200) || !oneOf(in.Conclusion, "OBSERVED_REGISTERED", "CONFIRMED_NOT_REGISTERED", "DEVICE_ATTEMPT_STOPPED") {
return nil, fail(400, "VALIDATION_FAILED", "对账结论、版本和证据必填")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
a, err := operatorAccess(ctx, tx, p, tenant, true)
if err != nil {
return nil, err
}
if a.Role != "ops_admin" {
return nil, errAccess
}
var device string
err = tx.QueryRow(ctx, `SELECT device_id::text FROM device_activation_job WHERE id=$1::uuid AND tenant_id=$2::uuid`, activationID, a.TenantID).Scan(&device)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errAccess
}
if err != nil {
return nil, err
}
d, err := readDevice(ctx, tx, device, true)
if err != nil {
return nil, err
}
j, err := readJob(ctx, tx, d.ID)
if err != nil {
return nil, err
}
if j.Version != in.ExpectedVersion {
return nil, fail(409, "ACTIVATION_VERSION_CONFLICT", "任务已变化,请刷新后核对")
}
if in.Conclusion == "DEVICE_ATTEMPT_STOPPED" {
if !uuidV4(in.AttemptID) {
return nil, fail(400, "VALIDATION_FAILED", "请指定设备尝试")
}
tag, err := tx.Exec(ctx, `UPDATE device_activation_attempt SET status='DEFINITE_FAILURE',completed_at=now(),evidence=jsonb_build_object('operator_evidence',$3::text) WHERE id=$1::uuid AND activation_id=$2::uuid AND executor='DEVICE' AND stage='CONVERSATION_TEST' AND status IN ('CONTACT_POSSIBLE','UNKNOWN') AND permit_expires_at<now()-interval '3 minutes'`, in.AttemptID, j.ID, in.EvidenceRef)
if err != nil {
return nil, err
}
if tag.RowsAffected() != 1 {
return nil, fail(409, "ATTEMPT_STILL_POSSIBLE", "许可尚未超过安全等待期,或已确认结果")
}
} else {
if !oneOf(j.Status, "CONTACT_POSSIBLE", "RECONCILE_REQUIRED", "BLOCKED") || len(j.Secret) > 0 {
return nil, errState
}
var attempt string
var ended bool
err = tx.QueryRow(ctx, `SELECT id::text,permit_expires_at<now()-interval '3 minutes' FROM device_activation_attempt WHERE activation_id=$1::uuid AND (executor='SERVER' OR stage='SDK_REGISTER') ORDER BY created_at DESC LIMIT 1 FOR UPDATE`, j.ID).Scan(&attempt, &ended)
if err != nil {
return nil, err
}
if !ended {
return nil, fail(409, "ATTEMPT_STILL_POSSIBLE", "供应商调用尚未超过安全等待期")
}
if in.Conclusion == "OBSERVED_REGISTERED" {
if (in.DeviceSecret != "" && len(in.DeviceSecret) < 16) || len(in.DeviceSecret) > 4096 || len(in.RTCAppID) > 200 {
return nil, fail(400, "VALIDATION_FAILED", "请提供核验后的设备凭据")
}
if in.DeviceSecret != "" {
j.Secret = s.crypt.seal([]byte(in.DeviceSecret), "provider:"+j.ID)
}
observed := s.now()
j.Registered = &observed
j.RTCAppID = in.RTCAppID
status := "BLOCKED"
var bound bool
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL AND generation=$2)`, d.ID, d.Generation).Scan(&bound); err != nil {
return nil, err
}
if bound && deviceAllowed(d) && j.Generation == d.Generation {
status = "REGISTERED_OBSERVED"
}
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET provider_secret_ciphertext=$2,provider_rtc_app_id=$3,status=$4,sdk_registered_at=COALESCE(sdk_registered_at,now()),engine_ready_at=now() WHERE id=$1::uuid`, j.ID, j.Secret, j.RTCAppID, status)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `UPDATE device_activation_attempt SET status='SUCCESS_OBSERVED',completed_at=now(),evidence=jsonb_build_object('operator_evidence',$2::text) WHERE id=$1::uuid`, attempt, in.EvidenceRef)
if err != nil {
return nil, err
}
j.Status = status
if status == "REGISTERED_OBSERVED" {
if d.Lifecycle == "BOUND" {
if err = requireLifecycle(ctx, tx, d, deviceactivation.Provisioning, requestID); err != nil {
return nil, err
}
}
if err = s.ensureConfig(ctx, tx, d, &j); err != nil {
return nil, err
}
}
} else {
_, err = tx.Exec(ctx, `UPDATE device_activation_attempt SET status='DEFINITE_FAILURE',completed_at=now(),evidence=jsonb_build_object('operator_evidence',$2::text) WHERE id=$1::uuid`, attempt, in.EvidenceRef)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET status='NOT_STARTED',current_attempt_id=NULL WHERE id=$1::uuid`, j.ID)
if err != nil {
return nil, err
}
dedup := "reconcile:" + requestID
payload, _ := json.Marshal(map[string]any{"device_id": d.ID, "activation_id": j.ID, "binding_generation": d.Generation})
_, err = s.outbox.Enqueue(ctx, tx, outbox.NewEvent{TenantID: &d.TenantID, AggregateType: "device", AggregateID: d.ID, EventType: ActivationEvent, DeduplicationKey: &dedup, Payload: payload})
if err != nil {
return nil, err
}
}
}
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET version=version+1,reconciliation_evidence=$2,last_error_code=NULL,status=CASE WHEN $3='DEVICE_ATTEMPT_STOPPED' THEN 'REGISTERED_OBSERVED' ELSE status END,updated_at=now() WHERE id=$1::uuid`, j.ID, in.EvidenceRef, in.Conclusion)
if err != nil {
return nil, err
}
if err = bump(ctx, tx, d.ID); err != nil {
return nil, err
}
if err = s.auditEvent(ctx, tx, d, "user", a.PublicID, "onboarding.activation.reconcile", requestID, map[string]any{"activation_id": j.ID, "conclusion": in.Conclusion, "evidence_ref": in.EvidenceRef, "reason": in.Reason}); err != nil {
return nil, err
}
st, err := s.state(ctx, tx, d)
if err != nil {
return nil, err
}
return map[string]any{"state": st}, tx.Commit(ctx)
}
// This status route remains available after the temporary setup closes.
func (s *Service) AccountDevices(ctx context.Context, p identity.Principal) (map[string]any, error) {
rows, err := s.pool.Query(ctx, `SELECT d.public_id::text,d.factory_device_id,d.binding_generation,d.onboarding_revision,COALESCE(j.status,'NOT_STARTED'),h.name,sp.preferred_name FROM device d JOIN device_binding b ON b.device_id=d.id AND b.unbound_at IS NULL JOIN household h ON h.id=b.household_id AND h.status='active' JOIN subject_profile sp ON sp.id=b.subject_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.status='active' JOIN tenant t ON t.id=d.tenant_id AND t.status='active' LEFT JOIN device_activation_job j ON j.device_id=d.id WHERE ua.oidc_issuer=$1 AND ua.oidc_subject=$2 AND d.enrollment_mode='physical_confirm' ORDER BY d.registered_at DESC LIMIT 200`, p.Issuer, p.Subject)
if err != nil {
return nil, err
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id, factory, status, household, name string
var gen, rev int64
if err = rows.Scan(&id, &factory, &gen, &rev, &status, &household, &name); err != nil {
return nil, err
}
items = append(items, map[string]any{"platform_device_id": id, "device_id": factory, "binding_generation": gen, "device_revision": rev, "activation_state": status, "household_name": household, "preferred_name": name})
}
return map[string]any{"items": items}, rows.Err()
}
// Recovered settings never reset ownership; the regular authorized unbind
// route must first close the binding, then firmware may clear local user data.
func requireLifecycle(ctx context.Context, tx pgx.Tx, d Device, target deviceactivation.State, requestID string) error {
_, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: d.ID, TenantID: d.TenantID, Target: target, Reason: "首次接入状态同步", Source: "onboarding", ActorType: "system", RequestID: requestID})
return err
}
+137
View File
@@ -0,0 +1,137 @@
package onboarding
import (
"context"
"crypto/sha256"
"encoding/json"
"github.com/google/uuid"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestIntegrationClaimReplayCannotReturnReplacement(t *testing.T) {
f := newFixture(t)
in := ClaimInput{ElderID: f.elder, Generation: f.d.Generation}
id := uuid.NewString()
first, err := f.s.Claim(f.ctx, f.web, id, requestJSON(in), in)
must(t, err)
cancel := CancelInput{ClaimID: first["claim_id"].(string)}
_, err = f.s.Cancel(f.ctx, f.web, uuid.NewString(), requestJSON(cancel), cancel)
must(t, err)
_, err = f.s.Claim(f.ctx, f.web, uuid.NewString(), requestJSON(in), in)
must(t, err)
_, err = f.s.Claim(f.ctx, f.web, id, requestJSON(in), in)
if err == nil {
t.Fatal("returned cancelled claim from cache")
}
}
func TestIntegrationConsumedHandoffCannotReplayReplacement(t *testing.T) {
f := newFixture(t)
var local string
must(t, f.s.pool.QueryRow(f.ctx, `SELECT local_setup_id::text FROM device_setup_session WHERE id=$1::uuid`, f.setup).Scan(&local))
verifier := randomToken(32)
sum := sha256.Sum256([]byte(verifier))
in := HandoffInput{SetupID: f.setup, LocalID: local, Challenge: encode64(sum[:])}
id := uuid.NewString()
first, err := f.s.Handoff(f.ctx, f.d, id, requestJSON(in), in)
must(t, err)
_, err = f.s.Exchange(f.ctx, ExchangeInput{Token: first["handoff_token"].(string), Verifier: verifier})
must(t, err)
_, err = f.s.Handoff(f.ctx, f.d, uuid.NewString(), requestJSON(in), in)
must(t, err)
_, err = f.s.Handoff(f.ctx, f.d, id, requestJSON(in), in)
if err == nil {
t.Fatal("returned consumed handoff")
}
}
type captureSMS struct {
code string
calls int
}
func (p *captureSMS) Send(_ context.Context, _ string, code string, _ string) error {
p.code = code
p.calls++
return nil
}
func TestIntegrationSMSLoginRateAndOneTimeCode(t *testing.T) {
f := newFixture(t)
sender := &captureSMS{}
f.s.options.SMS = sender
f.s.options.SMSLoginEnabled = true
// Start a fresh anonymous browser session, as a phone opened from the device.
_, err := f.s.pool.Exec(f.ctx, `UPDATE onboarding_web_session SET user_id=NULL,issuer=NULL,subject=NULL WHERE token_hash=$1`, f.web.Hash)
must(t, err)
f.web.UserID = ""
f.web.Issuer = ""
f.web.Subject = ""
phone := "+8613" + strings.ReplaceAll(uuid.NewString(), "-", "")[:9]
// Use decimal digits while keeping independent fixtures unique.
for i, c := range phone {
if c >= 'a' && c <= 'f' {
phone = phone[:i] + string('0'+(c-'a')) + phone[i+1:]
}
}
in := SMSSendInput{Phone: phone, Purpose: "LOGIN"}
id := uuid.NewString()
sent, err := f.s.SendSMS(f.ctx, f.web, id, in)
must(t, err)
_, err = f.s.SendSMS(f.ctx, f.web, id, in)
must(t, err)
if sender.calls != 1 {
t.Fatal("SMS retry sent twice")
}
_, err = f.s.SendSMS(f.ctx, f.web, uuid.NewString(), in)
if err == nil {
t.Fatal("resend cooldown ignored")
}
verify := SMSVerifyInput{ChallengeID: sent["sms_challenge_id"].(string), Code: sender.code}
bad := verify
bad.Code = "000000"
if bad.Code == sender.code {
bad.Code = "999999"
}
_, err = f.s.VerifySMS(f.ctx, f.web, bad)
if err == nil {
t.Fatal("incorrect SMS accepted")
}
credentials, err := f.s.VerifySMS(f.ctx, f.web, verify)
must(t, err)
principal, err := f.s.AccountPrincipal(f.ctx, credentials.AccountToken, credentials.CSRF, true)
must(t, err)
if principal.Issuer != "urn:platform:onboarding:sms" {
t.Fatal("incorrect SMS identity namespace")
}
_, err = f.s.VerifySMS(f.ctx, f.web, verify)
if err == nil {
t.Fatal("SMS consumed twice")
}
}
func TestHTTPSMSGatewayContract(t *testing.T) {
calls := 0
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.Header.Get("Authorization") != "Bearer test-token" || r.Header.Get("Idempotency-Key") != "test-request" {
t.Error("gateway authentication missing")
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Error(err)
}
if body["code"] != "123456" || body["phone_e164"] != "+8613800000000" {
t.Error("gateway payload incorrect")
}
w.WriteHeader(204)
}))
defer server.Close()
provider, err := NewHTTPSMSProvider(server.URL, "test-token")
must(t, err)
provider.(*HTTPSMSProvider).client = server.Client()
must(t, provider.Send(context.Background(), "+8613800000000", "123456", "test-request"))
if calls != 1 {
t.Fatal("unexpected request count")
}
}
+158
View File
@@ -0,0 +1,158 @@
package onboarding
import (
"context"
"encoding/json"
"github.com/jackc/pgx/v5"
"strings"
"time"
)
func (s *Service) replayGuard(ctx context.Context, tx pgx.Tx, d Device, actor, scope string, raw []byte) error {
if strings.HasPrefix(actor, "web:") {
if err := s.checkWebActor(ctx, tx, strings.TrimPrefix(actor, "web:"), d); err != nil {
return err
}
}
switch scope {
case "register":
var in RegisterInput
_ = json.Unmarshal(raw, &in)
if d.BootID != in.BootID {
return errState
}
case "setup":
var in SetupInput
_ = json.Unmarshal(raw, &in)
var valid bool
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_setup_session WHERE device_id=$1::uuid AND boot_id=$2::uuid AND local_setup_id=$3::uuid AND binding_generation=$4 AND closed_at IS NULL AND expires_at>now())`, d.ID, in.BootID, in.LocalSetupID, d.Generation).Scan(&valid)
if err != nil {
return err
}
if !valid {
return fail(410, "SETUP_EXPIRED", "请创建新的恢复会话")
}
case "handoff":
var in HandoffInput
_ = json.Unmarshal(raw, &in)
if _, err := readSetup(ctx, tx, in.SetupID, d, true); err != nil {
return err
}
var valid bool
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_handoff WHERE setup_id=$1::uuid AND code_challenge=$2 AND consumed_at IS NULL AND expires_at>now())`, in.SetupID, in.Challenge).Scan(&valid)
if err != nil {
return err
}
if !valid {
return fail(410, "HANDOFF_INVALID", "入口已消费或失效,请重新获取")
}
case "confirm":
var in ConfirmationInput
_ = json.Unmarshal(raw, &in)
c, err := readClaim(ctx, tx, in.ClaimID, d.ID)
if err != nil {
return err
}
if err = claimPermission(ctx, tx, c); err != nil {
return err
}
if c.Status != "CONFIRMED" || c.PressID != in.PressEventID || d.Generation != c.Generation+1 {
return errState
}
var valid bool
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE id=$1::uuid AND generation=$2 AND unbound_at IS NULL)`, c.BindingID, d.Generation).Scan(&valid)
if err != nil {
return err
}
if !valid {
return errState
}
case "claim":
var in ClaimInput
_ = json.Unmarshal(raw, &in)
w, err := s.webSession(ctx, tx, strings.TrimPrefix(actor, "web:"))
if err != nil {
return err
}
var c claimRecord
c.UserID = w.UserID
c.TenantID = d.TenantID
err = tx.QueryRow(ctx, `SELECT id::text,household_id::text FROM subject_profile WHERE public_id=$1::uuid AND tenant_id=$2::uuid`, in.ElderID, d.TenantID).Scan(&c.SubjectID, &c.HouseholdID)
if err != nil {
return errAccess
}
if err = claimPermission(ctx, tx, c); err != nil {
return err
}
var valid bool
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding_claim WHERE setup_id=$1::uuid AND device_id=$2::uuid AND user_id=$3::uuid AND subject_id=$4::uuid AND status='PENDING' AND expires_at>now())`, w.SetupID, d.ID, w.UserID, c.SubjectID).Scan(&valid)
if err != nil {
return err
}
if !valid {
return fail(410, "CLAIM_EXPIRED", "请查询当前绑定进度")
}
case "attempt":
var in AttemptInput
_ = json.Unmarshal(raw, &in)
j, err := readJob(ctx, tx, d.ID)
if err != nil {
return err
}
if err = validConfig(s, d, j, in.ActivationID, in.ConfigVersion); err != nil {
return err
}
var status string
var expiry time.Time
var conversation *string
err = tx.QueryRow(ctx, `SELECT status,permit_expires_at,conversation_id::text FROM device_activation_attempt WHERE id=$1::uuid AND activation_id=$2::uuid`, in.AttemptID, j.ID).Scan(&status, &expiry, &conversation)
if err != nil {
return err
}
if status != "CONTACT_POSSIBLE" || conversation != nil || !expiry.After(s.now()) {
return fail(409, "ATTEMPT_ALREADY_ISSUED", "请恢复原尝试的结果")
}
case "activation-ack":
var in ActivationAckInput
_ = json.Unmarshal(raw, &in)
j, err := readJob(ctx, tx, d.ID)
if err != nil {
return err
}
return validConfig(s, d, j, in.ActivationID, in.ConfigVersion)
case "activation-result":
var in ResultInput
_ = json.Unmarshal(raw, &in)
j, err := readJob(ctx, tx, d.ID)
if err != nil {
return err
}
var version int64
err = tx.QueryRow(ctx, `SELECT config_version FROM device_activation_attempt WHERE id=$1::uuid AND activation_id=$2::uuid`, in.AttemptID, j.ID).Scan(&version)
if err != nil {
return err
}
if j.ID != in.ActivationID || j.Generation != d.Generation || j.ConfigVersion != version || j.Status == "BLOCKED" {
return errState
}
return nil
case "profile":
var in ProfileInput
_ = json.Unmarshal(raw, &in)
if in.HouseholdID != "" {
w, err := s.webSession(ctx, tx, strings.TrimPrefix(actor, "web:"))
if err != nil {
return err
}
var allowed bool
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM household h JOIN household_member hm ON hm.household_id=h.id WHERE h.public_id=$1::uuid AND h.status='active' AND hm.user_id=$2::uuid AND hm.status='active' AND hm.role IN ('owner','admin','editor'))`, in.HouseholdID, w.UserID).Scan(&allowed)
if err != nil {
return err
}
if !allowed {
return errAccess
}
}
}
return nil
}
+37
View File
@@ -0,0 +1,37 @@
package onboarding
import (
"context"
"github.com/jackc/pgx/v5"
"strings"
)
// Validate the exact cached resource, never a replacement with similar inputs.
func (s *Service) replayResultGuard(ctx context.Context, tx pgx.Tx, d Device, actor, scope string, result map[string]any) error {
var valid bool
var err error
switch scope {
case "handoff":
token, _ := result["handoff_token"].(string)
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_handoff WHERE token_hash=$1 AND consumed_at IS NULL AND expires_at>now())`, digest([]byte(token))).Scan(&valid)
case "claim":
id, _ := result["claim_id"].(string)
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding_claim WHERE id=$1::uuid AND device_id=$2::uuid AND status='PENDING' AND expires_at>now())`, id, d.ID).Scan(&valid)
case "profile":
id, _ := result["elder_id"].(string)
w, e := s.webSession(ctx, tx, strings.TrimPrefix(actor, "web:"))
if e != nil {
return e
}
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM subject_profile s JOIN household h ON h.id=s.household_id JOIN household_member m ON m.household_id=h.id WHERE s.public_id=$1::uuid AND s.tenant_id=$2::uuid AND h.status='active' AND m.user_id=$3::uuid AND m.status='active' AND m.role IN ('owner','admin','editor'))`, id, d.TenantID, w.UserID).Scan(&valid)
default:
return nil
}
if err != nil {
return err
}
if !valid {
return fail(410, "REQUEST_RESOURCE_EXPIRED", "原请求对应资源已失效,请恢复当前流程")
}
return nil
}
+93
View File
@@ -0,0 +1,93 @@
package onboarding
import (
"data-terminal-cloud/internal/deviceauth"
"encoding/json"
"net/http"
"regexp"
"strings"
)
var runtimePath = regexp.MustCompile(`^/device/v(?:2/conversations|3/agent-sessions)/([0-9a-f-]{36})(?:/(events|heartbeat|stop|activate|turns:batch|upload-batches))?$`)
func (s *Service) runtimeAdmission(r *http.Request, d Device, raw []byte) (bool, error) {
create := r.URL.Path == "/device/v3/agent-sessions"
match := runtimePath.FindStringSubmatch(r.URL.Path)
if r.URL.Path == "/device/v2/conversations" {
return false, deviceauth.ErrDeviceInactive
}
if len(match) > 0 {
var current bool
err := s.pool.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM conversation WHERE public_id=$1::uuid AND device_id=$2::uuid AND onboarding_generation=$3)`, match[1], d.ID, d.Generation).Scan(&current)
if err != nil {
return false, err
}
if !current {
return false, deviceauth.ErrDeviceInactive
}
}
if !create && len(match) == 0 {
parts := strings.Split(r.URL.Path, "/")
if len(parts) >= 5 && (parts[3] == "upload-batches" || parts[3] == "media-segments") {
if !uuidValid(parts[4]) {
return false, deviceauth.ErrCredentialInvalid
}
table := "upload_batch"
if parts[3] == "media-segments" {
table = "media_segment"
}
var current bool
err := s.pool.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM `+table+` m JOIN conversation c ON c.id=m.conversation_id WHERE m.public_id=$1::uuid AND c.device_id=$2::uuid AND c.onboarding_generation=$3)`, parts[4], d.ID, d.Generation).Scan(&current)
if err != nil {
return false, err
}
if !current {
return false, deviceauth.ErrDeviceInactive
}
}
return false, nil
}
var intent, activation, attempt, token string
if create {
var input struct {
Intent string `json:"intent"`
Activation string `json:"activation_id"`
Attempt string `json:"attempt_id"`
Token string `json:"permit_token"`
}
if err := json.Unmarshal(raw, &input); err != nil {
return false, deviceauth.ErrCredentialInvalid
}
intent = input.Intent
activation = input.Activation
attempt = input.Attempt
token = input.Token
}
var ready bool
err := s.pool.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM device_activation_job j JOIN device_binding b ON b.device_id=j.device_id AND b.generation=j.binding_generation AND b.unbound_at IS NULL WHERE j.device_id=$1::uuid AND j.binding_generation=$2 AND j.status='READY' AND j.config_ack_at IS NOT NULL AND j.config_expires_at>now())`, d.ID, d.Generation).Scan(&ready)
if err != nil {
return false, err
}
if ready && intent != "activation_test" {
return false, nil
}
var valid bool
if create {
if intent != "activation_test" || !uuidV4(activation) || !uuidV4(attempt) || (token != "" && !validB64(token, 32)) {
return false, deviceauth.ErrDeviceInactive
}
err = s.pool.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM device_activation_job j JOIN device_activation_attempt a ON a.activation_id=j.id JOIN device_binding b ON b.device_id=j.device_id AND b.generation=j.binding_generation AND b.unbound_at IS NULL WHERE j.device_id=$1::uuid AND j.id=$2::uuid AND a.id=$3::uuid AND j.binding_generation=$4 AND a.binding_generation=$4 AND a.config_version=j.config_version AND j.status IN ('REGISTERED_OBSERVED','CONTACT_POSSIBLE','READY') AND j.config_ack_at IS NOT NULL AND j.engine_ready_at IS NOT NULL AND j.config_expires_at>now() AND a.stage='CONVERSATION_TEST' AND a.executor='DEVICE' AND a.status='CONTACT_POSSIBLE' AND (a.permit_hash IS NULL OR a.permit_hash=$5) AND (a.permit_expires_at>now() OR a.conversation_id IS NOT NULL))`, d.ID, activation, attempt, d.Generation, digest([]byte(token))).Scan(&valid)
} else {
if len(match) > 2 && !oneOf(match[2], "", "events", "heartbeat", "stop") {
return false, deviceauth.ErrDeviceInactive
}
err = s.pool.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM conversation c JOIN device_activation_attempt a ON a.conversation_id=c.id JOIN device_activation_job j ON j.id=a.activation_id WHERE c.public_id=$1::uuid AND c.device_id=$2::uuid AND j.binding_generation=$3 AND a.binding_generation=$3 AND a.config_version=j.config_version AND a.stage='CONVERSATION_TEST' AND a.status IN ('CONTACT_POSSIBLE','SUCCESS_OBSERVED','UNKNOWN'))`, match[1], d.ID, d.Generation).Scan(&valid)
}
if err != nil {
return false, err
}
if !valid {
return false, deviceauth.ErrDeviceInactive
}
return true, nil
}
+307
View File
@@ -0,0 +1,307 @@
package onboarding
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/hardwareprovision"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/outbox"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
)
type Error struct {
Status int
Code, Message, Recovery string
}
func (e *Error) Error() string { return e.Code }
func fail(status int, code, message string) *Error {
return &Error{Status: status, Code: code, Message: message, Recovery: "NONE"}
}
var errAuth = fail(401, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
var errAccess = fail(403, "RESOURCE_ACCESS_DENIED", "当前账号或设备无权执行此操作")
var errState = fail(409, "BINDING_GENERATION_CONFLICT", "设备状态已变化,请重新同步")
type LegacyVerifier interface {
Verify(*http.Request) (deviceauth.Identity, error)
}
type Options struct {
PauseClaims bool
TrustedProxyCIDRs []string
Enabled bool
MasterKey, EntryURL, ProviderMasterKey string
AllowLocalHTTP bool
TokenVerifier identity.TokenVerifier
LegacyVerifier LegacyVerifier
Registrar hardwareprovision.Registrar
ActivationEnabled bool
InstanceID, ProductKey, ProductSecret, BotID, Transport, MinimumSDKVersion string
SMS SMSProvider
SMSLoginEnabled bool
SelfSignupTenantIDs []string
Logger *slog.Logger
}
type Service struct {
pool *pgxpool.Pool
redis *redis.Client
crypt cryptor
providerCrypt cryptor
options Options
now func() time.Time
audit *audit.Repository
outbox *outbox.Store
}
func NewService(pool *pgxpool.Pool, redisClient *redis.Client, options Options) (*Service, error) {
if pool == nil || redisClient == nil {
return nil, errors.New("onboarding requires PostgreSQL and Redis")
}
crypt, err := newCryptor(options.MasterKey)
if err != nil {
return nil, err
}
entry, err := url.Parse(options.EntryURL)
if err != nil || entry.Host == "" || entry.User != nil || entry.RawQuery != "" || entry.Fragment != "" || (entry.Scheme != "https" && !(options.AllowLocalHTTP && entry.Scheme == "http" && (entry.Hostname() == "localhost" || entry.Hostname() == "127.0.0.1"))) {
return nil, errors.New("onboarding entry must be a fixed HTTPS URL")
}
if options.Logger == nil {
options.Logger = slog.Default()
}
if options.ActivationEnabled && (len(options.ProductSecret) < 16 || options.InstanceID == "" || options.ProductKey == "" || options.BotID == "") {
return nil, errors.New("onboarding activation requires configured provider")
}
if options.SMSLoginEnabled && options.SMS == nil {
return nil, errors.New("SMS login requires a configured sender")
}
var providerCrypt cryptor
if options.ProviderMasterKey != "" {
providerCrypt, err = newCryptor(options.ProviderMasterKey)
if err != nil {
return nil, err
}
}
if options.ActivationEnabled && providerCrypt.aead == nil {
return nil, errors.New("onboarding activation requires provider credential master key")
}
return &Service{pool: pool, redis: redisClient, crypt: crypt, providerCrypt: providerCrypt, options: options, now: time.Now, audit: audit.NewRepository(), outbox: outbox.NewStore(pool)}, nil
}
func (s *Service) Enabled() bool { return s != nil && s.options.Enabled }
type Device struct {
ID, PublicID, TenantID, FactoryID, Model, HardwareRevision, Status, Lifecycle, Mode string
Generation, Revision int64
BootID string
Released bool
KeyVersion int64
Key []byte
}
type queryer interface {
QueryRow(context.Context, string, ...any) pgx.Row
}
func readDevice(ctx context.Context, q queryer, id string, lock bool) (Device, error) {
var d Device
suffix := ""
if lock {
suffix = " FOR UPDATE OF d"
}
err := q.QueryRow(ctx, `SELECT d.id::text,d.public_id::text,d.tenant_id::text,COALESCE(d.factory_device_id,''),d.model,COALESCE(d.hardware_revision,''),d.status,d.activation_state,d.enrollment_mode,d.binding_generation,d.onboarding_revision,COALESCE(d.onboarding_boot_id::text,''),d.onboarding_released_at IS NOT NULL FROM device d JOIN tenant t ON t.id=d.tenant_id AND t.status='active' WHERE d.id=$1::uuid`+suffix, id).Scan(&d.ID, &d.PublicID, &d.TenantID, &d.FactoryID, &d.Model, &d.HardwareRevision, &d.Status, &d.Lifecycle, &d.Mode, &d.Generation, &d.Revision, &d.BootID, &d.Released)
if errors.Is(err, pgx.ErrNoRows) {
return d, errAccess
}
return d, err
}
func deviceAllowed(d Device) bool {
return d.Mode == "physical_confirm" && d.Status == "registered" && d.Released && d.Lifecycle != "REVOKED" && d.Lifecycle != "LOST"
}
type State struct {
DeviceID string `json:"device_id"`
PlatformDeviceID string `json:"-"`
Revision int64 `json:"device_revision"`
Generation int64 `json:"binding_generation"`
BindingState string `json:"binding_state"`
ActivationState string `json:"activation_state"`
NextAction string `json:"-"`
Hash string `json:"state_hash"`
}
func stateHash(st State) string {
return digest([]byte(fmt.Sprintf("STATE1\n%s\n%d\n%d\n%s\n%s", st.DeviceID, st.Revision, st.Generation, st.BindingState, st.ActivationState)))
}
func (s *Service) state(ctx context.Context, tx pgx.Tx, d Device) (State, error) {
if err := s.expireClaims(ctx, tx, d.ID); err != nil {
return State{}, err
}
d, err := readDevice(ctx, tx, d.ID, true)
if err != nil {
return State{}, err
}
st := State{DeviceID: d.FactoryID, PlatformDeviceID: d.PublicID, Revision: d.Revision, Generation: d.Generation, BindingState: "UNBOUND", ActivationState: "NOT_STARTED", NextAction: "WAIT_BIND"}
var bound, pending bool
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL),EXISTS(SELECT 1 FROM device_binding_claim WHERE device_id=$1::uuid AND status='PENDING' AND expires_at>now())`, d.ID).Scan(&bound, &pending)
if err != nil {
return st, err
}
err = tx.QueryRow(ctx, `SELECT status FROM device_activation_job WHERE device_id=$1::uuid`, d.ID).Scan(&st.ActivationState)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return st, err
}
if pending {
st.BindingState = "CLAIM_PENDING"
}
if bound {
st.BindingState = "BOUND"
st.NextAction = "RESUME_ACTIVATION"
}
if st.ActivationState == "READY" && bound {
st.NextAction = "ENTER_READY"
}
if !deviceAllowed(d) {
st.NextAction = "CONTACT_SUPPORT"
}
st.Hash = stateHash(st)
raw, _ := json.Marshal(st)
_, err = tx.Exec(ctx, `INSERT INTO device_state_snapshot(device_id,revision,binding_generation,state_hash,state) VALUES($1::uuid,$2,$3,$4,$5::jsonb) ON CONFLICT(device_id,revision) DO NOTHING`, d.ID, st.Revision, st.Generation, st.Hash, raw)
if err != nil {
return st, err
}
var stored string
err = tx.QueryRow(ctx, `SELECT state_hash FROM device_state_snapshot WHERE device_id=$1::uuid AND revision=$2`, d.ID, st.Revision).Scan(&stored)
if err == nil && stored != st.Hash {
return st, errors.New("onboarding state changed without revision")
}
return st, err
}
func bump(ctx context.Context, tx pgx.Tx, deviceID string) error {
_, err := tx.Exec(ctx, `UPDATE device SET onboarding_revision=onboarding_revision+1 WHERE id=$1::uuid`, deviceID)
return err
}
func (s *Service) auditEvent(ctx context.Context, tx pgx.Tx, d Device, actorType, actorID, action, requestID string, detail map[string]any) error {
return s.audit.Record(ctx, tx, audit.Event{TenantID: &d.TenantID, ActorType: actorType, ActorPublicID: &actorID, Action: action, ResourceType: "device", ResourceID: &d.PublicID, RequestID: requestID, Result: "success", Detail: detail})
}
// The device lock protects both replay validation and the mutation. Cached
// responses are encrypted and may never outlive their business authorization.
func (s *Service) mutate(ctx context.Context, d Device, actor, scope, requestID string, raw []byte, ttl time.Duration, op func(pgx.Tx, Device) (map[string]any, error)) (map[string]any, error) {
if !uuidV4(requestID) {
return nil, fail(400, "REQUEST_ID_REQUIRED", "X-Request-ID必须是UUID v4")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
current, err := readDevice(ctx, tx, d.ID, true)
if err != nil {
return nil, err
}
if !deviceAllowed(current) {
return nil, errAccess
}
if current.Generation != d.Generation {
return nil, errState
}
if strings.HasPrefix(actor, "device:") && d.KeyVersion > 0 {
var valid bool
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_onboarding_key WHERE device_id=$1::uuid AND key_version=$2 AND status='ACTIVE')`, d.ID, d.KeyVersion).Scan(&valid); err != nil {
return nil, err
}
if !valid {
return nil, errAuth
}
if scope != "register" && current.BootID != d.BootID {
return nil, errState
}
}
if strings.HasPrefix(actor, "web:") {
if err = s.checkWebActor(ctx, tx, strings.TrimPrefix(actor, "web:"), current); err != nil {
return nil, err
}
}
requestHash := digest(raw)
aad := actor + "\n" + scope + "\n" + requestID
var stored string
var generation int64
var encrypted []byte
var expires time.Time
err = tx.QueryRow(ctx, `SELECT request_digest,binding_generation,response_ciphertext,expires_at FROM device_onboarding_request WHERE actor_key=$1 AND scope=$2 AND request_id=$3::uuid`, actor, scope, requestID).Scan(&stored, &generation, &encrypted, &expires)
if err == nil {
if stored != requestHash {
return nil, fail(409, "REQUEST_BODY_CONFLICT", "相同请求号不能提交不同内容")
}
if generation != current.Generation {
return nil, errState
}
if !expires.After(s.now()) {
return nil, fail(410, "REQUEST_RESULT_EXPIRED", "该请求的短时结果已过期,请恢复当前流程")
}
if err = s.replayGuard(ctx, tx, current, actor, scope, raw); err != nil {
return nil, err
}
plain, err := s.crypt.open(encrypted, aad)
if err != nil {
return nil, err
}
var result map[string]any
if err = json.Unmarshal(plain, &result); err != nil {
return nil, err
}
if err = s.replayResultGuard(ctx, tx, current, actor, scope, result); err != nil {
return nil, err
}
// ACK persistence is idempotent, but close readiness changes as the
// grace period elapses. Recompute it without moving accepted_at.
if scope == "state-ack" {
result["setup_can_close"], err = s.stateAckCanClose(ctx, tx, current)
if err != nil {
return nil, err
}
}
if scope == "activation-result" || scope == "activation-ack" {
st, e := s.state(ctx, tx, current)
if e != nil {
return nil, e
}
result["state"] = st
if scope == "activation-result" {
result["reconcile_required"] = st.ActivationState == "RECONCILE_REQUIRED"
}
}
return result, tx.Commit(ctx)
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
result, err := op(tx, current)
if err != nil {
return nil, err
}
current, err = readDevice(ctx, tx, d.ID, false)
if err != nil {
return nil, err
}
payload, err := json.Marshal(result)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `INSERT INTO device_onboarding_request(actor_key,scope,request_id,request_digest,device_id,binding_generation,response_ciphertext,expires_at) VALUES($1,$2,$3::uuid,$4,$5::uuid,$6,$7,$8)`, actor, scope, requestID, requestHash, d.ID, current.Generation, s.crypt.seal(payload, aad), s.now().Add(ttl))
if err != nil {
return nil, err
}
return result, tx.Commit(ctx)
}
+518
View File
@@ -0,0 +1,518 @@
package onboarding
import (
"context"
"crypto/hmac"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"data-terminal-cloud/internal/deviceactivation"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type RegisterInput struct {
ChallengeID string `json:"challenge_id"`
BootID string `json:"boot_id"`
Model string `json:"hardware_model"`
HardwareRevision string `json:"hardware_revision"`
FirmwareVersion string `json:"firmware_version"`
SDKVersion string `json:"sdk_version"`
}
func (s *Service) Register(ctx context.Context, d Device, requestID string, raw []byte, in RegisterInput) (map[string]any, error) {
if !uuidV4(in.ChallengeID) || !uuidV4(in.BootID) || in.Model != d.Model || in.HardwareRevision != d.HardwareRevision || len(in.FirmwareVersion) < 1 || len(in.FirmwareVersion) > 32 || len(in.SDKVersion) < 1 || len(in.SDKVersion) > 64 {
return nil, fail(400, "VALIDATION_FAILED", "注册字段与生产记录不一致")
}
return s.mutate(ctx, d, "device:"+d.ID, "register", requestID, raw, 24*time.Hour, func(tx pgx.Tx, current Device) (map[string]any, error) {
tag, err := tx.Exec(ctx, `UPDATE device_time_challenge SET consumed_by=$1::uuid WHERE id=$2::uuid AND device_id=$3::uuid AND key_version=$4 AND boot_id=$5::uuid AND expires_at>now() AND consumed_by IS NULL`, requestID, in.ChallengeID, d.ID, d.KeyVersion, in.BootID)
if err != nil {
return nil, err
}
if tag.RowsAffected() != 1 {
return nil, fail(409, "CHALLENGE_INVALID", "请重新获取校时挑战")
}
if current.BootID != "" && current.BootID != in.BootID {
if _, err = tx.Exec(ctx, `UPDATE device_setup_session SET closed_at=COALESCE(closed_at,now()),close_reason='DEVICE_REBOOT' WHERE device_id=$1::uuid`, d.ID); err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `UPDATE device_binding_claim SET status='CANCELLED',completed_at=now() WHERE device_id=$1::uuid AND status='PENDING'`, d.ID); err != nil {
return nil, err
}
// A new boot cannot reuse a still-live pre-reboot execution permit:
// the previous SDK call may have reached the supplier before power loss.
pending, e := tx.Exec(ctx, `UPDATE device_activation_attempt a SET status='UNKNOWN' FROM device_activation_job j WHERE a.activation_id=j.id AND j.device_id=$1::uuid AND a.executor='DEVICE' AND a.status='CONTACT_POSSIBLE'`, d.ID)
if e != nil {
return nil, e
}
if pending.RowsAffected() > 0 {
if _, err = tx.Exec(ctx, `UPDATE device_activation_job SET status='RECONCILE_REQUIRED',last_error_code='DEVICE_REBOOT_DURING_ATTEMPT',version=version+1,updated_at=now() WHERE device_id=$1::uuid`, d.ID); err != nil {
return nil, err
}
}
}
_, err = tx.Exec(ctx, `UPDATE device SET onboarding_boot_id=$2::uuid,onboarding_last_seen_at=now(),onboarding_revision=onboarding_revision+1 WHERE id=$1::uuid`, d.ID, in.BootID)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `UPDATE device_twin SET firmware_version=$2,board_revision=$3,rtc_sdk_version=$4,last_reported_at=now(),updated_at=now() WHERE device_id=$1::uuid`, d.ID, in.FirmwareVersion, in.HardwareRevision, in.SDKVersion)
if err != nil {
return nil, err
}
st, err := s.state(ctx, tx, d)
return map[string]any{"state": st, "contract_version": ContractVersion}, err
})
}
type SetupInput struct {
BootID string `json:"boot_id"`
LocalSetupID string `json:"local_setup_id"`
Transport string `json:"transport_profile"`
}
type setup struct {
ID, DeviceID, BootID, LocalID, Mode string
Generation int64
Expires time.Time
Closed *time.Time
}
func readSetup(ctx context.Context, tx pgx.Tx, id string, d Device, open bool) (setup, error) {
var v setup
if !uuidV4(id) {
return v, fail(400, "VALIDATION_FAILED", "设置会话编号不正确")
}
err := tx.QueryRow(ctx, `SELECT id::text,device_id::text,boot_id::text,local_setup_id::text,mode,binding_generation,expires_at,closed_at FROM device_setup_session WHERE id=$1::uuid AND device_id=$2::uuid AND tenant_id=$3::uuid FOR UPDATE`, id, d.ID, d.TenantID).Scan(&v.ID, &v.DeviceID, &v.BootID, &v.LocalID, &v.Mode, &v.Generation, &v.Expires, &v.Closed)
if errors.Is(err, pgx.ErrNoRows) {
return v, errAccess
}
if err != nil {
return v, err
}
if v.BootID != d.BootID {
return v, fail(409, "BOOT_CHANGED", "设备已重启,请恢复新的设置会话")
}
if v.Generation != d.Generation {
return v, errState
}
if open && (v.Closed != nil || !v.Expires.After(time.Now())) {
return v, fail(410, "SETUP_EXPIRED", "设置入口已关闭,请从设备恢复")
}
return v, nil
}
func (s *Service) Setup(ctx context.Context, d Device, requestID string, raw []byte, in SetupInput) (map[string]any, error) {
if !uuidV4(in.BootID) || !uuidV4(in.LocalSetupID) || in.Transport != "LOCAL_WEB" {
return nil, fail(400, "VALIDATION_FAILED", "设置会话参数不正确")
}
return s.mutate(ctx, d, "device:"+d.ID, "setup", requestID, raw, 15*time.Minute, func(tx pgx.Tx, current Device) (map[string]any, error) {
if current.BootID != in.BootID {
return nil, fail(409, "BOOT_CHANGED", "请先完成本次启动注册")
}
st, err := s.state(ctx, tx, current)
if err != nil {
return nil, err
}
mode := "ENROLL"
if st.BindingState == "BOUND" {
mode = "RECOVERY"
}
if mode == "ENROLL" && !s.Enabled() {
return nil, fail(503, "ENROLLMENT_PAUSED", "首次绑定入口暂停")
}
var id string
var expires time.Time
err = tx.QueryRow(ctx, `SELECT id::text,expires_at FROM device_setup_session WHERE device_id=$1::uuid AND boot_id=$2::uuid AND local_setup_id=$3::uuid AND binding_generation=$4 AND mode=$5 AND closed_at IS NULL AND expires_at>now() ORDER BY created_at DESC LIMIT 1`, d.ID, in.BootID, in.LocalSetupID, current.Generation, mode).Scan(&id, &expires)
if errors.Is(err, pgx.ErrNoRows) {
expires = s.now().Add(15 * time.Minute)
err = tx.QueryRow(ctx, `INSERT INTO device_setup_session(device_id,tenant_id,boot_id,local_setup_id,binding_generation,mode,expires_at) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5,$6,$7) RETURNING id::text`, d.ID, d.TenantID, in.BootID, in.LocalSetupID, current.Generation, mode, expires).Scan(&id)
}
return map[string]any{"setup_id": id, "expires_at_ms": expires.UnixMilli(), "poll_after_ms": 2000, "state": st}, err
})
}
type HandoffInput struct {
SetupID string `json:"setup_id"`
LocalID string `json:"local_setup_id"`
Challenge string `json:"code_challenge"`
}
func (s *Service) Handoff(ctx context.Context, d Device, requestID string, raw []byte, in HandoffInput) (map[string]any, error) {
if !uuidV4(in.LocalID) || !validB64(in.Challenge, 32) {
return nil, fail(400, "VALIDATION_FAILED", "入口挑战不正确")
}
return s.mutate(ctx, d, "device:"+d.ID, "handoff", requestID, raw, 90*time.Second, func(tx pgx.Tx, current Device) (map[string]any, error) {
setup, err := readSetup(ctx, tx, in.SetupID, current, true)
if err != nil {
return nil, err
}
if setup.Mode != "ENROLL" || setup.LocalID != in.LocalID {
return nil, errAccess
}
var bound bool
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL)`, d.ID).Scan(&bound); err != nil {
return nil, err
}
if bound {
return nil, errAccess
}
token := randomToken(32)
expires := s.now().Add(90 * time.Second)
if setup.Expires.Before(expires) {
expires = setup.Expires
}
_, err = tx.Exec(ctx, `INSERT INTO device_handoff(token_hash,setup_id,code_challenge,expires_at) VALUES($1,$2::uuid,$3,$4)`, digest([]byte(token)), setup.ID, in.Challenge, expires)
return map[string]any{"handoff_token": token, "expires_at_ms": expires.UnixMilli(), "entry_url": s.options.EntryURL}, err
})
}
type PollInput struct {
SetupID string `json:"setup_id"`
BootID string `json:"boot_id"`
KnownRevision int64 `json:"known_revision"`
}
func (s *Service) Poll(ctx context.Context, d Device, in PollInput) (map[string]any, error) {
if !uuidV4(in.BootID) || in.KnownRevision < 0 {
return nil, fail(400, "VALIDATION_FAILED", "轮询参数不正确")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
d, err = readDevice(ctx, tx, d.ID, true)
if err != nil {
return nil, err
}
if !deviceAllowed(d) {
return nil, errAccess
}
setup, err := readSetup(ctx, tx, in.SetupID, d, false)
if err != nil {
return nil, err
}
if in.BootID != d.BootID {
return nil, errState
}
if err = s.expireClaims(ctx, tx, d.ID); err != nil {
return nil, err
}
st, err := s.state(ctx, tx, d)
if err != nil {
return nil, err
}
var command any
if setup.Closed == nil && setup.Expires.After(s.now()) && setup.Mode == "ENROLL" {
var id, hash, masked string
var encrypted []byte
var expires time.Time
var gen int64
err = tx.QueryRow(ctx, `SELECT c.id::text,c.challenge_ciphertext,c.binding_digest,c.expected_generation,c.expires_at,COALESCE(ua.display_name,'') FROM device_binding_claim c JOIN user_account ua ON ua.id=c.user_id WHERE c.device_id=$1::uuid AND c.setup_id=$2::uuid AND c.status='PENDING' AND c.expires_at>now()`, d.ID, setup.ID).Scan(&id, &encrypted, &hash, &gen, &expires, &masked)
if err == nil {
plain, e := s.crypt.open(encrypted, "claim:"+id)
if e != nil {
return nil, e
}
runes := []rune(masked)
if len(runes) > 4 {
masked = "尾号" + string(runes[len(runes)-4:])
} else {
masked = "当前登录账号"
}
command = map[string]any{"command_id": id, "type": "CONFIRM_CLAIM", "expires_at_ms": expires.UnixMilli(), "payload": map[string]any{"claim_id": id, "challenge": string(plain), "binding_digest": hash, "expected_binding_generation": gen, "masked_account": masked}}
} else if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
}
if command == nil && st.BindingState == "BOUND" {
command, err = s.activationCommand(ctx, tx, d)
if err != nil {
return nil, err
}
}
st, err = s.state(ctx, tx, d)
if err != nil {
return nil, err
}
interval := 2000
if st.BindingState == "BOUND" {
interval = 10000
}
if st.ActivationState == "READY" {
interval = 60000
}
return map[string]any{"state": st, "command": command, "poll_after_ms": interval}, tx.Commit(ctx)
}
func (s *Service) expireClaims(ctx context.Context, tx pgx.Tx, id string) error {
tag, err := tx.Exec(ctx, `UPDATE device_binding_claim SET status='EXPIRED',completed_at=now() WHERE device_id=$1::uuid AND status='PENDING' AND expires_at<=now()`, id)
if err == nil && tag.RowsAffected() > 0 {
err = bump(ctx, tx, id)
}
return err
}
type ConfirmationInput struct {
ClaimID string `json:"claim_id"`
Challenge string `json:"challenge"`
Digest string `json:"binding_digest"`
ExpectedGeneration int64 `json:"expected_binding_generation"`
PressEventID string `json:"press_event_id"`
}
func (s *Service) Confirm(ctx context.Context, d Device, requestID string, raw []byte, in ConfirmationInput) (map[string]any, error) {
if !uuidV4(in.ClaimID) || !uuidV4(in.PressEventID) || !validB64(in.Challenge, 32) || !validDigest(in.Digest) || in.ExpectedGeneration < 0 {
return nil, fail(400, "VALIDATION_FAILED", "设备确认字段不正确")
}
return s.mutate(ctx, d, "device:"+d.ID, "confirm", requestID, raw, 24*time.Hour, func(tx pgx.Tx, current Device) (map[string]any, error) {
var claim claimRecord
var err error
claim, err = readClaim(ctx, tx, in.ClaimID, current.ID)
if err != nil {
return nil, err
}
if err = claimPermission(ctx, tx, claim); err != nil {
return nil, err
}
if !hmac.Equal([]byte(claim.ChallengeHash), []byte(digest([]byte(in.Challenge)))) || !hmac.Equal([]byte(claim.Digest), []byte(in.Digest)) || claim.Generation != in.ExpectedGeneration {
return nil, errState
}
if claim.Status == "CONFIRMED" {
if claim.PressID != in.PressEventID || current.Generation != claim.Generation+1 {
return nil, errState
}
var active bool
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE id=$1::uuid AND unbound_at IS NULL AND generation=$2)`, claim.BindingID, current.Generation).Scan(&active)
if err != nil {
return nil, err
}
if !active {
return nil, errState
}
st, e := s.state(ctx, tx, current)
return map[string]any{"claim_id": claim.ID, "state": st, "already_applied": true}, e
}
if current.Generation != in.ExpectedGeneration {
return nil, errState
}
setup, err := readSetup(ctx, tx, claim.SetupID, current, true)
if err != nil {
return nil, err
}
if setup.Mode != "ENROLL" {
return nil, errAccess
}
if claim.Status != "PENDING" || !claim.Expires.After(s.now()) {
return nil, fail(410, "CLAIM_EXPIRED", "绑定申请已过期或取消")
}
var active bool
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL)`, d.ID).Scan(&active)
if err != nil {
return nil, err
}
if active {
return nil, errState
}
if _, err = tx.Exec(ctx, `SELECT set_config('app.onboarding_claim',$1,true)`, claim.ID); err != nil {
return nil, err
}
var bindingID string
err = tx.QueryRow(ctx, `INSERT INTO device_binding(tenant_id,device_id,household_id,subject_id,bound_by,reason) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,'physical_confirm') RETURNING id::text`, d.TenantID, d.ID, claim.HouseholdID, claim.SubjectID, claim.UserID).Scan(&bindingID)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `UPDATE device_binding_claim SET status='CONFIRMED',press_event_id=$2::uuid,binding_id=$3::uuid,completed_at=now() WHERE id=$1::uuid`, claim.ID, in.PressEventID, bindingID)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `UPDATE device_setup_session SET binding_generation=$2 WHERE id=$1::uuid`, setup.ID, current.Generation+1)
if err != nil {
return nil, err
}
if _, err = deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: d.ID, TenantID: d.TenantID, Target: deviceactivation.Bound, Reason: "设备按键确认绑定", Source: "onboarding", ActorType: "device", ActorPublicID: &d.PublicID, RequestID: requestID}); err != nil {
return nil, err
}
if err = s.enqueueActivation(ctx, tx, current, current.Generation+1, requestID); err != nil {
return nil, err
}
// Match the platform's basic voice mode. Optional storage, extraction and
// personalization stay off until their existing consent flows are used.
_, err = tx.Exec(ctx, `INSERT INTO consent_snapshot(tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids) SELECT $1::uuid,$2::uuid,1,'{"voice_chat":true,"transcript_storage":false,"raw_audio_storage":false,"story_extraction":false,"personalized_memory":false,"family_sharing":false}'::jsonb,'[]'::jsonb WHERE NOT EXISTS(SELECT 1 FROM consent_snapshot WHERE tenant_id=$1::uuid AND subject_id=$2::uuid)`, current.TenantID, claim.SubjectID)
if err != nil {
return nil, err
}
if err = s.auditEvent(ctx, tx, d, "device", d.PublicID, "device.binding.confirm", requestID, map[string]any{"claim_id": claim.ID, "binding_generation": current.Generation + 1}); err != nil {
return nil, err
}
st, err := s.state(ctx, tx, current)
return map[string]any{"claim_id": claim.ID, "state": st, "already_applied": false}, err
})
}
type claimRecord struct {
ID, SetupID, DeviceID, TenantID, UserID, HouseholdID, SubjectID, ChallengeHash, Digest, Status, BindingID, PressID string
Generation int64
Expires time.Time
}
func readClaim(ctx context.Context, tx pgx.Tx, id, device string) (claimRecord, error) {
var c claimRecord
err := tx.QueryRow(ctx, `SELECT id::text,setup_id::text,device_id::text,tenant_id::text,user_id::text,household_id::text,subject_id::text,challenge_hash,binding_digest,status,COALESCE(binding_id::text,''),COALESCE(press_event_id::text,''),expected_generation,expires_at FROM device_binding_claim WHERE id=$1::uuid AND device_id=$2::uuid FOR UPDATE`, id, device).Scan(&c.ID, &c.SetupID, &c.DeviceID, &c.TenantID, &c.UserID, &c.HouseholdID, &c.SubjectID, &c.ChallengeHash, &c.Digest, &c.Status, &c.BindingID, &c.PressID, &c.Generation, &c.Expires)
if errors.Is(err, pgx.ErrNoRows) {
return c, errAccess
}
return c, err
}
func claimPermission(ctx context.Context, tx pgx.Tx, c claimRecord) error {
var allowed bool
err := tx.QueryRow(ctx, `SELECT true FROM household_member hm JOIN user_account ua ON ua.id=hm.user_id AND ua.status='active' JOIN household h ON h.id=hm.household_id AND h.status='active' JOIN subject_profile sp ON sp.household_id=h.id AND sp.tenant_id=h.tenant_id WHERE ua.id=$1::uuid AND hm.household_id=$2::uuid AND sp.id=$3::uuid AND hm.tenant_id=$4::uuid AND hm.status='active' AND hm.role IN ('owner','admin','editor') AND sp.status='active' FOR SHARE OF ua,hm,h,sp`, c.UserID, c.HouseholdID, c.SubjectID, c.TenantID).Scan(&allowed)
if errors.Is(err, pgx.ErrNoRows) {
return errAccess
}
if err != nil {
return err
}
if !allowed {
return errAccess
}
return nil
}
type StateAckInput struct {
Revision int64 `json:"device_revision"`
Generation int64 `json:"binding_generation"`
LocalGeneration int64 `json:"local_generation"`
Hash string `json:"state_hash"`
}
func (s *Service) StateAck(ctx context.Context, d Device, requestID string, raw []byte, in StateAckInput) (map[string]any, error) {
if in.Revision < 1 || in.Generation < 0 || in.LocalGeneration < 0 || !validDigest(in.Hash) {
return nil, fail(400, "VALIDATION_FAILED", "状态回执不正确")
}
return s.mutate(ctx, d, "device:"+d.ID, "state-ack", requestID, raw, 24*time.Hour, func(tx pgx.Tx, current Device) (map[string]any, error) {
st, err := s.state(ctx, tx, current)
if err != nil {
return nil, err
}
if st.Revision != in.Revision || st.Generation != in.Generation || st.Hash != in.Hash {
return nil, errState
}
var local int64
var boot string
err = tx.QueryRow(ctx, `SELECT local_generation,boot_id::text FROM device_state_ack WHERE device_id=$1::uuid`, d.ID).Scan(&local, &boot)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err == nil && boot == current.BootID && local > in.LocalGeneration {
return nil, errState
}
_, err = tx.Exec(ctx, `INSERT INTO device_state_ack(device_id,revision,binding_generation,state_hash,boot_id,local_generation) VALUES($1::uuid,$2,$3,$4,$5::uuid,$6) ON CONFLICT(device_id) DO UPDATE SET revision=EXCLUDED.revision,binding_generation=EXCLUDED.binding_generation,state_hash=EXCLUDED.state_hash,boot_id=EXCLUDED.boot_id,local_generation=EXCLUDED.local_generation,accepted_at=CASE WHEN device_state_ack.binding_generation=EXCLUDED.binding_generation AND device_state_ack.boot_id=EXCLUDED.boot_id THEN device_state_ack.accepted_at ELSE now() END`, d.ID, in.Revision, in.Generation, in.Hash, current.BootID, in.LocalGeneration)
if err != nil {
return nil, err
}
canClose, err := s.stateAckCanClose(ctx, tx, current)
return map[string]any{"accepted_revision": in.Revision, "setup_can_close": canClose}, err
})
}
type CloseInput struct {
SetupID string `json:"setup_id"`
Reason string `json:"reason"`
Revision int64 `json:"final_revision"`
}
func (s *Service) Close(ctx context.Context, d Device, requestID string, raw []byte, in CloseInput) (map[string]any, error) {
if !uuidV4(in.SetupID) || in.Revision < 1 || !oneOf(in.Reason, "SUCCESS", "TIMEOUT", "CANCELLED", "NETWORK_SWITCH") {
return nil, fail(400, "VALIDATION_FAILED", "收尾参数不正确")
}
return s.mutate(ctx, d, "device:"+d.ID, "close", requestID, raw, 24*time.Hour, func(tx pgx.Tx, current Device) (map[string]any, error) {
setup, err := readSetup(ctx, tx, in.SetupID, current, false)
if err != nil {
return nil, err
}
if in.Revision > current.Revision {
return nil, errState
}
if in.Reason == "SUCCESS" {
_, allowed, e := s.closeDelay(ctx, tx, current, setup.ID)
if e != nil {
return nil, e
}
if !allowed {
return nil, fail(409, "SETUP_CLOSE_WAIT", "请等待设备和页面回执的收尾时间")
}
var saved bool
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_state_ack a JOIN device_binding b ON b.device_id=a.device_id AND b.unbound_at IS NULL WHERE a.device_id=$1::uuid AND a.binding_generation=$2 AND a.boot_id=$3::uuid)`, d.ID, current.Generation, current.BootID).Scan(&saved)
if err != nil {
return nil, err
}
if !saved {
return nil, fail(409, "STATE_ACK_REQUIRED", "设备保存后才能确认绑定收尾")
}
}
if _, err = tx.Exec(ctx, `UPDATE device_setup_session SET closed_at=COALESCE(closed_at,now()),close_reason=COALESCE(close_reason,$2) WHERE id=$1::uuid`, setup.ID, in.Reason); err != nil {
return nil, err
}
tag, err := tx.Exec(ctx, `UPDATE device_binding_claim SET status='CANCELLED',completed_at=now() WHERE setup_id=$1::uuid AND status='PENDING'`, setup.ID)
if err != nil {
return nil, err
}
if tag.RowsAffected() > 0 {
if err = bump(ctx, tx, d.ID); err != nil {
return nil, err
}
}
st, err := s.state(ctx, tx, current)
return map[string]any{"close_allowed": true, "state": st}, err
})
}
func oneOf(s string, values ...string) bool {
for _, v := range values {
if s == v {
return true
}
}
return false
}
func bindingDigest(d Device, user, household, subject, claim string) string {
return digest([]byte(fmt.Sprintf("CLAIM1\n%s\n%s\n%s\n%s\n%s\n%d", d.FactoryID, user, household, subject, claim, d.Generation)))
}
func newClaim(ctx context.Context, s *Service, tx pgx.Tx, d Device, setupID, user, household, subject string) (map[string]any, error) {
if err := s.expireClaims(ctx, tx, d.ID); err != nil {
return nil, err
}
var pending bool
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding_claim WHERE device_id=$1::uuid AND status='PENDING')`, d.ID).Scan(&pending)
if err != nil {
return nil, err
}
if pending {
return nil, fail(409, "CLAIM_BUSY", "设备正在等待另一笔确认,请稍后再试")
}
id := uuid.NewString()
challenge := randomToken(32)
hash := bindingDigest(d, user, household, subject, id)
expires := s.now().Add(120 * time.Second)
setup, err := readSetup(ctx, tx, setupID, d, true)
if err != nil {
return nil, err
}
if setup.Expires.Before(expires) {
expires = setup.Expires
}
_, err = tx.Exec(ctx, `INSERT INTO device_binding_claim(id,device_id,tenant_id,setup_id,user_id,household_id,subject_id,expected_generation,challenge_hash,challenge_ciphertext,binding_digest,expires_at) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid,$7::uuid,$8,$9,$10,$11,$12)`, id, d.ID, d.TenantID, setupID, user, household, subject, d.Generation, digest([]byte(challenge)), s.crypt.seal([]byte(challenge), "claim:"+id), hash, expires)
if err != nil {
return nil, err
}
if err = bump(ctx, tx, d.ID); err != nil {
return nil, err
}
st, err := s.state(ctx, tx, d)
return map[string]any{"claim_id": id, "expires_at_ms": expires.UnixMilli(), "state": st}, err
}
// Used by protocol tests without leaking transient challenges into logs.
func requestJSON(v any) []byte { b, _ := json.Marshal(v); return b }
func nonempty(v string, max int) bool { return len(strings.TrimSpace(v)) > 0 && len(v) <= max }
+240
View File
@@ -0,0 +1,240 @@
package onboarding
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type SMSProvider interface {
Send(context.Context, string, string, string) error
}
type HTTPSMSProvider struct {
endpoint, token string
client *http.Client
}
func NewHTTPSMSProvider(endpoint, token string) (SMSProvider, error) {
u, err := url.Parse(endpoint)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || token == "" {
return nil, errors.New("SMS gateway requires fixed HTTPS endpoint and access token")
}
return &HTTPSMSProvider{endpoint: endpoint, token: token, client: &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil
}
// The gateway must deduplicate request_id. A timeout is never retried locally.
func (p *HTTPSMSProvider) Send(ctx context.Context, phone, code, id string) error {
raw, _ := json.Marshal(map[string]any{"phone_e164": phone, "code": code, "purpose": "LOGIN", "request_id": id, "expires_in_seconds": 300})
r, err := http.NewRequestWithContext(ctx, http.MethodPost, p.endpoint, bytes.NewReader(raw))
if err != nil {
return err
}
r.Header.Set("Authorization", "Bearer "+p.token)
r.Header.Set("Content-Type", "application/json")
r.Header.Set("Idempotency-Key", id)
response, err := p.client.Do(r)
if err != nil {
return errors.New("SMS_GATEWAY_UNCERTAIN")
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
if response.StatusCode < 200 || response.StatusCode >= 300 {
return errors.New("SMS_GATEWAY_REJECTED")
}
return nil
}
var phonePattern = regexp.MustCompile(`^\+[1-9][0-9]{7,14}$`)
var smsCodePattern = regexp.MustCompile(`^[0-9]{6}$`)
type SMSSendInput struct {
Phone string `json:"phone_e164"`
Purpose string `json:"purpose"`
}
func (s *Service) SendSMS(ctx context.Context, w WebSession, requestID string, in SMSSendInput) (map[string]any, error) {
if !s.options.SMSLoginEnabled {
return nil, fail(503, "SMS_UNAVAILABLE", "短信登录暂未开放,请使用账号登录")
}
if !uuidV4(requestID) || !phonePattern.MatchString(in.Phone) || in.Purpose != "LOGIN" {
return nil, fail(400, "VALIDATION_FAILED", "请使用包含国家区号的手机号码")
}
phoneHash := domainMAC(s.crypt.hashKey, "PHONE1\n"+in.Phone)
// Keyed phone hashes prevent offline enumeration of stored phone numbers.
if err := s.limit(ctx, "sms:global", 1000, time.Hour); err != nil {
return nil, err
}
if err := s.limit(ctx, "sms:session:"+w.Hash, 10, time.Hour); err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
d, err := readDevice(ctx, tx, w.Device.ID, true)
if err != nil {
return nil, err
}
if err = s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "sms:"+phoneHash); err != nil {
return nil, err
}
var id, status, stored string
var expires time.Time
err = tx.QueryRow(ctx, `SELECT id::text,status,phone_digest,expires_at FROM onboarding_sms_challenge WHERE session_hash=$1 AND request_id=$2::uuid`, w.Hash, requestID).Scan(&id, &status, &stored, &expires)
if err == nil {
if stored != phoneHash {
return nil, fail(409, "REQUEST_BODY_CONFLICT", "请求号已用于其他手机号码")
}
if status != "SENT" || !expires.After(s.now()) {
return nil, fail(409, "SMS_SEND_UNCERTAIN", "请等待重发间隔后重新申请验证码")
}
return smsSendResult(id, expires), tx.Commit(ctx)
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
var recent, hourCount int
err = tx.QueryRow(ctx, `SELECT count(*) FILTER(WHERE created_at>now()-interval '60 seconds'),count(*) FROM onboarding_sms_challenge WHERE phone_digest=$1 AND created_at>now()-interval '1 hour'`, phoneHash).Scan(&recent, &hourCount)
if err != nil {
return nil, err
}
if recent > 0 || hourCount >= 5 {
return nil, fail(429, "SMS_RATE_LIMITED", "发送过于频繁,请稍后再试")
}
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return nil, err
}
code := fmt.Sprintf("%06d", n.Int64())
id = uuid.NewString()
expires = s.now().Add(5 * time.Minute)
codeHash := domainMAC(s.smsHashKey(), id+"\n"+code)
_, err = tx.Exec(ctx, `INSERT INTO onboarding_sms_challenge(id,session_hash,phone_digest,phone_ciphertext,code_hash,request_id,expires_at) VALUES($1::uuid,$2,$3,$4,$5,$6::uuid,$7)`, id, w.Hash, phoneHash, s.crypt.seal([]byte(in.Phone), "sms-phone:"+id), codeHash, requestID, expires)
if err != nil {
return nil, err
}
if err = tx.Commit(ctx); err != nil {
return nil, err
}
sendErr := s.options.SMS.Send(ctx, in.Phone, code, id)
status = "SENT"
if sendErr != nil {
status = "FAILED"
}
if _, err = s.pool.Exec(ctx, `UPDATE onboarding_sms_challenge SET status=$2 WHERE id=$1::uuid AND status='SENDING'`, id, status); err != nil {
return nil, err
}
if sendErr != nil {
return nil, fail(503, "SMS_SEND_UNCERTAIN", "发送结果未确认,请等待重发间隔后再试")
}
return smsSendResult(id, expires), nil
}
func (s *Service) smsHashKey() []byte { return []byte(domainMAC(s.crypt.hashKey, "SMS-CODE-KEY1")) }
func smsSendResult(id string, expires time.Time) map[string]any {
return map[string]any{"sms_challenge_id": id, "expires_at_ms": expires.UnixMilli(), "resend_after_ms": 60000}
}
type SMSVerifyInput struct {
ChallengeID string `json:"sms_challenge_id"`
Code string `json:"code"`
}
func (s *Service) VerifySMS(ctx context.Context, w WebSession, in SMSVerifyInput) (BrowserCredentials, error) {
var out BrowserCredentials
if !s.options.SMSLoginEnabled {
return out, fail(503, "SMS_UNAVAILABLE", "短信登录暂未开放")
}
if !uuidV4(in.ChallengeID) || !smsCodePattern.MatchString(in.Code) {
return out, fail(400, "VALIDATION_FAILED", "验证码格式不正确")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return out, err
}
defer tx.Rollback(ctx)
d, err := readDevice(ctx, tx, w.Device.ID, true)
if err != nil {
return out, err
}
if err = s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
return out, err
}
var expected, phoneHash, status string
var encrypted []byte
var attempts int
var expires time.Time
err = tx.QueryRow(ctx, `SELECT code_hash,phone_digest,phone_ciphertext,status,attempts,expires_at FROM onboarding_sms_challenge WHERE id=$1::uuid AND session_hash=$2 FOR UPDATE`, in.ChallengeID, w.Hash).Scan(&expected, &phoneHash, &encrypted, &status, &attempts, &expires)
if errors.Is(err, pgx.ErrNoRows) {
return out, fail(401, "SMS_CODE_INVALID", "验证码错误或已失效")
}
if err != nil {
return out, err
}
if status != "SENT" || attempts >= 5 || !expires.After(s.now()) {
return out, fail(401, "SMS_CODE_INVALID", "验证码错误或已失效")
}
if !hmac.Equal([]byte(expected), []byte(domainMAC(s.smsHashKey(), in.ChallengeID+"\n"+in.Code))) {
if _, err = tx.Exec(ctx, `UPDATE onboarding_sms_challenge SET attempts=attempts+1 WHERE id=$1::uuid`, in.ChallengeID); err != nil {
return out, err
}
if err = tx.Commit(ctx); err != nil {
return out, err
}
return out, fail(401, "SMS_CODE_INVALID", "验证码错误或已失效")
}
phone, err := s.crypt.open(encrypted, "sms-phone:"+in.ChallengeID)
if err != nil {
return out, err
}
masked := "用户" + string(phone[len(phone)-4:])
p := identity.Principal{Issuer: "urn:platform:onboarding:sms", Subject: phoneHash, DisplayName: masked}
out, err = s.attachPrincipal(ctx, tx, w, d, p, true)
if err != nil {
return out, err
}
_, err = tx.Exec(ctx, `UPDATE onboarding_sms_challenge SET status='CONSUMED',consumed_at=now(),attempts=attempts+1 WHERE id=$1::uuid`, in.ChallengeID)
if err != nil {
return out, err
}
return out, tx.Commit(ctx)
}
// Account cookies are accepted only by the explicit customer-route adapter.
func (s *Service) AccountPrincipal(ctx context.Context, token, csrf string, write bool) (identity.Principal, error) {
var p identity.Principal
var expected string
if !validB64(token, 32) {
return p, errAccess
}
err := s.pool.QueryRow(ctx, `SELECT ua.oidc_issuer,ua.oidc_subject,COALESCE(ua.display_name,''),ss.csrf_hash FROM onboarding_account_session ss JOIN user_account ua ON ua.id=ss.user_id AND ua.status='active' JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE ss.token_hash=$1 AND ss.expires_at>now() AND ss.revoked_at IS NULL`, digest([]byte(token))).Scan(&p.Issuer, &p.Subject, &p.DisplayName, &expected)
if errors.Is(err, pgx.ErrNoRows) {
return p, errAccess
}
if err != nil {
return p, err
}
if write && (!validB64(csrf, 32) || !hmac.Equal([]byte(expected), []byte(digest([]byte(csrf))))) {
return p, errAccess
}
p.DisplayName = strings.TrimSpace(p.DisplayName)
return p, nil
}
+437
View File
@@ -0,0 +1,437 @@
package onboarding
import (
"context"
"crypto/hmac"
"crypto/sha256"
"errors"
"time"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
type WebSession struct {
Hash, SetupID, UserID, Issuer, Subject string
Device Device
Expires time.Time
}
type BrowserCredentials struct {
Token string `json:"-"`
CSRF string `json:"csrf_token"`
AccountToken string `json:"-"`
Data map[string]any `json:"data"`
}
func (s *Service) webSession(ctx context.Context, q queryer, hash string) (WebSession, error) {
var w WebSession
var id string
err := q.QueryRow(ctx, `SELECT ws.token_hash,ws.setup_id::text,COALESCE(ws.user_id::text,''),COALESCE(ws.issuer,''),COALESCE(ws.subject,''),ws.expires_at,ss.device_id::text FROM onboarding_web_session ws JOIN device_setup_session ss ON ss.id=ws.setup_id WHERE ws.token_hash=$1 AND ws.revoked_at IS NULL AND ws.expires_at>now()`, hash).Scan(&w.Hash, &w.SetupID, &w.UserID, &w.Issuer, &w.Subject, &w.Expires, &id)
if errors.Is(err, pgx.ErrNoRows) {
return w, fail(401, "WEB_SESSION_EXPIRED", "请从设备重新打开设置入口")
}
if err != nil {
return w, err
}
w.Device, err = readDevice(ctx, q, id, false)
return w, err
}
func (s *Service) checkWebActor(ctx context.Context, tx pgx.Tx, hash string, d Device) error {
w, err := s.webSession(ctx, tx, hash)
if err != nil {
return err
}
if w.Device.ID != d.ID {
return errAccess
}
setup, err := readSetup(ctx, tx, w.SetupID, d, false)
if err != nil {
return err
}
// A closed setup can report a completed binding, but cannot authorize writes.
if setup.Closed != nil || !setup.Expires.After(s.now()) {
return fail(410, "SETUP_EXPIRED", "设置入口已关闭,请从设备列表查看结果")
}
if w.UserID != "" {
var active bool
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM user_account WHERE id=$1::uuid AND tenant_id=$2::uuid AND status='active')`, w.UserID, d.TenantID).Scan(&active)
if err != nil {
return err
}
if !active {
return errAccess
}
}
return nil
}
func (s *Service) checkCSRF(ctx context.Context, hash, token string) error {
if !validB64(token, 32) {
return errAccess
}
var expected string
err := s.pool.QueryRow(ctx, `SELECT csrf_hash FROM onboarding_web_session WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at>now()`, hash).Scan(&expected)
if err != nil || !hmac.Equal([]byte(expected), []byte(digest([]byte(token)))) {
return errAccess
}
return nil
}
type ExchangeInput struct {
Token string `json:"token"`
Verifier string `json:"verifier"`
}
func (s *Service) Exchange(ctx context.Context, in ExchangeInput) (BrowserCredentials, error) {
var out BrowserCredentials
if !validB64(in.Token, 32) || !validB64(in.Verifier, 32) {
return out, errAccess
}
var deviceID string
err := s.pool.QueryRow(ctx, `SELECT ss.device_id::text FROM device_handoff h JOIN device_setup_session ss ON ss.id=h.setup_id WHERE h.token_hash=$1`, digest([]byte(in.Token))).Scan(&deviceID)
if errors.Is(err, pgx.ErrNoRows) {
return out, errAccess
}
if err != nil {
return out, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return out, err
}
defer tx.Rollback(ctx)
d, err := readDevice(ctx, tx, deviceID, true)
if err != nil {
return out, err
}
if !deviceAllowed(d) {
return out, errAccess
}
var setupID, challenge string
var expires time.Time
var consumed *time.Time
err = tx.QueryRow(ctx, `SELECT setup_id::text,code_challenge,expires_at,consumed_at FROM device_handoff WHERE token_hash=$1 FOR UPDATE`, digest([]byte(in.Token))).Scan(&setupID, &challenge, &expires, &consumed)
if err != nil {
return out, err
}
sum := sha256.Sum256([]byte(in.Verifier))
if consumed != nil || !expires.After(s.now()) || !hmac.Equal([]byte(challenge), []byte(encode64(sum[:]))) {
return out, fail(410, "HANDOFF_INVALID", "入口已失效,请从设备获取新入口")
}
setup, err := readSetup(ctx, tx, setupID, d, true)
if err != nil {
return out, err
}
if setup.Mode != "ENROLL" {
return out, errAccess
}
st, err := s.state(ctx, tx, d)
if err != nil {
return out, err
}
if st.BindingState == "BOUND" {
return out, errAccess
}
out.Token = randomToken(32)
out.CSRF = randomToken(32)
_, err = tx.Exec(ctx, `INSERT INTO onboarding_web_session(token_hash,setup_id,csrf_hash,expires_at) VALUES($1,$2::uuid,$3,$4)`, digest([]byte(out.Token)), setupID, digest([]byte(out.CSRF)), setup.Expires)
if err != nil {
return out, err
}
_, err = tx.Exec(ctx, `UPDATE device_handoff SET consumed_at=now() WHERE token_hash=$1`, digest([]byte(in.Token)))
if err != nil {
return out, err
}
out.Data = map[string]any{"setup_id": setupID, "device_id": d.FactoryID, "state": st, "expires_at_ms": setup.Expires.UnixMilli(), "sms_enabled": s.options.SMSLoginEnabled}
return out, tx.Commit(ctx)
}
func (s *Service) attachPrincipal(ctx context.Context, tx pgx.Tx, w WebSession, d Device, p identity.Principal, allowSignup bool) (BrowserCredentials, error) {
var out BrowserCredentials
var user string
if err := s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
return out, err
}
_, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "account:"+d.TenantID+":"+p.Issuer+":"+p.Subject)
if err != nil {
return out, err
}
err = tx.QueryRow(ctx, `SELECT id::text FROM user_account WHERE tenant_id=$1::uuid AND oidc_issuer=$2 AND oidc_subject=$3 AND status='active' FOR SHARE`, d.TenantID, p.Issuer, p.Subject).Scan(&user)
if errors.Is(err, pgx.ErrNoRows) {
var tenantPublic string
if err = tx.QueryRow(ctx, `SELECT public_id::text FROM tenant WHERE id=$1::uuid`, d.TenantID).Scan(&tenantPublic); err != nil {
return out, err
}
allowed := false
for _, id := range s.options.SelfSignupTenantIDs {
if id == tenantPublic {
allowed = true
}
}
if !allowSignup || !allowed {
return out, fail(403, "TENANT_MEMBERSHIP_REQUIRED", "请先由该租户管理员授权账号")
}
err = tx.QueryRow(ctx, `INSERT INTO user_account(tenant_id,oidc_issuer,oidc_subject,display_name,platform_role,account_kind) VALUES($1::uuid,$2,$3,$4,'customer','customer') RETURNING id::text`, d.TenantID, p.Issuer, p.Subject, p.DisplayName).Scan(&user)
}
if err != nil {
return out, err
}
out.Token = randomToken(32)
out.CSRF = randomToken(32)
out.AccountToken = randomToken(32)
_, err = tx.Exec(ctx, `INSERT INTO onboarding_web_session(token_hash,setup_id,csrf_hash,user_id,issuer,subject,expires_at) VALUES($1,$2::uuid,$3,$4::uuid,$5,$6,$7)`, digest([]byte(out.Token)), w.SetupID, digest([]byte(out.CSRF)), user, p.Issuer, p.Subject, w.Expires)
if err != nil {
return out, err
}
_, err = tx.Exec(ctx, `UPDATE onboarding_web_session SET revoked_at=now() WHERE token_hash=$1`, w.Hash)
if err != nil {
return out, err
}
_, err = tx.Exec(ctx, `INSERT INTO onboarding_account_session(token_hash,csrf_hash,user_id,expires_at) VALUES($1,$2,$3::uuid,$4)`, digest([]byte(out.AccountToken)), digest([]byte(out.CSRF)), user, s.now().Add(24*time.Hour))
if err != nil {
return out, err
}
out.Data = map[string]any{"authenticated": true, "setup_id": w.SetupID}
return out, nil
}
func (s *Service) AuthLink(ctx context.Context, w WebSession, p identity.Principal) (BrowserCredentials, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return BrowserCredentials{}, err
}
defer tx.Rollback(ctx)
d, err := readDevice(ctx, tx, w.Device.ID, true)
if err != nil {
return BrowserCredentials{}, err
}
out, err := s.attachPrincipal(ctx, tx, w, d, p, true)
if err != nil {
return out, err
}
return out, tx.Commit(ctx)
}
type ClaimInput struct {
ElderID string `json:"elder_id"`
Generation int64 `json:"expected_binding_generation"`
}
func (s *Service) Claim(ctx context.Context, w WebSession, requestID string, raw []byte, in ClaimInput) (map[string]any, error) {
if w.UserID == "" {
return nil, fail(401, "LOGIN_REQUIRED", "请先登录")
}
if !uuidValid(in.ElderID) || in.Generation < 0 {
return nil, fail(400, "VALIDATION_FAILED", "请选择使用者")
}
return s.mutate(ctx, w.Device, "web:"+w.Hash, "claim", requestID, raw, 120*time.Second, func(tx pgx.Tx, d Device) (map[string]any, error) {
if err := s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
return nil, err
}
if d.Generation != in.Generation {
return nil, errState
}
setup, err := readSetup(ctx, tx, w.SetupID, d, true)
if err != nil {
return nil, err
}
if setup.Mode != "ENROLL" {
return nil, errAccess
}
var household, subject string
err = tx.QueryRow(ctx, `SELECT id::text,household_id::text FROM subject_profile WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND status='active'`, in.ElderID, d.TenantID).Scan(&subject, &household)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errAccess
}
if err != nil {
return nil, err
}
if err = claimPermission(ctx, tx, claimRecord{UserID: w.UserID, HouseholdID: household, SubjectID: subject, TenantID: d.TenantID}); err != nil {
return nil, err
}
st, err := s.state(ctx, tx, d)
if err != nil {
return nil, err
}
if st.BindingState == "BOUND" {
return nil, errState
}
return newClaim(ctx, s, tx, d, setup.ID, w.UserID, household, subject)
})
}
type CancelInput struct {
ClaimID string `json:"claim_id"`
Generation int64 `json:"expected_binding_generation"`
}
func (s *Service) Cancel(ctx context.Context, w WebSession, requestID string, raw []byte, in CancelInput) (map[string]any, error) {
if !uuidV4(in.ClaimID) || in.Generation < 0 || w.UserID == "" {
return nil, errAccess
}
return s.mutate(ctx, w.Device, "web:"+w.Hash, "cancel", requestID, raw, 24*time.Hour, func(tx pgx.Tx, d Device) (map[string]any, error) {
if err := s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
return nil, err
}
c, err := readClaim(ctx, tx, in.ClaimID, d.ID)
if err != nil {
return nil, err
}
if c.UserID != w.UserID || c.SetupID != w.SetupID {
return nil, errAccess
}
if err = claimPermission(ctx, tx, c); err != nil {
return nil, err
}
if c.Status == "CONFIRMED" && d.Generation == c.Generation+1 {
st, e := s.state(ctx, tx, d)
return map[string]any{"already_bound": true, "state": st}, e
}
if d.Generation != in.Generation {
return nil, errState
}
tag, err := tx.Exec(ctx, `UPDATE device_binding_claim SET status='CANCELLED',completed_at=now() WHERE id=$1::uuid AND status='PENDING'`, c.ID)
if err != nil {
return nil, err
}
if tag.RowsAffected() > 0 {
if err = bump(ctx, tx, d.ID); err != nil {
return nil, err
}
}
st, err := s.state(ctx, tx, d)
return map[string]any{"state": st}, err
})
}
func (s *Service) WebStatus(ctx context.Context, w WebSession) (map[string]any, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
d, err := readDevice(ctx, tx, w.Device.ID, true)
if err != nil {
return nil, err
}
if !deviceAllowed(d) {
return nil, errAccess
}
setup, err := readSetup(ctx, tx, w.SetupID, d, false)
if err != nil {
return nil, err
}
st, err := s.state(ctx, tx, d)
if err != nil {
return nil, err
}
var binding *string
var deviceSaved bool
var mine *string
if w.UserID != "" {
err = tx.QueryRow(ctx, `SELECT b.public_id::text,EXISTS(SELECT 1 FROM device_state_ack a WHERE a.device_id=b.device_id AND a.binding_generation=b.generation AND a.boot_id=$3::uuid) FROM device_binding b JOIN household_member hm ON hm.household_id=b.household_id AND hm.user_id=$2::uuid AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.status='active' WHERE b.device_id=$1::uuid AND b.unbound_at IS NULL`, d.ID, w.UserID, d.BootID).Scan(&binding, &deviceSaved)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
err = tx.QueryRow(ctx, `SELECT id::text FROM device_binding_claim WHERE setup_id=$1::uuid AND user_id=$2::uuid AND status='PENDING' AND expires_at>now()`, w.SetupID, w.UserID).Scan(&mine)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
}
// Never expose a different household's progress through an old handoff.
if st.BindingState == "BOUND" && binding == nil {
return nil, errAccess
}
return map[string]any{"setup_id": w.SetupID, "state": st, "binding_id": binding, "claim_id": mine, "device_saved": deviceSaved, "authenticated": w.UserID != "", "closed": setup.Closed != nil || !setup.Expires.After(s.now()), "sms_enabled": s.options.SMSLoginEnabled, "expires_at_ms": setup.Expires.UnixMilli()}, tx.Commit(ctx)
}
type WebAckInput struct {
BindingID string `json:"binding_id"`
Generation int64 `json:"binding_generation"`
Revision int64 `json:"received_revision"`
}
func (s *Service) WebAck(ctx context.Context, w WebSession, requestID string, raw []byte, in WebAckInput) (map[string]any, error) {
if !uuidValid(in.BindingID) || in.Generation < 1 || in.Revision < 1 || w.UserID == "" {
return nil, errAccess
}
return s.mutate(ctx, w.Device, "web:"+w.Hash, "web-ack", requestID, raw, 24*time.Hour, func(tx pgx.Tx, d Device) (map[string]any, error) {
var bindingID string
err := tx.QueryRow(ctx, `SELECT b.id::text FROM device_binding b JOIN household_member hm ON hm.household_id=b.household_id AND hm.user_id=$3::uuid AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.status='active' JOIN device_state_snapshot ss ON ss.device_id=b.device_id AND ss.revision=$5 AND ss.binding_generation=b.generation AND ss.state->>'binding_state'='BOUND' WHERE b.device_id=$1::uuid AND b.public_id=$2::uuid AND b.unbound_at IS NULL AND b.generation=$4`, d.ID, in.BindingID, w.UserID, in.Generation, in.Revision).Scan(&bindingID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errState
}
if err != nil {
return nil, err
}
if d.Generation != in.Generation {
return nil, errState
}
_, err = tx.Exec(ctx, `UPDATE device_setup_session SET web_ack_binding_id=$2::uuid,web_ack_generation=$3,web_ack_revision=$4,web_ack_at=now() WHERE id=$1::uuid`, w.SetupID, bindingID, in.Generation, in.Revision)
return map[string]any{"accepted": true}, err
})
}
func (s *Service) Profiles(ctx context.Context, w WebSession) (map[string]any, error) {
if w.UserID == "" {
return nil, fail(401, "LOGIN_REQUIRED", "请先登录")
}
rows, err := s.pool.Query(ctx, `SELECT h.public_id::text,h.name,sp.public_id::text,sp.preferred_name FROM household_member hm JOIN user_account ua ON ua.id=hm.user_id AND ua.status='active' JOIN household h ON h.id=hm.household_id AND h.tenant_id=hm.tenant_id AND h.status='active' LEFT JOIN subject_profile sp ON sp.household_id=h.id AND sp.tenant_id=h.tenant_id AND sp.status='active' WHERE hm.user_id=$1::uuid AND hm.tenant_id=$2::uuid AND hm.status='active' AND hm.role IN ('owner','admin','editor') ORDER BY h.name,sp.preferred_name`, w.UserID, w.Device.TenantID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var h, n string
var id, name *string
if err = rows.Scan(&h, &n, &id, &name); err != nil {
return nil, err
}
items = append(items, map[string]any{"household_id": h, "household_name": n, "elder_id": id, "preferred_name": name})
}
return map[string]any{"items": items}, rows.Err()
}
type ProfileInput struct {
HouseholdID string `json:"household_id,omitempty"`
HouseholdName string `json:"household_name,omitempty"`
Name string `json:"preferred_name"`
}
func (s *Service) CreateProfile(ctx context.Context, w WebSession, requestID string, raw []byte, in ProfileInput) (map[string]any, error) {
if w.UserID == "" || !nonempty(in.Name, 120) || (in.HouseholdID == "" && !nonempty(in.HouseholdName, 200)) || (in.HouseholdID != "" && !uuidValid(in.HouseholdID)) {
return nil, fail(400, "VALIDATION_FAILED", "家庭和使用者信息不正确")
}
return s.mutate(ctx, w.Device, "web:"+w.Hash, "profile", requestID, raw, 24*time.Hour, func(tx pgx.Tx, d Device) (map[string]any, error) {
if err := s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
return nil, err
}
var household, public string
var err error
if in.HouseholdID == "" {
err = tx.QueryRow(ctx, `INSERT INTO household(tenant_id,name,owner_user_id) VALUES($1::uuid,$2,$3::uuid) RETURNING id::text,public_id::text`, d.TenantID, in.HouseholdName, w.UserID).Scan(&household, &public)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `INSERT INTO household_member(tenant_id,household_id,user_id,role) VALUES($1::uuid,$2::uuid,$3::uuid,'owner')`, d.TenantID, household, w.UserID)
} else {
err = tx.QueryRow(ctx, `SELECT h.id::text,h.public_id::text FROM household h JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=h.tenant_id WHERE h.public_id=$1::uuid AND h.tenant_id=$2::uuid AND h.status='active' AND hm.user_id=$3::uuid AND hm.status='active' AND hm.role IN ('owner','admin','editor') FOR SHARE OF h,hm`, in.HouseholdID, d.TenantID, w.UserID).Scan(&household, &public)
}
if errors.Is(err, pgx.ErrNoRows) {
return nil, errAccess
}
if err != nil {
return nil, err
}
var id string
err = tx.QueryRow(ctx, `INSERT INTO subject_profile(tenant_id,household_id,preferred_name) VALUES($1::uuid,$2::uuid,$3) RETURNING public_id::text`, d.TenantID, household, in.Name).Scan(&id)
if err != nil {
return nil, err
}
if err = s.auditEvent(ctx, tx, d, "user", accountPublicID(ctx, tx, w.UserID), "onboarding.profile.create", requestID, map[string]any{"subject_id": id}); err != nil {
return nil, err
}
return map[string]any{"elder_id": id, "household_id": public}, nil
})
}
func accountPublicID(ctx context.Context, q queryer, id string) string {
var p string
_ = q.QueryRow(ctx, `SELECT public_id::text FROM user_account WHERE id=$1::uuid`, id).Scan(&p)
return p
}
+19
View File
@@ -15,6 +15,7 @@ import (
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/deviceactivation"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/devicebinding"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
@@ -398,6 +399,9 @@ func (s *Service) IssueDeviceClaim(ctx context.Context, principal identity.Princ
}
func (s *Service) issueDeviceClaimTx(ctx context.Context, tx pgx.Tx, access operatorAccess, devicePublicID, deviceID, requestID string) (DeviceClaimIssue, error) {
if err := devicebinding.RequireLegacy(ctx, tx, deviceID); err != nil {
return DeviceClaimIssue{}, err
}
randomBytes := make([]byte, 10)
if _, err := rand.Read(randomBytes); err != nil {
return DeviceClaimIssue{}, fmt.Errorf("generate device claim: %w", err)
@@ -453,6 +457,11 @@ func (s *Service) Update(ctx context.Context, principal identity.Principal, tena
return Device{}, ErrVersionConflict
}
model, modelSet := pointerValue(input.Model)
if input.Model != nil || input.HardwareRevision != nil || input.VoiceRuntime != nil || (input.HouseholdID != nil && strings.TrimSpace(*input.HouseholdID) != "") {
if err := devicebinding.RequireLegacy(ctx, tx, deviceID); err != nil {
return Device{}, err
}
}
hardware, hardwareSet := pointerValue(input.HardwareRevision)
security, securitySet := pointerValue(input.MinimumSecurityVersion)
firmware, firmwareSet := pointerValue(input.MinimumFirmwareVersion)
@@ -735,6 +744,13 @@ func (s *Service) RotateCredential(ctx context.Context, principal identity.Princ
if err != nil {
return CredentialIssue{}, err
}
var rotationDeviceID string
if err := tx.QueryRow(ctx, `SELECT id::text FROM device WHERE public_id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, devicePublicID, access.tenantID).Scan(&rotationDeviceID); err != nil {
return CredentialIssue{}, ErrNotFound
}
if err := devicebinding.RequireLegacy(ctx, tx, rotationDeviceID); err != nil {
return CredentialIssue{}, err
}
version, err := s.credential.ProvisionTx(ctx, tx, devicePublicID, []byte(secret), true)
if errors.Is(err, deviceauth.ErrDeviceNotFound) {
return CredentialIssue{}, ErrNotFound
@@ -786,6 +802,9 @@ func validateAssignment(ctx context.Context, q queryRow, tenantID, householdPubl
}
func insertBinding(ctx context.Context, tx pgx.Tx, access operatorAccess, deviceID, householdPublicID, subjectPublicID, reason string) error {
if err := devicebinding.RequireLegacy(ctx, tx, deviceID); err != nil {
return err
}
_, err := tx.Exec(ctx, `INSERT INTO device_binding(tenant_id,device_id,household_id,subject_id,bound_by,reason)
SELECT $1::uuid,$2::uuid,h.id,sp.id,$5::uuid,$6 FROM household h JOIN subject_profile sp ON sp.household_id=h.id AND sp.tenant_id=h.tenant_id
WHERE h.public_id=$3::uuid AND sp.public_id=$4::uuid AND h.tenant_id=$1::uuid`,
+22 -5
View File
@@ -32,10 +32,12 @@ func (s *Service) CreateHardwareAgent(ctx context.Context, identity deviceauth.I
request := idempotency.Request{TenantID: &tenantID, ActorKey: "device:" + identity.PublicID,
Scope: "POST /device/v3/agent-sessions", Key: idempotencyKey,
RequestHash: idempotency.Hash(rawRequest), ExpiresAfter: 24 * time.Hour}
if cached, found, err := s.executor.Lookup(ctx, request); err != nil {
return HardwareAgentCreateResponse{}, false, err
} else if found {
return s.finalizeHardwareCreate(cached)
if identity.EnrollmentMode != "physical_confirm" {
if cached, found, err := s.executor.Lookup(ctx, request); err != nil {
return HardwareAgentCreateResponse{}, false, err
} else if found {
return s.finalizeHardwareCreate(cached)
}
}
memoryVector, memoryPolicy, err := s.prepareMemoryQuery(ctx, identity, input.RequestedGoal)
if err != nil {
@@ -66,7 +68,7 @@ func (s *Service) CreateHardwareAgent(ctx context.Context, identity deviceauth.I
}
}
}
result, err := s.executor.Execute(ctx, request, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
result, err := s.executor.ExecuteGuarded(ctx, request, onboardingCreateGuard(identity, input), func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
now := s.now().UTC()
created, internalID, createErr := s.repository.Create(operationContext, tx, identity, CreateInput{
DeviceTime: input.DeviceTime, MonotonicMS: input.MonotonicMS, Network: input.Network,
@@ -86,9 +88,18 @@ func (s *Service) CreateHardwareAgent(ctx context.Context, identity deviceauth.I
if createErr != nil {
return idempotency.Result{}, createErr
}
if err := attachOnboardingConversation(operationContext, tx, identity, input, internalID); err != nil {
return idempotency.Result{}, err
}
if created.Hardware == nil {
return idempotency.Result{}, errors.New("hardware agent runtime was not allocated")
}
if input.Intent == "activation_test" {
created.Hardware.Session.MaximumDurationSeconds = 60
if created.Hardware.Session.AllocationExpiresAt.After(now.Add(time.Minute)) {
created.Hardware.Session.AllocationExpiresAt = now.Add(time.Minute)
}
}
body, marshalErr := json.Marshal(created.Hardware)
if marshalErr != nil {
return idempotency.Result{}, marshalErr
@@ -144,6 +155,9 @@ func (s *Service) ProcessHardwareAgentEvent(ctx context.Context, identity device
return HardwareAgentEventResponse{}, err
}
defer tx.Rollback(context.Background())
if err := guardOnboardingEvent(ctx, tx, identity, conversationPublicID, input); err != nil {
return HardwareAgentEventResponse{}, err
}
now := s.now().UTC()
var internalID, tenantID, providerSessionRef, nonceDigest, expectedBotID, expectedTransport, expectedConfigDigest string
var lastEventSequence int64
@@ -248,6 +262,9 @@ func (s *Service) ProcessHardwareAgentEvent(ctx context.Context, identity device
currentStatus = conversation.StatusFailed
} else {
_, err = tx.Exec(ctx, `UPDATE hardware_agent_session SET last_event_at=$2,last_event_sequence=GREATEST(last_event_sequence,$3),updated_at=$2 WHERE conversation_id=$1::uuid`, internalID, now, input.SequenceNo)
if input.EventType == "first_audio_received" && currentStatus != conversation.StatusActive {
return HardwareAgentEventResponse{}, ErrConversationStateConflict
}
if err == nil && currentStatus == conversation.StatusActive {
leaseExpires := now.Add(time.Duration(s.options.HeartbeatInterval*5) * time.Second)
_, err = tx.Exec(ctx, `UPDATE conversation SET lease_expires_at=$2,updated_at=$3,last_heartbeat_seq=GREATEST(last_heartbeat_seq,$4) WHERE id=$1::uuid AND status='ACTIVE'`, internalID, leaseExpires, now, input.SequenceNo)
+135
View File
@@ -0,0 +1,135 @@
package orchestration
import (
"context"
"crypto/sha256"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/idempotency"
"encoding/hex"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"time"
)
var ErrOnboardingState = errors.New("onboarding state or test permit is no longer valid")
func onboardingCreateGuard(identity deviceauth.Identity, input HardwareAgentCreateInput) idempotency.Guard {
return func(ctx context.Context, tx pgx.Tx, cached *idempotency.Result) error {
var mode, status, lifecycle string
var generation int64
err := tx.QueryRow(ctx, `SELECT enrollment_mode,status,activation_state,binding_generation FROM device WHERE id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, identity.ID, identity.TenantID).Scan(&mode, &status, &lifecycle, &generation)
if err != nil {
return err
}
if mode == "legacy" {
if input.Intent != "" || input.ActivationID != "" || input.AttemptID != "" || input.PermitToken != "" {
return ErrOnboardingState
}
return nil
}
if identity.EnrollmentMode != "physical_confirm" || generation != identity.BindingGeneration || status != "registered" || lifecycle == "REVOKED" || lifecycle == "LOST" {
return ErrOnboardingState
}
var allowed bool
err = tx.QueryRow(ctx, `SELECT true FROM device_binding b JOIN household h ON h.id=b.household_id AND h.status='active' JOIN subject_profile sp ON sp.id=b.subject_id AND sp.status='active' JOIN household_member hm ON hm.household_id=h.id AND hm.user_id=b.bound_by AND hm.status='active' AND hm.role IN ('owner','admin','editor') JOIN user_account ua ON ua.id=hm.user_id AND ua.status='active' WHERE b.device_id=$1::uuid AND b.unbound_at IS NULL AND b.generation=$2 FOR SHARE OF b,h,sp,hm,ua`, identity.ID, generation).Scan(&allowed)
if errors.Is(err, pgx.ErrNoRows) {
return ErrOnboardingState
}
if err != nil {
return err
}
if input.Intent == "" {
if input.ActivationID != "" || input.AttemptID != "" || input.PermitToken != "" {
return ErrOnboardingState
}
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_activation_job WHERE device_id=$1::uuid AND binding_generation=$2 AND status='READY' AND config_ack_at IS NOT NULL AND config_expires_at>now())`, identity.ID, generation).Scan(&allowed)
} else {
if input.Intent != "activation_test" {
return ErrOnboardingState
}
if _, e := uuid.Parse(input.ActivationID); e != nil {
return ErrOnboardingState
}
if _, e := uuid.Parse(input.AttemptID); e != nil {
return ErrOnboardingState
}
hash := sha256.Sum256([]byte(input.PermitToken))
var attached *string
err = tx.QueryRow(ctx, `SELECT a.conversation_id::text FROM device_activation_job j JOIN device_activation_attempt a ON a.activation_id=j.id WHERE j.id=$1::uuid AND j.device_id=$2::uuid AND a.id=$3::uuid AND j.binding_generation=$4 AND a.binding_generation=$4 AND a.config_version=j.config_version AND j.status IN ('REGISTERED_OBSERVED','CONTACT_POSSIBLE','READY') AND j.config_ack_at IS NOT NULL AND j.engine_ready_at IS NOT NULL AND j.config_expires_at>now() AND a.stage='CONVERSATION_TEST' AND a.executor='DEVICE' AND a.status='CONTACT_POSSIBLE' AND (a.permit_hash IS NULL OR a.permit_hash=$5) AND (a.permit_expires_at>now() OR a.conversation_id IS NOT NULL) FOR UPDATE OF j,a`, input.ActivationID, identity.ID, input.AttemptID, generation, hex.EncodeToString(hash[:])).Scan(&attached)
if errors.Is(err, pgx.ErrNoRows) {
return ErrOnboardingState
}
if err != nil {
return err
}
allowed = true
if cached != nil && (cached.ResourceID == nil || attached == nil || *cached.ResourceID != *attached) {
return ErrOnboardingState
}
}
if err != nil {
return err
}
if !allowed {
return ErrOnboardingState
}
if cached != nil {
if cached.ResourceID == nil {
return ErrOnboardingState
}
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM conversation WHERE id=$1::uuid AND device_id=$2::uuid AND onboarding_generation=$3 AND status IN ('ROOM_ISSUED','ACTIVE') AND (activation_test_deadline IS NULL OR activation_test_deadline>now()))`, *cached.ResourceID, identity.ID, generation).Scan(&allowed)
if err != nil {
return err
}
if !allowed {
return ErrOnboardingState
}
}
return nil
}
}
func attachOnboardingConversation(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, input HardwareAgentCreateInput, conversation string) error {
if identity.EnrollmentMode != "physical_confirm" {
return nil
}
if input.Intent == "activation_test" {
tag, err := tx.Exec(ctx, `UPDATE device_activation_attempt SET conversation_id=$2::uuid WHERE id=$1::uuid AND conversation_id IS NULL AND permit_expires_at>now() AND status='CONTACT_POSSIBLE'`, input.AttemptID, conversation)
if err != nil {
return err
}
if tag.RowsAffected() != 1 {
return ErrOnboardingState
}
_, err = tx.Exec(ctx, `UPDATE conversation SET onboarding_generation=$2,activation_attempt_id=$3::uuid,activation_test_deadline=now()+interval '60 seconds',allocation_expires_at=LEAST(allocation_expires_at,now()+interval '60 seconds') WHERE id=$1::uuid`, conversation, identity.BindingGeneration, input.AttemptID)
return err
}
_, err := tx.Exec(ctx, `UPDATE conversation SET onboarding_generation=$2 WHERE id=$1::uuid`, conversation, identity.BindingGeneration)
return err
}
func guardOnboardingEvent(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, conversation string, event HardwareAgentEventInput) error {
if identity.EnrollmentMode != "physical_confirm" {
return nil
}
var generation int64
var status string
if err := tx.QueryRow(ctx, `SELECT binding_generation,status FROM device WHERE id=$1::uuid FOR UPDATE`, identity.ID).Scan(&generation, &status); err != nil {
return err
}
if generation != identity.BindingGeneration || status != "registered" {
return ErrOnboardingState
}
var stored *int64
var deadline *time.Time
err := tx.QueryRow(ctx, `SELECT onboarding_generation,activation_test_deadline FROM conversation WHERE public_id=$1::uuid AND device_id=$2::uuid`, conversation, identity.ID).Scan(&stored, &deadline)
if err != nil {
return err
}
if stored == nil || *stored != generation {
return ErrOnboardingState
}
if deadline != nil && deadline.Before(time.Now()) && event.EventType != "session_ended" && event.EventType != "session_failed" {
return ErrOnboardingState
}
return nil
}
+1
View File
@@ -88,6 +88,7 @@ func (s *RecoveryScanner) ScanOnce(ctx context.Context) (int, error) {
LEFT JOIN conversation_usage_budget cub ON cub.conversation_id=c.id AND cub.tenant_id=c.tenant_id
WHERE (c.status='ROOM_ISSUED' AND c.allocation_expires_at <= $1)
OR (c.status='ACTIVE' AND c.lease_expires_at IS NOT NULL AND c.lease_expires_at <= $1)
OR (c.status='ACTIVE' AND c.activation_test_deadline IS NOT NULL AND c.activation_test_deadline <= $1)
OR (c.status='ACTIVE' AND cub.in_progress_action='STOP_IMMEDIATELY' AND c.started_at IS NOT NULL AND c.started_at+make_interval(secs=>cub.max_call_duration_sec)<=$1)
OR (c.status='STARTING_AGENT' AND c.updated_at <= $2)
OR (c.status='STOPPING' AND c.updated_at <= $3)
+5
View File
@@ -57,6 +57,7 @@ type Privacy struct {
}
type Session struct {
MaximumDurationSeconds int `json:"maximum_duration_seconds,omitempty"`
EngineMode string `json:"engine_mode"`
HeartbeatIntervalSecond int `json:"heartbeat_interval_sec"`
AllocationExpiresAt time.Time `json:"allocation_expires_at"`
@@ -93,6 +94,10 @@ type HardwareAgentCapabilities struct {
}
type HardwareAgentCreateInput struct {
Intent string `json:"intent,omitempty"`
ActivationID string `json:"activation_id,omitempty"`
AttemptID string `json:"attempt_id,omitempty"`
PermitToken string `json:"permit_token,omitempty"`
DeviceTime time.Time `json:"device_time"`
MonotonicMS *int64 `json:"monotonic_ms,omitempty"`
Capabilities HardwareAgentCapabilities `json:"capabilities"`
+1 -1
View File
@@ -1640,7 +1640,7 @@ func collectBaseline(root string) ([]bundleFile, int, []string, error) {
"docs/alert-operations.md", "docs/integration-diagnostics.md", "docs/content-safety-provider.md", "docs/remote-ci-supply-chain-evidence.md", "docs/real-device-rtc-evidence.md", "docs/real-agent-models-evidence.md", "docs/production-infrastructure-evidence.md", "docs/safari-compatibility-evidence.md", "docs/pilot-daily-operations.md", "docs/pilot-weekly-review.md",
}
paths := slices.Clone(required)
for _, directory := range []string{"migrations", "deploy/kubernetes"} {
for _, directory := range []string{"openapi", "migrations", "deploy/kubernetes"} {
err := filepath.WalkDir(filepath.Join(root, directory), func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
+2 -2
View File
@@ -47,7 +47,7 @@ func TestBuildCreatesHashedBundleAndPreservesPendingGates(t *testing.T) {
for _, entry := range reader.File {
entries[entry.Name] = entry
}
for _, required := range []string{"release-manifest.json", "evidence-index.json", "acceptance/technical-acceptance-snapshot.json", "baseline/openapi/openapi.yaml", "evidence/REMOTE_CI_SUPPLY_CHAIN/01-ci-report.json"} {
for _, required := range []string{"release-manifest.json", "evidence-index.json", "acceptance/technical-acceptance-snapshot.json", "baseline/openapi/openapi.yaml", "baseline/openapi/onboarding.yaml", "evidence/REMOTE_CI_SUPPLY_CHAIN/01-ci-report.json"} {
if entries[required] == nil {
t.Fatalf("bundle entry missing: %s", required)
}
@@ -442,7 +442,7 @@ func releaseFixture(t *testing.T, gateSatisfied bool) (string, string, string, s
t.Helper()
root := t.TempDir()
files := []string{
"openapi/openapi.yaml", "compose.yaml", ".github/workflows/ci.yml", "go.mod", "go.sum", "web/package.json", "web/package-lock.json",
"openapi/openapi.yaml", "openapi/onboarding.yaml", "compose.yaml", ".github/workflows/ci.yml", "go.mod", "go.sum", "web/package.json", "web/package-lock.json",
"docs/development-plan.md", "docs/development-status.md", "docs/requirements-traceability.md", "docs/external-acceptance-checklist.md",
"docs/kubernetes-deployment.md", "docs/alert-operations.md", "docs/integration-diagnostics.md", "migrations/000001.up.sql", "deploy/kubernetes/base/api.yaml",
"docs/content-safety-provider.md", "docs/remote-ci-supply-chain-evidence.md", "docs/real-device-rtc-evidence.md", "docs/real-agent-models-evidence.md", "docs/production-infrastructure-evidence.md", "docs/safari-compatibility-evidence.md", "docs/pilot-daily-operations.md", "docs/pilot-weekly-review.md",
+6
View File
@@ -10,6 +10,7 @@ import (
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/deviceactivation"
"data-terminal-cloud/internal/devicebinding"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
@@ -72,6 +73,11 @@ func (s *Service) ChangeDeviceBinding(ctx context.Context, principal identity.Pr
return err
}
var bindingID string
if input.Action == DeviceBindingActionTransfer {
if err = devicebinding.RequireLegacy(ctx, tx, access.deviceID); err != nil {
return err
}
}
if err := tx.QueryRow(ctx, `SELECT public_id::text FROM device_binding WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND unbound_at IS NULL FOR UPDATE`, access.deviceID, access.tenantID).Scan(&bindingID); errors.Is(err, pgx.ErrNoRows) {
return ErrDeviceBindingStale
} else if err != nil {
+8 -1
View File
@@ -11,6 +11,7 @@ import (
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/deviceactivation"
"data-terminal-cloud/internal/devicebinding"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
@@ -57,6 +58,9 @@ func (s *Service) ClaimDevice(ctx context.Context, principal identity.Principal,
} else if err != nil {
return fmt.Errorf("resolve device claim: %w", err)
}
if err = devicebinding.RequireLegacy(ctx, tx, deviceID); err != nil {
return err
}
if revokedAt != nil || !expiresAt.After(time.Now()) {
return ErrDeviceClaimInvalid
}
@@ -70,7 +74,10 @@ func (s *Service) ClaimDevice(ctx context.Context, principal identity.Principal,
JOIN device d ON d.id=b.device_id AND d.tenant_id=b.tenant_id
JOIN household h ON h.id=b.household_id AND h.tenant_id=b.tenant_id
JOIN subject_profile sp ON sp.id=b.subject_id AND sp.tenant_id=b.tenant_id
WHERE b.id=$1::uuid`, *usedBindingID).Scan(&result.BindingID, &result.DeviceID, &result.HouseholdID, &result.SubjectID, &result.BoundAt); err != nil {
WHERE b.id=$1::uuid AND b.unbound_at IS NULL AND b.generation=d.binding_generation`, *usedBindingID).Scan(&result.BindingID, &result.DeviceID, &result.HouseholdID, &result.SubjectID, &result.BoundAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrDeviceClaimUsed
}
return fmt.Errorf("read used device claim binding: %w", err)
}
if result.HouseholdID != householdPublicID || result.SubjectID != subjectPublicID {
@@ -364,6 +364,7 @@ func TestDeviceClaimIsOneTimeAndRetrySafe(t *testing.T) {
if bindingID != "" {
_, _ = pool.Exec(ctx, `DELETE FROM device_binding WHERE public_id=$1::uuid`, bindingID)
}
_, _ = pool.Exec(ctx,`UPDATE device SET activation_state='CLAIMABLE' WHERE public_id='10000000-0000-0000-0000-000000000512'::uuid AND NOT EXISTS(SELECT 1 FROM device_binding WHERE device_id=device.id AND unbound_at IS NULL)`)
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id='saas-integration-device-claim'`)
}()
if _, _, err := service.ClaimDevice(ctx, outsider, claimCode, householdID, subjectID, "saas-integration-outsider-claim"); !errors.Is(err, ErrAccessDenied) {
@@ -0,0 +1,3 @@
-- New onboarding records are durable ownership and external-call evidence.
-- Do not silently destroy them during an application rollback.
DO $$ BEGIN RAISE EXCEPTION 'Migration 53 is forward-only; roll back the application to the compatibility release and retain onboarding records'; END $$;
+259
View File
@@ -0,0 +1,259 @@
BEGIN;
ALTER TABLE device
ADD COLUMN enrollment_mode text NOT NULL DEFAULT 'legacy' CHECK (enrollment_mode IN ('legacy','physical_confirm')),
ADD COLUMN factory_device_id varchar(32) UNIQUE,
ADD COLUMN factory_mac char(12) UNIQUE,
ADD COLUMN binding_generation bigint NOT NULL DEFAULT 0 CHECK (binding_generation >= 0),
ADD COLUMN onboarding_revision bigint NOT NULL DEFAULT 1 CHECK (onboarding_revision > 0),
ADD COLUMN onboarding_released_at timestamptz,
ADD COLUMN onboarding_boot_id uuid,
ADD COLUMN onboarding_last_seen_at timestamptz;
ALTER TABLE device_binding ADD COLUMN generation bigint;
WITH history AS (
SELECT id, 2 * row_number() OVER (PARTITION BY device_id ORDER BY bound_at,id) - 1 AS generation
FROM device_binding
) UPDATE device_binding b SET generation=h.generation FROM history h WHERE b.id=h.id;
UPDATE device d SET binding_generation=COALESCE((SELECT max(b.generation + CASE WHEN b.unbound_at IS NULL THEN 0 ELSE 1 END) FROM device_binding b WHERE b.device_id=d.id),0);
CREATE UNIQUE INDEX device_binding_generation_key ON device_binding(device_id,generation);
CREATE TABLE device_onboarding_key (
device_id uuid NOT NULL,
tenant_id uuid NOT NULL,
key_version bigint NOT NULL CHECK (key_version > 0),
ciphertext bytea NOT NULL,
status text NOT NULL CHECK (status IN ('PENDING','ACTIVE','REVOKED')),
created_at timestamptz NOT NULL DEFAULT now(),
activated_at timestamptz,
PRIMARY KEY(device_id,key_version),
FOREIGN KEY(device_id,tenant_id) REFERENCES device(id,tenant_id)
);
CREATE UNIQUE INDEX device_onboarding_one_active_key ON device_onboarding_key(device_id) WHERE status='ACTIVE';
CREATE TABLE device_enrollment_job (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
device_id uuid NOT NULL,
actor_id uuid NOT NULL REFERENCES user_account(id),
request_key varchar(160) NOT NULL,
request_digest char(64) NOT NULL,
batch_no varchar(120) NOT NULL,
key_version bigint NOT NULL DEFAULT 1,
status text NOT NULL DEFAULT 'PENDING' CHECK(status IN ('PENDING','DELIVERED','RELEASED','CANCELLED')),
receiver_key text,
delivery_ciphertext bytea,
delivery_expires_at timestamptz,
evidence_ref text,
created_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY(device_id,tenant_id) REFERENCES device(id,tenant_id),
UNIQUE(tenant_id,actor_id,request_key)
);
CREATE TABLE device_time_challenge (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
device_id uuid NOT NULL REFERENCES device(id),
key_version bigint NOT NULL,
boot_id uuid NOT NULL,
client_nonce varchar(32) NOT NULL,
server_time_ms bigint NOT NULL,
expires_at timestamptz NOT NULL,
consumed_by uuid,
UNIQUE(device_id,key_version,boot_id,client_nonce)
);
CREATE TABLE device_setup_session (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
device_id uuid NOT NULL,
tenant_id uuid NOT NULL,
boot_id uuid NOT NULL,
local_setup_id uuid NOT NULL,
binding_generation bigint NOT NULL,
mode text NOT NULL CHECK(mode IN ('ENROLL','RECOVERY')),
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
closed_at timestamptz,
close_reason text,
web_ack_binding_id uuid,
web_ack_generation bigint,
web_ack_revision bigint,
web_ack_at timestamptz,
FOREIGN KEY(device_id,tenant_id) REFERENCES device(id,tenant_id)
);
CREATE INDEX device_setup_device_idx ON device_setup_session(device_id,created_at DESC);
CREATE TABLE device_handoff (
token_hash char(64) PRIMARY KEY,
setup_id uuid NOT NULL REFERENCES device_setup_session(id),
code_challenge varchar(64) NOT NULL,
expires_at timestamptz NOT NULL,
consumed_at timestamptz
);
CREATE TABLE onboarding_web_session (
token_hash char(64) PRIMARY KEY,
setup_id uuid NOT NULL REFERENCES device_setup_session(id),
csrf_hash char(64) NOT NULL,
user_id uuid REFERENCES user_account(id),
issuer text,
subject text,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
revoked_at timestamptz
);
CREATE TABLE device_binding_claim (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
device_id uuid NOT NULL,
tenant_id uuid NOT NULL,
setup_id uuid NOT NULL REFERENCES device_setup_session(id),
user_id uuid NOT NULL REFERENCES user_account(id),
household_id uuid NOT NULL,
subject_id uuid NOT NULL,
expected_generation bigint NOT NULL,
challenge_hash char(64) NOT NULL,
challenge_ciphertext bytea NOT NULL,
binding_digest char(64) NOT NULL,
status text NOT NULL DEFAULT 'PENDING' CHECK(status IN ('PENDING','CONFIRMED','CANCELLED','EXPIRED')),
expires_at timestamptz NOT NULL,
press_event_id uuid,
binding_id uuid REFERENCES device_binding(id),
created_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz,
FOREIGN KEY(device_id,tenant_id) REFERENCES device(id,tenant_id),
FOREIGN KEY(household_id,tenant_id) REFERENCES household(id,tenant_id),
FOREIGN KEY(subject_id,tenant_id) REFERENCES subject_profile(id,tenant_id),
UNIQUE(device_id,press_event_id)
);
CREATE UNIQUE INDEX device_binding_claim_one_pending ON device_binding_claim(device_id) WHERE status='PENDING';
CREATE TABLE device_onboarding_request (
actor_key text NOT NULL,
scope text NOT NULL,
request_id uuid NOT NULL,
request_digest char(64) NOT NULL,
device_id uuid NOT NULL REFERENCES device(id),
binding_generation bigint NOT NULL,
response_ciphertext bytea NOT NULL,
expires_at timestamptz NOT NULL,
PRIMARY KEY(actor_key,scope,request_id)
);
CREATE TABLE device_state_snapshot (
device_id uuid NOT NULL REFERENCES device(id),
revision bigint NOT NULL,
binding_generation bigint NOT NULL,
state_hash char(64) NOT NULL,
state jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY(device_id,revision)
);
CREATE TABLE device_state_ack (
device_id uuid PRIMARY KEY REFERENCES device(id),
revision bigint NOT NULL,
binding_generation bigint NOT NULL,
state_hash char(64) NOT NULL,
boot_id uuid NOT NULL,
local_generation bigint NOT NULL CHECK(local_generation >= 0),
accepted_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE device_activation_job (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
device_id uuid NOT NULL UNIQUE,
tenant_id uuid NOT NULL,
provider_device_name varchar(128) NOT NULL UNIQUE,
status text NOT NULL DEFAULT 'NOT_STARTED' CHECK(status IN ('NOT_STARTED','RESERVED','CONTACT_POSSIBLE','REGISTERED_OBSERVED','READY','RECONCILE_REQUIRED','BLOCKED')),
binding_generation bigint NOT NULL,
config_version bigint NOT NULL DEFAULT 1,
config_ciphertext bytea,
config_digest char(64),
config_expires_at timestamptz,
config_ack_at timestamptz,
engine_ready_at timestamptz,
current_attempt_id uuid,
last_error_code text,
reconciliation_evidence text,
version bigint NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY(device_id,tenant_id) REFERENCES device(id,tenant_id)
);
CREATE TABLE device_activation_attempt (
id uuid PRIMARY KEY,
activation_id uuid NOT NULL REFERENCES device_activation_job(id),
executor text NOT NULL CHECK(executor IN ('SERVER','DEVICE')),
stage text NOT NULL CHECK(stage IN ('SERVER_REGISTER','ENGINE_INIT','CONVERSATION_TEST')),
binding_generation bigint NOT NULL,
config_version bigint NOT NULL,
status text NOT NULL CHECK(status IN ('CONTACT_POSSIBLE','SUCCESS_OBSERVED','DEFINITE_FAILURE','UNKNOWN')),
permit_expires_at timestamptz NOT NULL,
permit_hash char(64),
conversation_id uuid,
sdk_result integer,
evidence jsonb,
result_digest char(64),
created_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz
);
CREATE TABLE onboarding_sms_challenge (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
session_hash char(64) NOT NULL REFERENCES onboarding_web_session(token_hash),
phone_digest char(64) NOT NULL,
phone_ciphertext bytea NOT NULL,
code_hash char(64) NOT NULL,
attempts integer NOT NULL DEFAULT 0,
status text NOT NULL DEFAULT 'SENDING' CHECK(status IN ('SENDING','SENT','FAILED','CONSUMED')),
request_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
consumed_at timestamptz,
UNIQUE(session_hash,request_id)
);
CREATE INDEX onboarding_sms_phone_time ON onboarding_sms_challenge(phone_digest,created_at DESC);
-- Every binding writer, including privacy deletion, advances the same generation.
-- Inserting ownership for a new-protocol device additionally needs a pending claim.
CREATE FUNCTION maintain_device_binding_generation() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE d device%ROWTYPE; claim_setting text; is_changed boolean;
BEGIN
SELECT * INTO d FROM device WHERE id=COALESCE(NEW.device_id,OLD.device_id) FOR UPDATE;
IF TG_OP='INSERT' THEN
IF d.enrollment_mode='physical_confirm' THEN
claim_setting := current_setting('app.onboarding_claim',true);
IF NOT EXISTS(SELECT 1 FROM device_binding_claim c WHERE c.id::text=claim_setting
AND c.device_id=NEW.device_id AND c.status='PENDING' AND c.expires_at>now()
AND c.expected_generation=d.binding_generation AND c.user_id=NEW.bound_by
AND c.household_id=NEW.household_id AND c.subject_id=NEW.subject_id) THEN
RAISE EXCEPTION 'DEVICE_CONFIRMATION_REQUIRED' USING ERRCODE='P0001';
END IF;
END IF;
NEW.generation := d.binding_generation+1;
is_changed := true;
ELSE
IF NEW.device_id<>OLD.device_id OR NEW.household_id<>OLD.household_id OR NEW.subject_id<>OLD.subject_id OR NEW.generation IS DISTINCT FROM OLD.generation THEN
RAISE EXCEPTION 'binding identity is immutable' USING ERRCODE='P0001';
END IF;
IF OLD.unbound_at IS NOT NULL AND NEW.unbound_at IS NULL THEN
RAISE EXCEPTION 'closed binding cannot be restored' USING ERRCODE='P0001';
END IF;
is_changed := OLD.unbound_at IS NULL AND NEW.unbound_at IS NOT NULL;
END IF;
IF is_changed THEN
UPDATE device SET binding_generation=binding_generation+1,onboarding_revision=onboarding_revision+1 WHERE id=d.id;
UPDATE device_binding_claim SET status='CANCELLED',completed_at=now() WHERE device_id=d.id AND status='PENDING'
AND id::text IS DISTINCT FROM current_setting('app.onboarding_claim',true);
IF TG_OP='UPDATE' THEN
UPDATE device_setup_session SET closed_at=COALESCE(closed_at,now()),close_reason='BINDING_CHANGED' WHERE device_id=d.id;
UPDATE onboarding_web_session SET revoked_at=COALESCE(revoked_at,now()) WHERE setup_id IN (SELECT id FROM device_setup_session WHERE device_id=d.id);
UPDATE device_activation_job SET config_ack_at=NULL,engine_ready_at=NULL,config_ciphertext=NULL,config_digest=NULL,
status=CASE WHEN status IN ('CONTACT_POSSIBLE','RECONCILE_REQUIRED') THEN 'RECONCILE_REQUIRED' ELSE 'BLOCKED' END,
last_error_code='BINDING_CHANGED',version=version+1,updated_at=now() WHERE device_id=d.id;
END IF;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER device_binding_generation_guard BEFORE INSERT OR UPDATE ON device_binding FOR EACH ROW EXECUTE FUNCTION maintain_device_binding_generation();
COMMIT;
@@ -0,0 +1 @@
DO $$ BEGIN RAISE EXCEPTION 'Onboarding sessions and provider evidence require a reviewed forward migration'; END $$;
@@ -0,0 +1,21 @@
BEGIN;
CREATE TABLE onboarding_account_session (
token_hash char(64) PRIMARY KEY,
csrf_hash char(64) NOT NULL,
user_id uuid NOT NULL REFERENCES user_account(id),
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
revoked_at timestamptz
);
ALTER TABLE device_activation_job
ADD COLUMN provider_instance_id varchar(200),
ADD COLUMN provider_product_key varchar(200),
ADD COLUMN provider_secret_ciphertext bytea,
ADD COLUMN provider_rtc_app_id varchar(200),
ADD COLUMN provider_bot_id varchar(200),
ADD COLUMN provider_transport varchar(32),
ADD COLUMN provider_minimum_sdk varchar(80);
ALTER TABLE device_activation_attempt ADD COLUMN permit_ciphertext bytea;
CREATE UNIQUE INDEX onboarding_one_device_attempt ON device_activation_attempt(activation_id)
WHERE executor='DEVICE' AND status IN ('CONTACT_POSSIBLE','UNKNOWN');
COMMIT;
@@ -0,0 +1 @@
DO $$ BEGIN RAISE EXCEPTION 'Onboarding identity fences must remain in place; use a reviewed forward migration'; END $$;
@@ -0,0 +1,43 @@
BEGIN;
ALTER TABLE device_activation_job ADD COLUMN config_local_generation bigint, ADD COLUMN config_ack_boot_id uuid;
ALTER TABLE device_binding_claim ADD CONSTRAINT onboarding_claim_user_tenant_fk FOREIGN KEY(user_id,tenant_id) REFERENCES user_account(id,tenant_id);
CREATE FUNCTION project_onboarding_unbind() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE d device%ROWTYPE;
BEGIN
IF OLD.unbound_at IS NULL AND NEW.unbound_at IS NOT NULL THEN
SELECT * INTO d FROM device WHERE id=NEW.device_id FOR UPDATE;
IF d.enrollment_mode='physical_confirm' THEN
UPDATE hardware_agent_provider_credential SET status='SUSPENDED',updated_at=now() WHERE device_id=d.id AND status='ACTIVE';
UPDATE hardware_agent_device_binding SET status='DISABLED',updated_at=now() WHERE device_id=d.id AND status='ACTIVE';
UPDATE device_activation_job SET config_local_generation=NULL,config_ack_boot_id=NULL WHERE device_id=d.id;
IF d.activation_state NOT IN ('FACTORY','CLAIMABLE','LOST','REVOKED') THEN
UPDATE device SET activation_state='CLAIMABLE',activation_revision=activation_revision+1,activation_updated_at=now(),activation_reason='binding-closed',version=version+1,updated_at=now() WHERE id=d.id;
INSERT INTO device_activation_transition(tenant_id,device_id,from_state,to_state,reason,source,actor_type,activation_revision)
VALUES(d.tenant_id,d.id,d.activation_state,'CLAIMABLE','归属已由授权业务关闭','binding-generation-trigger','system',d.activation_revision+1);
END IF;
END IF;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER onboarding_unbind_projection AFTER UPDATE ON device_binding FOR EACH ROW EXECUTE FUNCTION project_onboarding_unbind();
CREATE FUNCTION guard_onboarding_identity() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF OLD.enrollment_mode='physical_confirm' THEN
IF NEW.enrollment_mode<>OLD.enrollment_mode OR NEW.factory_device_id IS DISTINCT FROM OLD.factory_device_id OR NEW.factory_mac IS DISTINCT FROM OLD.factory_mac THEN
RAISE EXCEPTION 'ONBOARDING_IDENTITY_IMMUTABLE' USING ERRCODE='P0001';
END IF;
IF NEW.status<>OLD.status OR NEW.activation_state IN ('LOST','REVOKED') AND NEW.activation_state<>OLD.activation_state THEN
NEW.onboarding_revision:=NEW.onboarding_revision+1;
IF NEW.status<>'registered' OR NEW.activation_state IN ('LOST','REVOKED') THEN
UPDATE device_binding_claim SET status='CANCELLED',completed_at=now() WHERE device_id=OLD.id AND status='PENDING';
UPDATE device_setup_session SET closed_at=COALESCE(closed_at,now()),close_reason='DEVICE_DISABLED' WHERE device_id=OLD.id;
UPDATE onboarding_web_session SET revoked_at=COALESCE(revoked_at,now()) WHERE setup_id IN (SELECT id FROM device_setup_session WHERE device_id=OLD.id);
END IF;
END IF;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER onboarding_identity_guard BEFORE UPDATE ON device FOR EACH ROW EXECUTE FUNCTION guard_onboarding_identity();
COMMIT;
@@ -0,0 +1 @@
DO $$ BEGIN RAISE EXCEPTION 'Runtime generation and test evidence must be retained'; END $$;
@@ -0,0 +1,4 @@
BEGIN;
ALTER TABLE conversation ADD COLUMN onboarding_generation bigint, ADD COLUMN activation_attempt_id uuid REFERENCES device_activation_attempt(id), ADD COLUMN activation_test_deadline timestamptz;
CREATE UNIQUE INDEX conversation_activation_attempt_unique ON conversation(activation_attempt_id) WHERE activation_attempt_id IS NOT NULL;
COMMIT;
@@ -0,0 +1 @@
DO $$ BEGIN RAISE EXCEPTION 'Retain readiness evidence with a forward migration'; END $$;
@@ -0,0 +1,3 @@
BEGIN;
ALTER TABLE device_activation_job ADD COLUMN ready_binding_generation bigint;
COMMIT;
@@ -0,0 +1,11 @@
BEGIN;
DO $$ BEGIN
IF EXISTS(SELECT 1 FROM device_activation_attempt WHERE stage='SDK_REGISTER') THEN
RAISE EXCEPTION 'SDK_REGISTER history exists; use application rollback without removing migration 58';
END IF;
END $$;
ALTER TABLE device_activation_job DROP COLUMN sdk_registered_at;
ALTER TABLE device_activation_attempt DROP CONSTRAINT device_activation_attempt_stage_check;
ALTER TABLE device_activation_attempt ADD CONSTRAINT device_activation_attempt_stage_check
CHECK(stage IN ('SERVER_REGISTER','ENGINE_INIT','CONVERSATION_TEST'));
COMMIT;
@@ -0,0 +1,13 @@
BEGIN;
ALTER TABLE device_activation_attempt DROP CONSTRAINT device_activation_attempt_stage_check;
ALTER TABLE device_activation_attempt ADD CONSTRAINT device_activation_attempt_stage_check
CHECK(stage IN ('SERVER_REGISTER','ENGINE_INIT','SDK_REGISTER','CONVERSATION_TEST'));
-- Registration is a physical-device fact and survives changes of owner.
ALTER TABLE device_activation_job ADD COLUMN sdk_registered_at timestamptz;
UPDATE device_activation_job SET sdk_registered_at=updated_at WHERE provider_secret_ciphertext IS NOT NULL;
-- Old config packets use a different contract. Never replay them as 1.1.
UPDATE device_activation_job SET config_ciphertext=NULL,config_digest=NULL,config_expires_at=NULL,
config_ack_at=NULL,config_version=config_version+1,version=version+1,
status=CASE WHEN status='READY' THEN 'REGISTERED_OBSERVED' ELSE status END;
DELETE FROM device_onboarding_request WHERE actor_key LIKE 'device:%';
COMMIT;
File diff suppressed because it is too large Load Diff
+68 -3
View File
@@ -1,12 +1,72 @@
openapi: 3.1.0
info:
title: 数据采集终端—云端系统 API
version: 0.77.0
version: 0.78.0
description: 工程框架、SaaS核心业务、设备v2 RTC/VoiceChat兼容链路、设备v3火山硬件对话智能体控制面、每次会话人设与授权记忆冻结、SDK和音频能力门禁、动态配置生效证明、可靠设备事件和终稿补传、通话中受控记忆工具、首次通话就绪与最低固件门禁、背景资料准备失败闭环、活动通话授权撤回安全停止、语音内容闭环、批量设备遥测、录音上传队列、隐私对账、固件、双轨录音、状态事件、具体资源分享、受控运营配置、Agent组合组件引用、质量证据门禁、运营自定义版本族投放、新通话运行时冻结与供应商启动传递、带并发版本校验的配置投放暂停恢复、带严重度和验收证据门禁的联调问题闭环、含阻断告警定位、当前主机健康及生产部署标记、实际应用环境、身份服务模式、异地备份、外部告警与模拟服务强制条件的发布候选技术验收快照、绑定候选版本与构建摘要并按业务时区生成的可审计试点日/周运行证据、可审计且不生成正式告警的接收器联通测试、外部依赖预检台账、证书到期清单,以及带价格版本冻结、供应商用量去重和硬额度控制的费用治理接口。
servers:
- url: http://localhost:58080
description: 本地开发
paths:
/api/v1/device/time-challenges:
$ref: './onboarding.yaml#/paths/~1api~1v1~1device~1time-challenges'
/api/v1/device/register:
$ref: './onboarding.yaml#/paths/~1api~1v1~1device~1register'
/api/v1/device/setup-sessions:
$ref: './onboarding.yaml#/paths/~1api~1v1~1device~1setup-sessions'
/api/v1/device/handoffs:
$ref: './onboarding.yaml#/paths/~1api~1v1~1device~1handoffs'
/api/v1/device/poll:
$ref: './onboarding.yaml#/paths/~1api~1v1~1device~1poll'
/api/v1/device/claim-confirmations:
$ref: './onboarding.yaml#/paths/~1api~1v1~1device~1claim-confirmations'
/api/v1/device/state-acks:
$ref: './onboarding.yaml#/paths/~1api~1v1~1device~1state-acks'
/api/v1/device/activation-attempts:
$ref: './onboarding.yaml#/paths/~1api~1v1~1device~1activation-attempts'
/api/v1/device/activation-results:
$ref: './onboarding.yaml#/paths/~1api~1v1~1device~1activation-results'
/api/v1/device/activation-acks:
$ref: './onboarding.yaml#/paths/~1api~1v1~1device~1activation-acks'
/api/v1/device/setup-close:
$ref: './onboarding.yaml#/paths/~1api~1v1~1device~1setup-close'
/api/v1/web/handoffs/exchange:
$ref: './onboarding.yaml#/paths/~1api~1v1~1web~1handoffs~1exchange'
/api/v1/web/setup/session:
$ref: './onboarding.yaml#/paths/~1api~1v1~1web~1setup~1session'
/api/v1/web/setup/auth-link:
$ref: './onboarding.yaml#/paths/~1api~1v1~1web~1setup~1auth-link'
/api/v1/web/auth/sms/send:
$ref: './onboarding.yaml#/paths/~1api~1v1~1web~1auth~1sms~1send'
/api/v1/web/auth/sms/verify:
$ref: './onboarding.yaml#/paths/~1api~1v1~1web~1auth~1sms~1verify'
/api/v1/web/profiles:
$ref: './onboarding.yaml#/paths/~1api~1v1~1web~1profiles'
/api/v1/web/profiles/create:
$ref: './onboarding.yaml#/paths/~1api~1v1~1web~1profiles~1create'
/api/v1/web/claims:
$ref: './onboarding.yaml#/paths/~1api~1v1~1web~1claims'
/api/v1/web/claims/cancel:
$ref: './onboarding.yaml#/paths/~1api~1v1~1web~1claims~1cancel'
/api/v1/web/setup/status:
$ref: './onboarding.yaml#/paths/~1api~1v1~1web~1setup~1status'
/api/v1/web/setup/ack:
$ref: './onboarding.yaml#/paths/~1api~1v1~1web~1setup~1ack'
/api/v1/web/onboarding/devices:
$ref: './onboarding.yaml#/paths/~1api~1v1~1web~1onboarding~1devices'
/api/v1/web/account/session:
$ref: './onboarding.yaml#/paths/~1api~1v1~1web~1account~1session'
/api/v1/web/auth/logout:
$ref: './onboarding.yaml#/paths/~1api~1v1~1web~1auth~1logout'
/api/v1/ops/device-inventory:
$ref: './onboarding.yaml#/paths/~1api~1v1~1ops~1device-inventory'
/api/v1/ops/device-inventory/{id}/credential-deliveries:
$ref: './onboarding.yaml#/paths/~1api~1v1~1ops~1device-inventory~1{id}~1credential-deliveries'
/api/v1/ops/device-inventory/{id}/release:
$ref: './onboarding.yaml#/paths/~1api~1v1~1ops~1device-inventory~1{id}~1release'
/api/v1/ops/device-inventory/{id}/credential-rotations:
$ref: './onboarding.yaml#/paths/~1api~1v1~1ops~1device-inventory~1{id}~1credential-rotations'
/api/v1/ops/onboarding-activations/{id}/reconcile:
$ref: './onboarding.yaml#/paths/~1api~1v1~1ops~1onboarding-activations~1{id}~1reconcile'
/internal/v1/tools/memory-search:
post:
operationId: searchInCallMemory
@@ -6494,7 +6554,7 @@ components:
DeviceFactoryResetResult:
type: object
additionalProperties: false
required: [reset_id, device_id, activation_state, reset_counter, binding_removed, provider_credential_status, license_action, ended_conversation_count, cancelled_command_count, config_revision, claim, local_actions, reset_at]
required: [reset_id, device_id, activation_state, reset_counter, binding_removed, provider_credential_status, license_action, ended_conversation_count, cancelled_command_count, config_revision, local_actions, reset_at]
properties:
reset_id: { type: string, format: uuid }
device_id: { type: string, format: uuid }
@@ -6517,6 +6577,10 @@ components:
additionalProperties: false
required: [device_time, capabilities, network, requested_goal, config_revision]
properties:
intent: { type: string, enum: [activation_test], description: 仅physical_confirm首通测试填写;普通会话省略。 }
activation_id: { type: string, format: uuid }
attempt_id: { type: string, format: uuid }
permit_token: { type: string, description: 历史接口兼容字段;ONBOARDING-1.1不下发且无需填写。 }
device_time: { type: string, format: date-time }
monotonic_ms: { type: integer, format: int64, minimum: 0 }
config_revision: { type: integer, format: int64, minimum: 1 }
@@ -6576,7 +6640,7 @@ components:
properties:
event_id: { type: string, minLength: 1, maxLength: 255 }
sequence_no: { type: integer, format: int64, minimum: 1 }
event_type: { type: string, enum: [session_started, session_updated, session_ended, session_failed] }
event_type: { type: string, enum: [session_started, first_audio_received, session_updated, session_ended, session_failed] }
occurred_at: { type: string, format: date-time }
provider_session_ref: { type: string }
session_nonce: { type: string }
@@ -6619,6 +6683,7 @@ components:
type: object
required: [engine_mode, heartbeat_interval_sec, allocation_expires_at]
properties:
maximum_duration_seconds: { type: integer, minimum: 1, description: 首通测试固定60秒;普通会话省略。 }
engine_mode:
type: string
enum: [s2s_pure, s2s_hybrid, cascade]
+3 -3
View File
@@ -22,8 +22,8 @@ $payloadRoot = Join-Path $stagingRoot $bundleName
New-Item -ItemType Directory -Force -Path $payloadRoot | Out-Null
try {
$candidatePaths = @(& git ls-files --cached --others --exclude-standard -- `
cmd internal migrations openapi web deploy compose.yaml go.mod go.sum README.md CHANGELOG.md .env.example)
$candidatePaths = @(& git -c core.quotePath=false ls-files --cached --others --exclude-standard -- `
cmd internal migrations openapi web deploy scripts test docs compose.yaml go.mod go.sum README.md CHANGELOG.md .env.example .dockerignore)
if ($LASTEXITCODE -ne 0) { throw 'git file enumeration failed' }
$candidatePaths = $candidatePaths | Where-Object {
$_ -and
@@ -60,7 +60,7 @@ try {
generated_at = [DateTime]::UtcNow.ToString('o')
source_commit = $sourceCommit
working_tree_dirty = $statusLines.Count -gt 0
schema_migration_target = 52
schema_migration_target = 58
hardware_runtime = 'volc_hardware_agent'
files = @($files)
}
+70
View File
@@ -0,0 +1,70 @@
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
const require=createRequire(new URL('../web/package.json',import.meta.url));
const yaml=require('js-yaml');
const root=path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/,'$1')),'..');
const source=fs.readdirSync(path.join(root,'internal/onboarding')).filter(x=>x.endsWith('.go')&&!x.endsWith('_test.go')).map(x=>fs.readFileSync(path.join(root,'internal/onboarding',x),'utf8')).join('\n');
const descriptions={device_id:'生产编号,KP01-加12位大写MAC;不是平台UUID',platform_device_id:'平台公开UUID,沿用现有设备管理接口',factory_mac:'12位大写十六进制MAC;相同MAC不能创建第二台物理设备',hardware_model:'生产登记型号,当前为KP01',hardware_revision:'硬件修订号,注册必须与库存一致',batch_no:'生产批次,方便追溯',key_version:'设备认证密钥版本',expected_key_version:'交付放行目标版本;轮换时为当前生效版本',enrollment_job_id:'生产登记或密钥轮换任务UUID',receiver_public_key_pem:'工装RSA公钥,PKIX PEM,2048至4096位,指数65537',evidence_ref:'烧录读回或人工对账的证据引用,不放密钥',boot_id:'每次启动生成并保持的UUID v4',client_nonce:'设备生成的16字节随机数,Base64URL无填充',challenge_id:'C01签名校时返回的一次性挑战',firmware_version:'固件版本,用于兼容诊断',sdk_version:'SDK版本,用于兼容诊断',setup_id:'云端15分钟临时设置会话UUID',local_setup_id:'设备本次设置入口UUID v4;重试保持不变',transport_profile:'固定LOCAL_WEB,表示设备提供本地网页入口',code_challenge:'SHA256(ASCII verifier)的Base64URL无填充值',token:'90秒内只能兑换一次的handoff令牌',verifier:'设备交给手机的PKCE校验值;32字节随机数的Base64URL无填充字符串',known_revision:'设备已知状态版本;允许0',claim_id:'待物理确认的绑定申请UUID',challenge:'C05给设备的短时按键挑战,手机不能获取',binding_digest:'云端生成的绑定摘要,按键回报原值',expected_binding_generation:'提交时预期绑定代数,防止跨解绑或重新绑定重放',press_event_id:'该次真实物理按键事件UUID v4,重试保持不变',binding_generation:'绑定代数;解绑、重新绑定后旧代数无效',device_revision:'云端设备状态版本号',local_generation:'本次启动的本地持久化序号,不能倒退',state_hash:'STATE1规范字符串的SHA256小写十六进制摘要',reason:'关闭、轮换或人工处理原因,见对应枚举',final_revision:'设备关闭设置入口前看到的最新状态版本',elder_id:'使用者公开UUID,复用subject_profile',household_id:'家庭公开UUID;省略时须提供新家庭名称',household_name:'新建家庭的名称',preferred_name:'使用者称呼,用于设备人设和家庭展示',phone_e164:'含国家区号的手机号,例如+8613800000000',purpose:'固定LOGIN',sms_challenge_id:'发送短信得到的挑战UUID',code:'6位短信验证码;5分钟有效,最多验证5次',binding_id:'当前已生效的绑定公开UUID',received_revision:'网页已收到的设备状态版本',activation_id:'该物理设备唯一的激活任务UUID',config_version:'配置版本,必须与当前云端版本一致',config_digest:'config_b64解码后的原始字节SHA256;不能重序列化再计算',attempt_id:'设备生成的UUID v4,表示一次引擎初始化或首通执行',stage:'SDK_REGISTER或CONVERSATION_TEST;与C08平台当前阶段一致',user_initiated:'CONVERSATION_TEST必须为true,代表用户主动发起',sdk_result:'SDK执行返回码;SUCCESS_OBSERVED时必须为0',outcome:'SUCCESS_OBSERVED、DEFINITE_FAILURE或UNKNOWN;未知结果不得盲目重试',evidence:'首通验收使用的真实会话和事件引用',conversation_id:'测试会话公开UUID',started_event_id:'已入库session_started事件ID',first_audio_event_id:'已入库first_audio_received事件ID',ended_event_id:'已入库session_ended事件ID',expected_version:'运维列表中的激活任务版本,防止覆盖并发处理',conclusion:'OBSERVED_REGISTERED、CONFIRMED_NOT_REGISTERED或DEVICE_ATTEMPT_STOPPED',device_secret:'已核实的供应商单设备密钥,仅人工对账写入,加密保存',rtc_app_id:'供应商RTC应用ID;不填则使用已保存值'};
const schemas={};
for(const [,name,body] of source.matchAll(/type (\w+) struct \{([\s\S]*?)\n\}/g)){
if(!name.endsWith('Input')&&name!=='ActivationEvidence')continue;
const props={},required=[];
for(const [,type,field,omit] of body.matchAll(/\w+\s+(\w+)\s+`json:"([^",]+)(,omitempty)?"`/g)){
props[field]=type==='ActivationEvidence'?{$ref:'#/components/schemas/ActivationEvidence'}:{type:type==='bool'?'boolean':/^int/.test(type)?'integer':'string'};
if(/^int/.test(type))props[field].format='int64';
props[field].description=descriptions[field]??field;
if(!omit)required.push(field);
}
schemas[name]={type:'object',additionalProperties:false,properties:props,...(required.length?{required}:{})};
}
const set=(n,f,v)=>Object.assign(schemas[n].properties[f],v);
set('TimeChallengeInput','device_id',{pattern:'^KP01-[0-9A-F]{12}$'});set('InventoryInput','factory_mac',{pattern:'^[0-9A-F]{12}$'});
set('SetupInput','transport_profile',{enum:['LOCAL_WEB']});set('CloseInput','reason',{enum:['SUCCESS','TIMEOUT','CANCELLED','NETWORK_SWITCH']});set('SMSSendInput','purpose',{enum:['LOGIN']});set('SMSSendInput','phone_e164',{pattern:'^\\+[1-9][0-9]{7,14}$'});set('SMSVerifyInput','code',{pattern:'^[0-9]{6}$'});
set('ResultInput','stage',{enum:['SDK_REGISTER','CONVERSATION_TEST']});set('ResultInput','outcome',{enum:['SUCCESS_OBSERVED','DEFINITE_FAILURE','UNKNOWN']});set('ReconcileInput','conclusion',{enum:['OBSERVED_REGISTERED','CONFIRMED_NOT_REGISTERED','DEVICE_ATTEMPT_STOPPED']});
schemas.PollInput.required=['setup_id','boot_id','known_revision']; // Closed sessions remain usable for activation polling until boot/generation changes.
schemas.Empty={type:'object',additionalProperties:false};
const str=(description)=>({type:'string',description}),int=(description)=>({type:'integer',format:'int64',description}),bool={type:'boolean'},ref=n=>({$ref:'#/components/schemas/'+n});
const object=properties=>({type:'object',properties});
schemas.State=object({device_id:str(descriptions.device_id),device_revision:int(descriptions.device_revision),binding_generation:int(descriptions.binding_generation),binding_state:{type:'string',enum:['UNBOUND','CLAIM_PENDING','BOUND','TRANSFER_PENDING']},activation_state:{type:'string',enum:['NOT_STARTED','RESERVED','CONTACT_POSSIBLE','REGISTERED_OBSERVED','READY','RECONCILE_REQUIRED','BLOCKED']},state_hash:str(descriptions.state_hash)});
schemas.Command=object({command_id:str('命令标识'),type:{type:'string',enum:['CONFIRM_CLAIM','APPLY_ACTIVATION']},expires_at_ms:int('命令到期UTC毫秒'),payload:{oneOf:[object({claim_id:str('申请编号'),challenge:str(descriptions.challenge),binding_digest:str(descriptions.binding_digest),expected_binding_generation:int(descriptions.expected_binding_generation),masked_account:str('脱敏的申请账号')}),object({config_b64:str('原始配置JSON的Base64URL无填充编码'),config_mac:str('HMAC-SHA256(raw设备密钥, CFG1换行config_b64)')})]}});
schemas.ActivationConfig=object({contract_version:str('ONBOARDING-1.1'),device_id:str(descriptions.device_id),activation_id:str('任务编号'),config_version:int('版本'),binding_generation:int('当前绑定代数'),expires_at_ms:int('24小时配置有效期'),iot:object({instance_id:str('供应商实例'),product_key:str('产品编号'),device_name:str('冻结的物理设备名'),product_secret:str('供应商产品注册密钥,只在设备认证后的HTTPS激活配置中下发')}),bot_id:str('智能体ID')});
schemas.WebStatus=object({setup_id:str('临时会话UUID'),state:ref('State'),binding_id:str(descriptions.binding_id),claim_id:str(descriptions.claim_id),device_saved:bool,authenticated:bool,closed:bool,sms_enabled:bool,expires_at_ms:int('设置入口到期时间')});
schemas.Browser=object({csrf_token:str('保存在当前标签页sessionStorage;写请求放X-CSRF-Token'),data:ref('WebStatus')});
schemas.Failure=object({ok:{type:'boolean',const:false},code:str('稳定业务错误码'),message:str('用户可读说明'),request_id:str('请求追踪UUID')});
const data={TimeChallenge:object({payload_b64:str('TIME1校时JSON的Base64URL无填充'),mac_hex:str('HMAC-SHA256(raw32设备密钥, TIME1换行payload_b64)')}),Register:object({state:ref('State'),contract_version:str('ONBOARDING-1.1')}),Setup:object({setup_id:str('设置UUID'),expires_at_ms:int('15分钟到期'),poll_after_ms:int('首次2000'),state:ref('State')}),Handoff:object({handoff_token:str('90秒一次性令牌'),expires_at_ms:int('到期毫秒'),entry_url:str('固定HTTPS入口;设备将token和verifier放fragment')}),Poll:object({state:ref('State'),command:{oneOf:[ref('Command'),{type:'null'}]},poll_after_ms:int('下一次轮询间隔')}),Confirmation:object({claim_id:str('申请UUID'),state:ref('State'),already_applied:bool}),StateAck:object({accepted_revision:int('已接受版本'),setup_can_close:bool}),Attempt:object({allowed:bool,attempt_id:str('执行UUID'),permit_expires_at_ms:int('30秒有效期'),state:ref('State')}),Result:object({activation_id:str('任务UUID'),state:ref('State'),reconcile_required:bool}),ActivationAck:object({accepted:bool,state:ref('State')}),Close:object({close_allowed:bool,state:ref('State')}),Claim:object({claim_id:str('申请UUID'),expires_at_ms:int('120秒有效期'),state:ref('State')}),StateOnly:object({state:ref('State')}),Ack:object({accepted:bool}),SMS:object({sms_challenge_id:str('验证码挑战UUID'),expires_at_ms:int('5分钟'),resend_after_ms:int('60000')}),Profile:object({elder_id:str('使用者公开UUID'),household_id:str('家庭公开UUID')}),Profiles:object({items:{type:'array',items:object({household_id:str('家庭公开UUID'),household_name:str('家庭名'),elder_id:str('使用者UUID,空值表示家庭无使用者'),preferred_name:str('称呼')})}}),Devices:object({items:{type:'array',items:{type:'object',additionalProperties:true}},limit:int('运维列表最多200条;按设备创建时间倒序')}),Inventory:object({device_id:str(descriptions.device_id),platform_device_id:str(descriptions.platform_device_id),enrollment_job_id:str('生产或轮换任务UUID'),key_version:int('目标凭据版本')}),Delivery:object({algorithm:str('RSA-OAEP-SHA256+A256GCM'),enrollment_job_id:str('任务UUID'),wrapped_key_b64:str('RSA包装的AES数据密钥'),ciphertext_b64:str('前12字节为nonce,后接AES-GCM密文和16字节认证标签;AAD为任务UUID'),expires_at_ms:int('交付包到期时间')}),Release:object({released:bool,device_id:str(descriptions.device_id),key_version:int('生效版本')}),Logout:object({signed_out:bool}),Account:object({csrf_token:str('账号会话写请求CSRF'),authenticated:bool})};
data.Browser=ref('Browser');data.WebStatus=ref('WebStatus');
data.Inventory.properties.contract_version=str('ONBOARDING-1.1');data.Inventory.properties.released=bool;data.Release=data.Inventory;
schemas.DevicesItem=object({platform_device_id:str('平台设备UUID'),device_id:str('物理设备编号'),model:str('型号'),binding_generation:int('当前绑定代数'),device_revision:int('状态版本'),released:bool,activation_id:str('激活任务UUID'),activation_state:str('激活状态'),activation_version:int('对账使用的预期任务版本'),key_version:int('当前生效设备认证密钥版本'),pending_key_version:int('待交付或放行的版本,没有则0'),current_attempt_id:str('当前供应商注册尝试UUID'),enrollment_job_id:str('最新生产或轮换任务UUID'),last_error_code:str('故障原因码'),provider_device_name:str('冻结的供应商物理名'),household_name:str('家庭名,仅我的设备'),preferred_name:str('使用者称呼,仅我的设备')});data.Devices.properties.items.items=ref('DevicesItem');
for(const [name,schema]of Object.entries(data))schemas[name+'Response']=object({ok:{type:'boolean',const:true},code:{type:'string',const:'OK'},request_id:{type:'string',format:'uuid'},server_time_ms:int('UTC毫秒'),data:schema});
schemas.TimeChallengeResponse=data.TimeChallenge;
const endpoints=[
['POST','/api/v1/device/time-challenges','C01 校时挑战','TimeChallengeInput','TimeChallenge','public'],['POST','/api/v1/device/register','C02 首次认证和启动注册','RegisterInput','Register','device'],['POST','/api/v1/device/setup-sessions','C03 创建临时设置会话','SetupInput','Setup','device'],['POST','/api/v1/device/handoffs','C04 获取一次性手机入口','HandoffInput','Handoff','device'],['POST','/api/v1/device/poll','C05 查询状态和命令','PollInput','Poll','device'],['POST','/api/v1/device/claim-confirmations','C06 物理按键确认绑定','ConfirmationInput','Confirmation','device'],['POST','/api/v1/device/state-acks','C07 绑定状态持久化回执','StateAckInput','StateAck','device'],['POST','/api/v1/device/activation-attempts','C08 申请设备执行许可','AttemptInput','Attempt','device'],['POST','/api/v1/device/activation-results','C09 回报设备执行结果','ResultInput','Result','device'],['POST','/api/v1/device/activation-acks','C10 激活结果保存回执','ActivationAckInput','ActivationAck','device'],['POST','/api/v1/device/setup-close','C11 关闭临时设置入口','CloseInput','Close','device'],
['POST','/api/v1/web/handoffs/exchange','手机兑换一次性入口','ExchangeInput','Browser','exchange'],['GET','/api/v1/web/setup/session','恢复手机设置会话',null,'Browser','web'],['POST','/api/v1/web/setup/auth-link','关联OIDC登录','Empty','Browser','oidc'],['POST','/api/v1/web/auth/sms/send','发送登录短信','SMSSendInput','SMS','web'],['POST','/api/v1/web/auth/sms/verify','验证短信并登录','SMSVerifyInput','Browser','web'],['POST','/api/v1/web/profiles','查询可选择的家庭使用者','Empty','Profiles','web'],['POST','/api/v1/web/profiles/create','创建家庭或使用者','ProfileInput','Profile','web'],['POST','/api/v1/web/claims','提交绑定申请','ClaimInput','Claim','web'],['POST','/api/v1/web/claims/cancel','取消自己的绑定申请','CancelInput','StateOnly','web'],['POST','/api/v1/web/setup/status','查询手机绑定进度','Empty','WebStatus','web'],['POST','/api/v1/web/setup/ack','网页确认收到绑定结果','WebAckInput','Ack','web'],['GET','/api/v1/web/onboarding/devices','设置结束后查询我的设备',null,'Devices','account'],['GET','/api/v1/web/account/session','恢复账号会话及CSRF',null,'Account','account'],['POST','/api/v1/web/auth/logout','退出本浏览器登录','Empty','Logout','account'],
['GET','/api/v1/ops/device-inventory','查询生产设备和激活状态',null,'Devices','ops'],['POST','/api/v1/ops/device-inventory','登记生产设备','InventoryInput','Inventory','ops'],['POST','/api/v1/ops/device-inventory/{id}/credential-deliveries','向工装交付加密密钥','DeliveryInput','Delivery','ops'],['POST','/api/v1/ops/device-inventory/{id}/release','确认烧录读回并放行','ReleaseInput','Release','ops'],['POST','/api/v1/ops/device-inventory/{id}/credential-rotations','准备轮换凭据','RotationInput','Inventory','ops'],['POST','/api/v1/ops/onboarding-activations/{id}/reconcile','人工记录激活对账结论','ReconcileInput','StateOnly','ops']];
const spec={openapi:'3.1.0',info:{title:'SaaS 首次认证、绑定和激活接口',version:'0.78.0',description:'ONBOARDING-1.1。仅用于生产登记为physical_confirm的设备;旧设备维持原协议。详细规则见docs/onboarding-implementation.md。'},paths:{},components:{securitySchemes:{DeviceRequest:{type:'apiKey',in:'header',name:'Authorization',description:'Device-HMAC-SHA256小写十六进制签名;DEVREQ1九行规范;其余必填签名头见操作参数'},Bearer:{type:'http',scheme:'bearer'},SetupCookie:{type:'apiKey',in:'cookie',name:'__Host-onboarding'},AccountCookie:{type:'apiKey',in:'cookie',name:'__Host-platform-onboarding'}},schemas}};
function header(name,required=true,description=name){return{in:'header',name,required,schema:{type:'string'},description}}
for(const [method,url,title,input,output,auth]of endpoints){
const params=[header('X-Request-ID',method==='POST'&&auth!=='public','UUID v4;业务重试保持相同值和原始正文')];
if(url.includes('{id}'))params.push({in:'path',name:'id',required:true,schema:{type:'string',format:'uuid'},description:url.includes('onboarding-activations')?'激活任务UUID':'平台设备公开UUID'});
if(auth==='device')for(const name of ['X-Device-ID','X-Key-Version','X-Timestamp-Ms','X-Nonce'])params.push(header(name));
if(['web','oidc','account'].includes(auth)&&method==='POST')params.push(header('X-CSRF-Token'),header('Origin',true,'必须与ONBOARDING_ENTRY_URL同源'));
if(auth==='exchange')params.push(header('Origin',true,'必须与ONBOARDING_ENTRY_URL同源'));
if(auth==='ops'){params.push(header('X-Tenant-ID',true,'租户公开UUID;服务端核验运维角色'));if(input==='InventoryInput'||input==='RotationInput')params.push(header('Idempotency-Key',true,'16至160字符,重试保持相同值'));}
const security=auth==='device'?[{DeviceRequest:[]}]:auth==='ops'?[{Bearer:[]}]:auth==='oidc'?[{SetupCookie:[],Bearer:[]}]:auth==='web'?[{SetupCookie:[]}]:auth==='account'?[{AccountCookie:[]},{Bearer:[]}]:[];
const operation={operationId:'onboarding'+title.replace(/[^a-zA-Z0-9]/g,'')+method+url.replace(/[^a-zA-Z0-9]/g,''),summary:title,tags:['Onboarding'],description:auth==='device'?'HTTPS,无query参数;正文不超过8KiB。随机nonce每次发送更新,X-Request-ID在重试时保持。':auth==='public'?'专用HTTP或HTTPS校时;无需时间戳及请求签名;成功直接返回payload_b64和mac_hex,设备必须核验TIME1。':auth==='ops'?'必须使用运维OIDC身份。对账仅ops_admin;其它写操作ops_admin或ops_operator。':'HTTPS,同源浏览器,Cookie不可读,CSRF仅保存在当前标签页。',security,parameters:params,responses:{'200':{description:'成功;业务状态请读取data',content:{'application/json':{schema:ref(output+'Response')}}},default:{description:'失败;400/401/403/409/410/413/415/429/500/503,见code恢复;禁止根据超时推断供应商没有执行',content:{'application/json':{schema:ref('Failure')}}}}};
if(input)operation.requestBody={required:true,content:{'application/json':{schema:ref(input)}}};
(spec.paths[url]??={})[method.toLowerCase()]=operation;
}
fs.writeFileSync(path.join(root,'openapi/onboarding.yaml'),yaml.dump(spec,{lineWidth:140,noRefs:true}));
const mainPath=path.join(root,'openapi/openapi.yaml');let main=fs.readFileSync(mainPath,'utf8').replace(/\r\n/g,'\n').replace('version: 0.77.0','version: 0.78.0');
if(!main.includes('./onboarding.yaml#/paths/'))main=main.replace('paths:\n','paths:\n'+Object.keys(spec.paths).map(url=>` ${url}:\n $ref: './onboarding.yaml#/paths/${url.replaceAll('~','~0').replaceAll('/','~1')}'\n`).join(''));
if(!main.includes('intent: { type: string, enum: [activation_test]'))main=main.replace(' HardwareAgentCreateRequest:\n',' HardwareAgentCreateRequest:\n').replace(/( HardwareAgentCreateRequest:[\s\S]*? properties:\n)/,'$1 intent: { type: string, enum: [activation_test], description: 仅physical_confirm首通测试填写;普通会话省略。 }\n activation_id: { type: string, format: uuid }\n attempt_id: { type: string, format: uuid }\n permit_token: { type: string, description: 历史接口兼容字段;ONBOARDING-1.1不下发且无需填写。 }\n');
main=main.replace('enum: [session_started, session_updated, session_ended, session_failed','enum: [session_started, first_audio_received, session_updated, session_ended, session_failed');
main=main.replace('config_revision, claim, local_actions','config_revision, local_actions');
if(!main.includes('maximum_duration_seconds:'))main=main.replace(/( ConversationSession:[\s\S]*? properties:\n)/,'$1 maximum_duration_seconds: { type: integer, minimum: 1, description: 首通测试固定60秒;普通会话省略。 }\n');
fs.writeFileSync(mainPath,main);
const doc=['# 首次接入接口字段清单','', '由 `node scripts/generate-onboarding-contract.mjs` 根据实际Go请求结构生成。共 '+endpoints.length+' 项操作。完整响应结构见 [OpenAPI](../openapi/onboarding.yaml),流程、签名、错误恢复见 [实施说明](onboarding-implementation.md)。',''];
for(const [method,url,title,input,output,auth]of endpoints){doc.push('## '+title,'', '`'+method+' '+url+'`','', '调用身份:'+({device:'设备DEVREQ1签名',public:'校时请求,无请求签名;设备必须核验TIME1响应MAC',web:'设置Cookie + 写请求CSRF',oidc:'设置Cookie + CSRF + OIDC Bearer',exchange:'同源网页;一次性token和verifier',account:'账号Cookie;设备列表也支持OIDC Bearer',ops:'运维OIDC Bearer + X-Tenant-ID'}[auth]),'');if(input&&input!=='Empty'){doc.push('| 字段 | 类型 | 必填 | 用途 |','|---|---|---|---|');for(const [name,p]of Object.entries(schemas[input].properties))doc.push(`| ${name} | ${p.type??'object'} | ${schemas[input].required?.includes(name)?'是':'条件或可选'} | ${p.description} |`);}else doc.push(method==='POST'?'正文:`{}`。':'无请求正文。');doc.push('', output==='TimeChallenge'?'响应:根对象直接返回 `{payload_b64,mac_hex}`。':'响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `'+output+'Response.data`。','');}
fs.writeFileSync(path.join(root,'docs/onboarding-api-fields.md'),doc.join('\n'));
console.log('Generated onboarding OpenAPI and field reference: '+endpoints.length+' operations');
+71
View File
@@ -0,0 +1,71 @@
"""DEVREQ1 call helper. Keys stay in a user-specified local file, never argv.
Prints signed headers by default; --send explicitly submits one HTTPS request.
The caller keeps request-id + original body on retries and changes nonce/time.
"""
import argparse
import base64
import hashlib
import hmac
import json
import secrets
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
def sign(key, method, path, device_id, key_version, timestamp, nonce, request_id, body):
canonical = '\n'.join(['DEVREQ1', method.upper(), path, device_id, str(key_version), str(timestamp), nonce, request_id, hashlib.sha256(body).hexdigest()])
return hmac.new(key, canonical.encode('utf-8'), hashlib.sha256).hexdigest(), canonical
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--device-id', required=True)
parser.add_argument('--key-file', type=Path, required=True, help='file containing Base64URL (unpadded) encoded raw32 key')
parser.add_argument('--key-version', type=int, default=1)
parser.add_argument('--path', required=True)
parser.add_argument('--body-file', type=Path, required=True)
parser.add_argument('--request-id', required=True)
parser.add_argument('--method', default='POST', choices=['POST', 'GET'])
parser.add_argument('--send', help='explicit HTTPS origin, e.g. https://saas.example')
args = parser.parse_args()
if not args.path.startswith('/') or '?' in args.path or '#' in args.path or '\n' in args.path:
parser.error('path must be absolute without query or fragment')
if str(uuid.UUID(args.request_id)) != args.request_id or uuid.UUID(args.request_id).version != 4:
parser.error('request-id must be canonical UUID v4')
encoded = args.key_file.read_text(encoding='utf-8').strip()
key = base64.urlsafe_b64decode(encoded + '=' * (-len(encoded) % 4))
if len(key) != 32:
parser.error('device key must decode to 32 bytes')
body = args.body_file.read_bytes()
if len(body) > 8192 or body.startswith(b'\xef\xbb\xbf'):
parser.error('body must be <=8KiB UTF-8 without BOM')
json.loads(body.decode('utf-8'))
nonce = base64.urlsafe_b64encode(secrets.token_bytes(16)).decode().rstrip('=')
timestamp = int(time.time() * 1000)
signature, _ = sign(key, args.method, args.path, args.device_id, args.key_version, timestamp, nonce, args.request_id, body)
headers = {'Authorization': 'Device-HMAC-SHA256 ' + signature, 'X-Device-ID': args.device_id, 'X-Key-Version': str(args.key_version), 'X-Timestamp-Ms': str(timestamp), 'X-Nonce': nonce, 'X-Request-ID': args.request_id, 'Content-Type': 'application/json'}
if not args.send:
print(json.dumps({'method': args.method, 'path': args.path, 'headers': headers}, ensure_ascii=False, indent=2))
return
origin = urllib.parse.urlsplit(args.send)
if origin.scheme != 'https' or not origin.netloc or origin.username or origin.query or origin.fragment or origin.path not in ('', '/'):
parser.error('--send must be a fixed HTTPS origin')
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *unused):
return None
request = urllib.request.Request(args.send.rstrip('/') + args.path, data=body, headers=headers, method=args.method)
try:
with urllib.request.build_opener(NoRedirect).open(request, timeout=30) as response:
print(response.read(65536).decode('utf-8'))
except urllib.error.HTTPError as error:
print(error.read(65536).decode('utf-8'))
raise SystemExit(error.code)
if __name__ == '__main__':
main()
+34
View File
@@ -0,0 +1,34 @@
param([string[]]$Packages = @('onboarding','devicereset','opsdevice','hardwareprovision','idempotency','httpapi','orchestration','saas','outbox','privacy'))
$ErrorActionPreference='Continue'
Set-Location (Split-Path $PSScriptRoot -Parent)
$taskTemplate='codex_onboarding_template_'+[Guid]::NewGuid().ToString('N').Substring(0,12)
$taskDatabases=[System.Collections.Generic.List[string]]::new()
New-Item -ItemType Directory -Force -Path '.tmp-onboarding' | Out-Null
try {
docker compose exec -T postgres psql -U voice -d postgres -v ON_ERROR_STOP=1 -c "CREATE DATABASE $taskTemplate" | Out-Null
if($LASTEXITCODE -ne 0){throw 'Test database creation failed'}
$taskDatabases.Add($taskTemplate)
docker compose run --rm --no-deps migrate -path=/migrations "-database=postgres://voice:voice_dev_only@postgres:5432/$taskTemplate`?sslmode=disable" up 2>&1 | Out-File '.tmp-onboarding/migration-validation.log'
if($LASTEXITCODE -ne 0){throw 'Migration validation failed'}
docker compose run --rm --no-deps -e PGPASSWORD=voice_dev_only seed psql -h postgres -U voice -d $taskTemplate -v ON_ERROR_STOP=1 -f /fixtures/local_seed.sql 2>&1 | Out-File '.tmp-onboarding/seed-validation.log'
if($LASTEXITCODE -ne 0){throw 'Seed validation failed'}
foreach($taskPackage in $Packages) {
if($taskPackage -notmatch '^[a-z]+$'){throw 'Invalid package'}
$taskDatabase='codex_onboarding_test_'+[Guid]::NewGuid().ToString('N').Substring(0,12)
docker compose exec -T postgres psql -U voice -d postgres -v ON_ERROR_STOP=1 -c "CREATE DATABASE $taskDatabase TEMPLATE $taskTemplate" | Out-Null
if($LASTEXITCODE -ne 0){throw 'Package database creation failed'}
$taskDatabases.Add($taskDatabase)
$env:TEST_DATABASE_URL="postgres://voice:voice_dev_only@127.0.0.1:55432/$taskDatabase`?sslmode=disable"
$env:TEST_REDIS_ADDR='127.0.0.1:56379'
go test -count=1 -p 1 "./internal/$taskPackage" 2>&1 | Tee-Object ".tmp-onboarding/integration-$taskPackage.log"
if($LASTEXITCODE -ne 0){throw "Integration failed: $taskPackage"}
}
} finally {
Remove-Item Env:TEST_DATABASE_URL -ErrorAction SilentlyContinue
foreach($taskDatabase in $taskDatabases) {
# Only databases created by this invocation can be removed.
if($taskDatabase -match '^codex_onboarding_(template|test)_[0-9a-f]{12}$'){
docker compose exec -T postgres psql -U voice -d postgres -c "DROP DATABASE $taskDatabase WITH (FORCE)" | Out-Null
}
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ try {
$manifestPath = Join-Path $payload[0].FullName 'deploy-manifest.json'
$checksumsPath = Join-Path $payload[0].FullName 'SHA256SUMS'
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
if ($manifest.schema_version -ne 'server-deployment-bundle-v1' -or $manifest.schema_migration_target -ne 52 -or $manifest.hardware_runtime -ne 'volc_hardware_agent') {
if ($manifest.schema_version -ne 'server-deployment-bundle-v1' -or $manifest.schema_migration_target -ne 58 -or $manifest.hardware_runtime -ne 'volc_hardware_agent') {
throw 'Deployment manifest metadata is invalid'
}
$verifiedFiles = 0
+57
View File
@@ -0,0 +1,57 @@
// Synthetic customer data for layout checks; all API traffic is intercepted.
export const id = (n) => `10000000-0000-0000-0000-${String(n).padStart(12, "0")}`;
const now = new Date().toISOString();
const tomorrow = new Date(Date.now() + 86400000).toISOString();
const capabilities = ["household.read", "household.manage", "member.manage", "subject.read", "subject.write", "consent.manage", "device.read", "device.manage", "conversation.read", "story.review", "privacy.read", "privacy.manage", "privacy.request", "recording.access.manage", "sharing.manage"];
export const households = ["林家的安心陪伴空间", "陈家的家庭空间", "外婆的家庭空间"].map((name, i) => ({ id: id(200 + i), tenant_id: id(1), name, status: "active", role: "owner", capabilities, version: 1, created_at: now, updated_at: now }));
export const currentUser = { identity: { issuer: "http://localhost:58180/realms/voice-local", subject: "mobile-fixture", display_name: "林家的家庭管理员", preferred_username: "mobile-fixture" }, accounts: [{ user_id: id(100), tenant_id: id(1), tenant_name: "家庭空间", policy_version: "2026-09", platform_role: "customer", account_kind: "customer", display_name: "林家的家庭管理员", household_memberships: households.map((h) => ({ household_id: h.id, household_name: h.name, role: h.role, capabilities })) }] };
export const subjects = ["林秀兰", "陈建国", "王素梅"].map((preferred_name, i) => ({ id: id(400 + i), household_id: id(200), preferred_name, language: "zh-CN", timezone: "Asia/Shanghai", profile_data: { background: "退休后住在杭州,喜欢和家人分享以前的工作与生活经历。", interests: ["养花", "旅行", "听戏曲"], relationship: "妈妈", birth_year: 1948, city: "杭州" }, conversation_preferences: { conversation_goal: "轻松聊聊当天的生活与年轻时的工作", pace: "slow", preferred_topics: ["家乡", "老朋友"], avoided_topics: ["疾病"] }, status: "active", version: 1, created_at: now, updated_at: now }));
export const devices = subjects.map((s, i) => ({ id: id(500 + i), binding_id: id(510 + i), household_id: id(200), subject_id: s.id, subject_name: s.preferred_name, model: i ? "安心陪伴音箱" : "Companion-Speaker-Pro-2026-Long-Model-Name", status: "registered", firmware_version: "1.2.0", desired_revision: 1, reported_revision: 1, last_reported_at: now, bound_at: now, registered_at: now, version: 1 }));
export const members = ["林家的家庭管理员", "陈建国的家人"].map((display_name, i) => ({ id: id(300 + i), user_id: id(100 + i), display_name, role: i ? "editor" : "owner", status: "active", joined_at: now, version: 1, is_owner: !i }));
export const conversations = Array.from({ length: 7 }, (_, i) => ({ id: id(600 + i), subject_id: id(400), device_id: id(500), status: "COMPLETED", runtime_type: "legacy_voicechat", engine_mode: "s2s_hybrid", retention_mode: "transcript_and_audio", audio_retention_enabled: true, started_at: new Date(Date.now() - i * 86400000 - 1200000).toISOString(), ended_at: new Date(Date.now() - i * 86400000).toISOString(), duration_ms: 1200000, created_at: now, updated_at: now }));
export const stories = ["年轻时第一次独自乘火车去杭州的故事", "和老朋友一起种花的春天", "留给家人的童年回忆"].map((title, i) => ({ id: id(700 + i), subject_id: id(400), conversation_id: id(600), title, content: "那时候的火车站很热闹,第一次独自出门的时候,家人送我到了站台。后来结识了新朋友,我们一起工作、生活,留下许多珍贵的回忆。".repeat(5), status: "CANDIDATE", sensitivity: "normal", confidence: 0.92, generation_version: 1, revision_no: 1, author_type: "model", version: 1, created_at: now, updated_at: now, sources: [], facts: [{ id: id(750 + i), fact_type: "life_event", content: "年轻时在杭州工作", sensitivity: "normal", source_turn_ids: [id(650)], status: "CANDIDATE", version: 1 }] }));
const memories = [{ id: id(800), subject_id: id(400), content: "喜欢养花,和家人在杭州生活,每周都会和老朋友见面。", source_refs: [], status: "ACTIVE", sensitivity: "normal", version_no: 1, version: 1, confirmed_by: id(100), confirmed_at: now, index_status: "ACTIVE", provider_index_status: "DISABLED", created_at: now, updated_at: now }];
const consents = ["transcript_storage", "raw_audio_storage", "story_extraction", "personalized_memory", "family_sharing"].map((type, i) => ({ id: id(900 + i), subject_id: id(400), type, scope: {}, effective: true, version: 1, granted_at: now, policy_version: "2026-09", notice_digest: "synthetic-fixture", actor_user_id: id(100) }));
export async function installCustomerFixture(context, { empty = false, failure = false, authenticated = true } = {}) {
const unexpected = [];
if (authenticated) await context.addInitScript(({ currentUser }) => {
sessionStorage.setItem("oidc.user:http://localhost:58180/realms/voice-local:voice-platform-web", JSON.stringify({ access_token: "synthetic-mobile-layout-token", token_type: "Bearer", profile: { sub: "mobile-fixture", ...currentUser.identity }, expires_at: Math.floor(Date.now() / 1000) + 3600 }));
}, { currentUser });
const list = (items) => ({ items: empty ? [] : items });
await context.route("**/api/v1/**", async (route) => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname.replace("/api/v1", "");
if (request.method() !== "GET") { unexpected.push(`${request.method()} ${path}`); return route.abort(); }
if (path === "/events") return route.fulfill({ contentType: "text/event-stream", body: ": mobile layout fixture\n\n" });
if (failure && path === "/households") return route.fulfill({ status: 503, json: { error: { code: "unavailable", message: "家庭资料暂时无法读取,请稍后重试。" } } });
let data;
if (path === "/me") data = currentUser;
else if (path === "/households") data = list(households);
else if (path === "/devices") data = list(devices);
else if (/\/households\/[^/]+\/subjects$/.test(path)) data = list(subjects);
else if (/\/members$/.test(path)) data = list(members);
else if (/\/invitations$/.test(path)) data = list([{ id: id(350), household_id: id(200), invitee_hint: "邀请一起照顾长者的家人", role: "viewer", status: "pending", expires_at: tomorrow, created_at: now, version: 1 }]);
else if (/\/facts$/.test(path)) data = list([{ id: id(950), subject_id: id(400), fact_type: "background", content: "年轻时在杭州工作,喜欢种花和听戏曲。", source_type: "family_entry", confirmation_status: "pending", sensitivity: "normal", version: 1, created_at: now, updated_at: now }]);
else if (/\/consents$/.test(path)) data = list(consents);
else if (/\/conversation-readiness$/.test(path)) data = { subject_id: id(400), ready: true, evaluated_at: now, checks: [{ code: "PROFILE_BACKGROUND", status: "ready", blocking: false, title: "陪伴资料已完善", detail: "已填写背景与交流偏好" }], devices, start_time_checks: [] };
else if (/\/subjects\/[^/]+\/conversations$/.test(path)) data = list(conversations);
else if (/\/conversations\/[^/]+$/.test(path)) data = { ...(conversations.find((c) => path.endsWith(c.id)) || conversations[0]), household_id: id(200), snapshots: { consent_snapshot_id: id(901), consent_snapshot_version: 1, context_snapshot_id: id(902), profile_version: 1, prompt_bundle_version: "mobile-qa", context_preparation_status: "READY" }, history: [{ from_status: "ACTIVE", to_status: "COMPLETED", reason: "user_hangup", source: "device", occurred_at: now }] };
else if (/\/turns$/.test(path)) data = list(Array.from({ length: 6 }, (_, i) => ({ id: id(650 + i), sequence_no: i + 1, speaker: i % 2 ? "agent" : "subject", start_at: now, end_at: now, final_text: i % 2 ? "能和我分享一下当时的经历吗?" : "那时候我第一次独自乘火车出门,到杭州后认识了好多新朋友。".repeat(3), interrupted: false, source_type: "device" })));
else if (/\/summaries$/.test(path)) data = list([{ id: id(660), type: "conversation_summary", content: "长者聊起在杭州工作与养花的经历,心情轻松。关键词:杭州、工作、养花。", source_turn_ids: [id(650)], model_ref: "mobile-layout-fixture", prompt_version: "v1", generation_version: 1, status: "READY" }, { id: id(661), type: "open_topic", content: "下次可以继续聊聊老朋友与家乡的春天。", source_turn_ids: [id(650)], model_ref: "mobile-layout-fixture", prompt_version: "v1", generation_version: 1, status: "READY" }]);
else if (/\/usage$/.test(path)) data = { conversation_id: id(600), currency: "CNY", cost_micros: 120000, lines: [] };
else if (/\/media$/.test(path)) data = { conversation_id: id(600), segments: ["near", "far"].map((track, i) => ({ id: id(680 + i), track, sequence_no: 1, duration_ms: 1200000, expected_size: 38400000, status: "VERIFIED" })), can_play: true, can_download: true };
else if (/\/subjects\/[^/]+\/stories$/.test(path)) data = list(stories);
else if (/\/stories\/[^/]+$/.test(path)) data = stories.find((s) => path.endsWith(s.id));
else if (/\/memories$/.test(path)) data = list(memories);
else if (/\/(privacy-requests|exports|recording-access-grants|content-models)$/.test(path)) data = list([]);
else if (path === "/sharing-grants") data = list([{ id: id(980), subject_id: id(400), resource_type: "STORY", resource_id: id(700), permission: "VIEW_TEXT", purpose: "与家人分享珍贵的童年回忆", grantee_user_id: id(101), login_required: true, status: "ACTIVE", expires_at: tomorrow, created_at: now, version: 1 }]);
else if (/\/presence$/.test(path)) data = { device_id: id(500), online: true, status: "online", last_seen_at: now, last_heartbeat_at: now };
else if (/\/twin$/.test(path)) data = { device_id: id(500), desired_revision: 1, reported_revision: 1, desired_config: { speaker_volume: 60 }, reported_config: { speaker_volume: 60 }, updated_at: now };
else if (/\/usage-overview$/.test(path)) data = { from: url.searchParams.get("from"), to: url.searchParams.get("to"), timezone: "Asia/Shanghai", scope: { household_name: households[0].name }, total_duration_ms: 8400000, today_duration_ms: 1200000, average_duration_ms: 1200000, conversation_count: 7, active_days: 7, current_streak_days: 7, longest_streak_days: 7, cost_micros: 1200000, currency: "CNY", cost_available: true, daily: empty ? [] : conversations.map((c) => ({ date: c.started_at.slice(0, 10), duration_ms: c.duration_ms, conversation_count: 1, cost_micros: 120000 })), devices: empty ? [] : devices.map((d) => ({ id: d.id, name: d.model, conversation_count: 7, duration_ms: 8400000, cost_micros: 1200000 })), recent_conversations: empty ? [] : conversations.map((c) => ({ ...c, subject_name: subjects[0].preferred_name, device_name: devices[0].model, cost_micros: 120000 })) };
else { unexpected.push(`${request.method()} ${path}`); return route.fulfill({ status: 404, json: { error: { message: `Missing layout fixture: ${path}` } } }); }
return route.fulfill({ json: { request_id: "mobile-layout-fixture", data } });
});
return unexpected;
}
+60
View File
@@ -0,0 +1,60 @@
// Synthetic browser tests: all business APIs are intercepted; no real SMS or
// provider calls. Backend transactions are covered by Go integration tests.
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import {mkdir,writeFile} from 'node:fs/promises';
import {resolve} from 'node:path';
const require=createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM??import.meta.url);
const {chromium}=require('playwright');
const base=process.env.ONBOARDING_WEB_URL??'http://127.0.0.1:5319';
const output=resolve('.design-evidence/onboarding');await mkdir(output,{recursive:true});
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_PATH??'C:/Program Files/Google/Chrome/Application/chrome.exe'});
const failures=[],checks=[];const id=n=>`10000000-0000-4000-8000-${String(n).padStart(12,'0')}`;
async function shot(page,name){await page.evaluate(()=>document.fonts.ready);await page.waitForTimeout(250);const overflow=await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth);assert(overflow<=2,`${name} overflow ${overflow}`);await page.screenshot({path:resolve(output,name+'.png'),fullPage:true});checks.push(name);}
try{
for(const width of (process.env.OPS_ONLY?[]:[375,768])){
const context=await browser.newContext({viewport:{width,height:900}});const page=await context.newPage();page.on('pageerror',e=>failures.push(e.message));
let exchange=0,claims=0,acks=0;
const state={device_id:'KP01-AABBCCDDEEFF',platform_device_id:id(500),device_revision:3,binding_generation:0,binding_state:'UNBOUND',activation_state:'NOT_STARTED',state_hash:'a'.repeat(64),next_action:'OPEN_SETUP'};
const status={setup_id:id(600),state,authenticated:false,closed:false,sms_enabled:true,device_saved:false,expires_at_ms:Date.now()+900000};
const requests=[];
await page.route('**/api/v1/**',async route=>{
const r=route.request(),path=new URL(r.url()).pathname;let data={};let body;
if(r.method()==='POST'){body=r.postDataJSON();requests.push({path,body});assert(r.headers()['x-request-id']);if(!path.endsWith('/exchange'))assert(r.headers()['x-csrf-token']);}
if(path.endsWith('/handoffs/exchange')){exchange++;assert.equal(body.token,'once-token');assert.equal(body.verifier,'once-verifier');data={csrf_token:'test-csrf',data:status};}
else if(path.endsWith('/setup/session'))data={csrf_token:'test-csrf',data:status};
else if(path.endsWith('/setup/status'))data=status;
else if(path.endsWith('/auth/sms/send')){assert.equal(body.phone_e164,'+8613800000000');data={sms_challenge_id:id(700),resend_after_ms:60000};}
else if(path.endsWith('/auth/sms/verify')){assert.equal(body.code,'123456');status.authenticated=true;data={csrf_token:'test-csrf',data:status};}
else if(path.endsWith('/profiles'))data={items:[{household_id:id(200),household_name:'测试家庭',elder_id:id(400),preferred_name:'奶奶'}]};
else if(path.endsWith('/claims')){claims++;assert.equal(body.elder_id,id(400));state.binding_state='CLAIM_PENDING';status.claim_id=id(800);state.device_revision++;data={claim_id:status.claim_id,state};}
else if(path.endsWith('/claims/cancel')){state.binding_state='UNBOUND';status.claim_id='';state.device_revision++;data={state};}
else if(path.endsWith('/setup/ack')){acks++;assert.equal(body.binding_generation,1);data={accepted:true};}
else if(path.endsWith('/onboarding/devices'))data={items:[{...state,household_name:'测试家庭',preferred_name:'奶奶'}]};
else {failures.push('Unexpected mobile API '+path);await route.abort();return;}
await route.fulfill({contentType:'application/json',body:JSON.stringify({ok:true,code:'OK',data})});
});
await page.goto(base+'/onboarding/entry#token=once-token&verifier=once-verifier');
await page.getByRole('button',{name:'获取验证码',exact:true}).waitFor();assert.equal(exchange,1);assert(!page.url().includes('#'));await shot(page,`${width}-login`);
await page.getByLabel('手机号码(含国家区号)').fill('+8613800000000');await page.getByRole('button',{name:'获取验证码',exact:true}).click();await page.getByRole('button',{name:/秒后可重发/}).waitFor();
await page.getByLabel('短信验证码').fill('123456');await page.locator('button[type=submit]').click();
await page.getByRole('button',{name:'下一步,到设备确认',exact:true}).waitFor();await shot(page,`${width}-profiles`);
await page.getByRole('button',{name:'下一步,到设备确认',exact:true}).click();await page.getByRole('button',{name:/取消/}).waitFor();await shot(page,`${width}-pending`);
await page.getByRole('button',{name:/取消/}).click();await page.getByRole('button',{name:'下一步,到设备确认',exact:true}).waitFor();await page.getByRole('button',{name:'下一步,到设备确认',exact:true}).click();assert.equal(claims,2);
state.binding_state='BOUND';state.binding_generation=1;state.device_revision++;state.activation_state='RECONCILE_REQUIRED';status.binding_id=id(900);
await page.getByText('绑定已完成',{exact:true}).waitFor();await page.getByText('服务开通待核对',{exact:true}).waitFor();await shot(page,`${width}-bound-uncertain`);assert(acks>=1);
await page.getByRole('link',{name:'完成,查看我的设备'}).click();await page.getByText('奶奶',{exact:true}).waitFor();await shot(page,`${width}-devices`);
await context.close();
}
// Actual maintenance portal with an operator OIDC session and scoped APIs.
const context=await browser.newContext({viewport:{width:1440,height:1000}});const page=await context.newPage();page.on('pageerror',e=>failures.push(e.message));let inventoryBody;
const principal={identity:{issuer:'http://localhost:58180/realms/voice-local',subject:'ops-fixture',display_name:'测试运维'},accounts:[{user_id:id(100),tenant_id:id(1),tenant_name:'测试组织',account_kind:'operator',platform_role:'ops_admin',household_memberships:[]}]};
await page.addInitScript(({principal})=>sessionStorage.setItem('oidc.user:http://localhost:58180/realms/voice-local:voice-platform-web',JSON.stringify({access_token:'test-ops',token_type:'Bearer',scope:'openid profile',expires_at:Math.floor(Date.now()/1000)+3600,profile:{iss:principal.identity.issuer,sub:principal.identity.subject}})),{principal});
await page.route('**/api/v1/**',async route=>{const r=route.request(),p=new URL(r.url()).pathname;let data={};if(p==='/api/v1/me')data=principal;else if(p==='/api/v1/ops/device-inventory'){assert.equal(r.headers()['x-tenant-id'],id(1));if(r.method()==='POST'){inventoryBody=r.postDataJSON();data={device_id:'KP01-AABBCCDDEEFF',platform_device_id:id(500),enrollment_job_id:id(600),released:false};}else data={items:[],limit:200};}else if(p==='/api/v1/version')data={version:'test',commit:'test'};else{failures.push('Unexpected ops API '+p);}await route.fulfill({contentType:'application/json',body:JSON.stringify({ok:true,code:'OK',data})});});
await page.route('**/health/**',r=>r.fulfill({contentType:'application/json',body:JSON.stringify({ok:true,data:{status:'ok',dependencies:[]}})}));
await page.goto(base+'/maintenance?area=onboarding');await page.getByRole('button',{name:'登记生产设备',exact:true}).waitFor();await shot(page,'1440-ops');
await page.getByRole('button',{name:'登记生产设备',exact:true}).click();await page.getByRole('dialog').waitFor();await shot(page,'1440-inventory-dialog');
await page.getByLabel('出厂 MAC').fill('AABBCCDDEEFF');await page.getByLabel('硬件版本').fill('A1');await page.getByLabel('生产批次').fill('QA');await page.getByRole('button',{name:'确 定',exact:true}).click();
await page.getByRole('dialog').waitFor({state:'hidden',timeout:10000});assert.deepEqual(inventoryBody,{factory_mac:'AABBCCDDEEFF',hardware_model:'KP01',hardware_revision:'A1',batch_no:'QA'});
await context.close();assert.deepEqual(failures,[]);await writeFile(resolve(output,'results.json'),JSON.stringify({checks,failures},null,2));console.log(JSON.stringify({checks,failures}));
}finally{await browser.close();}
+212
View File
@@ -0,0 +1,212 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { installCustomerFixture } from "./fixtures/customer-mobile.mjs";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const webURL = process.env.VISUAL_WEB_URL ?? "http://127.0.0.1:5173";
const output = resolve(".design-evidence/customer-mobile");
const widths = (process.env.MOBILE_WIDTHS ?? "320,375,390,430,768,900,1024,1440").split(",").map(Number);
const sections = ["home", "conversations", "stories", "subjects", "messages", "members", "devices", "sharing", "privacy", "billing", "profile"];
await mkdir(output, { recursive: true });
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const results = [];
const errors = [];
async function inspect(page, name) {
await page.evaluate(() => document.fonts.ready);
const layout = await page.evaluate(() => {
const viewport = document.documentElement.clientWidth;
const overflows = [...document.querySelectorAll(".customer-page *, .customer-mobile-menu *")].filter((el) => {
const rect = el.getBoundingClientRect();
if (!rect.width || !rect.height || getComputedStyle(el).visibility === "hidden") return false;
// Only these intentionally scrollable regions may exceed their own viewport.
if (el.closest(".usage-heatmap-scroll, .permission-matrix-wrap, .household-tabs, .elder-switcher, .journal-weekly__chart")) return false;
return rect.right > viewport + 2 || rect.left < -2 || (el.scrollWidth > el.clientWidth + 2 && getComputedStyle(el).overflowX === "visible" && !["SPAN", "SVG"].includes(el.tagName));
}).slice(0, 16).map((el) => ({ tag: el.tagName, class: String(el.className), width: el.clientWidth, scroll: el.scrollWidth, text: el.textContent?.slice(0, 45) }));
return { viewport, pageOverflow: document.documentElement.scrollWidth - viewport, overflows };
});
results.push({ name, ...layout });
if (layout.pageOverflow > 2 || layout.overflows.length) console.log(JSON.stringify({ name, ...layout }));
await page.screenshot({ path: resolve(output, `${name}.png`), fullPage: true });
}
async function openSection(page, section) {
await page.goto(`${webURL}/?section=${section}`);
await page.locator(".household-tabs button").first().waitFor();
await page.waitForTimeout(200);
}
async function checkDialogs(page, width) {
const cases = [
["members", "new-household", () => page.getByRole("button", { name: "新增家庭", exact: true })],
["members", "edit-household", () => page.getByRole("button", { name: "编辑家庭名称" })],
["members", "accept-invitation", () => page.locator(".family-accept-button")],
["members", "invite-member", () => page.locator(".family-member-card--invite")],
["subjects", "new-subject", () => page.locator(".subject-add-button")],
["subjects", "edit-subject", () => page.locator("button.subject-profile-card").first()],
["subjects", "subject-operations", () => page.getByRole("button", { name: "资料与授权", exact: true })],
["devices", "bind-device", () => page.locator(".workspace__actions button").filter({ hasText: "绑定设备" })],
["devices", "device-settings", () => page.locator(".device-card__manage").first()],
["stories", "story-review", () => page.locator(".story-candidate-card").first()],
["stories", "memory-review", () => page.locator(".confirmed-memory-card > button").first()],
["sharing", "new-share", () => page.locator(".subject-list-toolbar > button").filter({ hasText: "新建分享" })],
["privacy", "correction", () => page.getByRole("button", { name: "资料更正", exact: true })],
["privacy", "delete-confirmation", () => page.getByRole("button", { name: /申请.*删除|永久删除|删除长者数据|删除全部数据|主体删除/ }).first()],
["privacy", "access-confirmation", () => page.getByRole("button", { name: "提交全量盘点", exact: true })],
["privacy", "withdraw-consents", () => page.getByRole("button", { name: "批量撤回授权", exact: true })],
["privacy", "export-confirmation", () => page.getByRole("button", { name: "生成私有导出", exact: true })],
];
for (const [section, name, trigger] of cases) {
await openSection(page, section);
await trigger().click();
const dialog = page.locator(".workspace-dialog:visible, .subject-create-modal:visible, .subject-detail-modal:visible, .subject-management-operations[open]").last();
await dialog.waitFor();
await page.waitForTimeout(100);
await inspect(page, `${width}-dialog-${name}`);
const bounds = await dialog.evaluate((el) => { const r = el.getBoundingClientRect(); return { top: r.top, bottom: r.bottom, height: window.innerHeight }; });
assert.ok(bounds.top >= -1 && bounds.bottom <= bounds.height + 1, `${name}: dialog must fit vertically`);
const controls = dialog.locator("button:visible");
await controls.last().scrollIntoViewIfNeeded();
const last = await controls.last().boundingBox();
assert.ok(last && last.y >= 0 && last.y + last.height <= page.viewportSize().height + 1, `${name}: last action must be reachable`);
const close = dialog.getByRole("button", { name: /^(关闭.*|取消)$/ }).first();
await close.click();
await dialog.waitFor({ state: "hidden" });
}
await openSection(page, "conversations");
await page.locator(".conversation-row").first().click();
await page.getByRole("button", { name: "查看完整聊天", exact: true }).click();
await inspect(page, `${width}-dialog-conversation`);
const conversation = page.getByRole("dialog");
await conversation.getByRole("button", { name: "关闭", exact: true }).click();
if (width <= 600) {
await page.getByRole("button", { name: "返回聊天列表" }).click();
await page.getByRole("button", { name: "更多筛选" }).click();
await page.getByLabel("开始日期", { exact: true }).fill("2026-08-01");
await inspect(page, `${width}-expanded-conversation-filters`);
}
}
async function checkStates() {
for (const [name, options, expected] of [
["login", { authenticated: false }, "登录家庭陪伴中心"],
["empty", { empty: true }, "还没有可访问的家庭"],
["error", { failure: true }, "家庭数据载入失败"],
]) {
const context = await browser.newContext({ viewport: { width: 320, height: 640 }, isMobile: true, hasTouch: true });
const unexpected = await installCustomerFixture(context, options);
const page = await context.newPage();
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto(webURL);
await page.getByText(expected, { exact: true }).waitFor();
await inspect(page, `320-state-${name}`);
errors.push(...unexpected);
await context.close();
}
const context = await browser.newContext({ viewport: { width: 844, height: 390 }, isMobile: true, hasTouch: true });
const unexpected = await installCustomerFixture(context);
const page = await context.newPage();
page.on("pageerror", (e) => errors.push(String(e)));
for (const section of sections) {
await page.goto(`${webURL}/?section=${section}`);
await page.locator(".household-tabs button").first().waitFor();
await page.waitForTimeout(150);
await inspect(page, `landscape-${section}`);
}
await openSection(page, "sharing");
await page.locator(".subject-list-toolbar > button").click();
await page.setViewportSize({ width: 390, height: 400 });
const dialog = page.getByRole("dialog");
await dialog.getByLabel("分享用途").fill("和家人一起回顾长者的珍贵记忆");
await dialog.getByRole("button", { name: "下一步确认" }).scrollIntoViewIfNeeded();
await inspect(page, "390-short-viewport-form");
const action = await dialog.getByRole("button", { name: "下一步确认" }).boundingBox();
assert.ok(action && action.y >= 0 && action.y + action.height <= 400, "Form action remains reachable in a short viewport");
await page.goto(`${webURL}/?designPreview=1`);
await inspect(page, "390-design-preview");
errors.push(...unexpected);
await context.close();
}
async function checkNavigation(page, width) {
await openSection(page, "home");
const toggle = page.getByRole("button", { name: "打开导航菜单" });
assert.equal(await toggle.getAttribute("aria-expanded"), "false");
await toggle.click();
const drawer = page.locator(".customer-mobile-menu");
await drawer.getByRole("navigation").waitFor();
await page.waitForFunction(() => {
const panel = document.querySelector(".customer-mobile-menu .ant-drawer-content-wrapper");
return panel && Math.abs(panel.getBoundingClientRect().left) < 1;
});
await inspect(page, `${width}-navigation`);
assert.equal(await drawer.getByRole("menuitem").count(), 12);
await page.keyboard.press("Escape");
await drawer.getByRole("navigation").waitFor({ state: "hidden" });
assert.equal(await toggle.evaluate((el) => el === document.activeElement), true, "Drawer restores focus");
await toggle.click();
await drawer.getByRole("menuitem", { name: /数据与授权$/ }).click();
await page.locator(".customer-page--privacy").waitFor();
await drawer.getByRole("navigation").waitFor({ state: "hidden" });
await page.goBack();
await page.locator(".customer-page--home").waitFor();
await toggle.click();
const search = drawer.getByPlaceholder("搜索长者、设备、通话");
await search.fill("设备");
await search.press("Enter");
await page.locator(".customer-page--devices").waitFor();
await drawer.getByRole("navigation").waitFor({ state: "hidden" });
await toggle.click();
await drawer.locator(".ant-drawer-mask").click({ position: { x: width - 5, y: 100 }, force: true });
await drawer.getByRole("navigation").waitFor({ state: "hidden" });
await toggle.click();
await page.setViewportSize({ width: 1280, height: 900 });
await page.locator(".customer-sider").waitFor();
await drawer.getByRole("navigation").waitFor({ state: "hidden" });
await page.setViewportSize({ width, height: 844 });
assert.equal(await toggle.getAttribute("aria-expanded"), "false", "Resize leaves the mobile menu closed");
}
try {
for (const width of widths) {
const context = await browser.newContext({ viewport: { width, height: width < 600 ? 844 : 1024 }, isMobile: width < 600, hasTouch: width <= 900, reducedMotion: "reduce" });
const unexpected = await installCustomerFixture(context);
const page = await context.newPage();
page.setDefaultTimeout(8000);
page.on("pageerror", (e) => errors.push(String(e)));
for (const section of process.env.MOBILE_INTERACTIONS_ONLY ? [] : process.env.MOBILE_SECTIONS?.split(",") ?? sections) {
await page.goto(`${webURL}/?section=${section}`);
await page.locator(".customer-account-button").waitFor();
if (section !== "profile") await page.locator(".household-tabs button").first().waitFor();
await page.waitForTimeout(200);
if (section === "conversations") {
await page.locator(".conversation-row").first().click();
await page.locator(".memory-summary-panel").waitFor();
await page.waitForTimeout(150);
}
if (section === "billing" && width <= 600) {
const scroll = await page.locator(".usage-heatmap-scroll").evaluate((el) => ({ left: el.scrollLeft, width: el.clientWidth, full: el.scrollWidth }));
assert.ok(scroll.left + scroll.width >= scroll.full - 2, "Mobile calendar opens at the latest dates");
await page.locator(".usage-heatmap button").last().click();
assert.equal(await page.locator(".usage-heatmap button.selected").count(), 1);
}
await inspect(page, `${width}-${section}`);
}
if (!process.env.MOBILE_SECTIONS && [320, 390, 768].includes(width)) await checkDialogs(page, width);
if (!process.env.MOBILE_SECTIONS && width === 390) await checkNavigation(page, width);
errors.push(...unexpected);
await context.close();
console.log(`Checked ${width}px`);
}
if (!process.env.MOBILE_INTERACTIONS_ONLY && !process.env.MOBILE_SECTIONS) await checkStates();
} finally {
await browser.close();
const failures = results.filter((r) => r.pageOverflow > 2 || r.overflows.length);
await writeFile(resolve(output, "report.json"), JSON.stringify({ results, errors, failures }, null, 2));
console.log(JSON.stringify({ checks: results.length, failures: failures.length, errors }));
}
assert.equal(errors.length, 0, "Browser errors or missing fixtures");
assert.equal(results.filter((r) => r.pageOverflow > 2 || r.overflows.length).length, 0, "Customer layouts must fit the viewport");

Some files were not shown because too many files have changed in this diff Show More