chore: establish local platform baseline

This commit is contained in:
Codex Local
2026-08-26 06:41:54 +08:00
commit 29bdf4900f
575 changed files with 95644 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.github
.env
coverage.out
bin
tmp
web/node_modules
web/dist
*.log
+103
View File
@@ -0,0 +1,103 @@
# 仅用于本地合成数据。不要填入生产密钥或真实个人数据。
# 敏感配置同时支持NAME_FILE=/容器内绝对路径;NAME与NAME_FILE只能设置一个。
APP_ENV=local
LOG_LEVEL=debug
HTTP_ADDR=0.0.0.0:58080
API_PORT=58080
CALLBACKS_PORT=58081
WORKER_PORT=58082
WEB_PORT=3100
OTEL_GRPC_PORT=54317
PROMETHEUS_PORT=59190
MOCK_PROVIDER_PORT=59200
POSTGRES_PORT=55432
REDIS_PORT=56379
MINIO_API_PORT=59000
MINIO_CONSOLE_PORT=59001
DATABASE_URL=postgres://voice:voice_dev_only@localhost:55432/voice_platform?sslmode=disable
DATABASE_URL_FILE=
REDIS_ADDR=localhost:56379
REDIS_PASSWORD=
REDIS_PASSWORD_FILE=
REDIS_DB=0
DEPENDENCY_TIMEOUT=2s
EXTERNAL_PROBE_INTERVAL=30s
SHUTDOWN_TIMEOUT=10s
WORKER_POLL_INTERVAL=2s
WEB_OIDC_AUTHORITY=http://localhost:58180/realms/voice-local
WEB_OIDC_CLIENT_ID=voice-platform-web
AUTH_ISSUER_URL=http://localhost:58180/realms/voice-local
AUTH_JWKS_URL=http://localhost:58180/realms/voice-local/protocol/openid-connect/certs
AUTH_AUDIENCE=voice-platform-web
OTEL_EXPORTER_OTLP_ENDPOINT=localhost:54317
RATE_LIMIT_RPS=50
RATE_LIMIT_BURST=100
RATE_LIMIT_USER_RPS=20
RATE_LIMIT_USER_BURST=40
RATE_LIMIT_TENANT_RPS=200
RATE_LIMIT_TENANT_BURST=400
RATE_LIMIT_DEVICE_RPS=10
RATE_LIMIT_DEVICE_BURST=20
PROVIDER_START_RPS=20
PROVIDER_START_BURST=40
PROVIDER_MAX_ACTIVE_TASKS=100
DEVICE_LOCAL_SECRET=local-simulated-device-secret-32-bytes-minimum
DEVICE_SECRET_DIRECTORY=
DEVICE_CLOCK_SKEW=5m
RTC_APP_ID=local-public-rtc-app
RTC_APP_KEY=
RTC_APP_KEY_FILE=
RTC_TOKEN_ISSUER=local
RTC_TOKEN_SIGNING_SECRET=local-simulated-rtc-token-secret-32-bytes-minimum
CURSOR_SIGNING_SECRET=local-signed-pagination-cursor-secret-32-bytes-minimum
SHARE_TOKEN_SIGNING_SECRET=local-share-token-signing-secret-32-bytes-minimum
RTC_TOKEN_TTL=5m
CONVERSATION_ALLOCATION_TTL=30s
HEARTBEAT_INTERVAL_SECONDS=15
MOCK_PROVIDER_BASE_URL=http://localhost:59200
MOCK_PROVIDER_API_KEY=local-mock-provider-key
VOICE_PROVIDER=mock
VOLC_RTC_BASE_URL=https://rtc.volcengineapi.com
VOLC_ACCESS_KEY_ID=
VOLC_SECRET_ACCESS_KEY=
VOLC_SESSION_TOKEN=
VOLC_ACCESS_KEY_ID_FILE=
VOLC_SECRET_ACCESS_KEY_FILE=
VOLC_SESSION_TOKEN_FILE=
VOLC_RTC_REGION=cn-north-1
VOLC_RTC_API_VERSION=2024-12-01
VOLC_VOICE_CHAT_TEMPLATE_FILE=
PROVIDER_CALLBACK_SECRET=local-provider-callback-secret-32-bytes-minimum
PROVIDER_CALLBACK_CLOCK_SKEW=5m
PROVIDER_REQUEST_TIMEOUT=5s
PROVIDER_FAILURE_THRESHOLD=3
PROVIDER_CIRCUIT_COOLDOWN=1m
RECOVERY_SCAN_INTERVAL=5s
RECOVERY_STARTING_AFTER=20s
RECOVERY_STOPPING_AFTER=20s
OBJECT_STORE_ENDPOINT=localhost:59000
OBJECT_STORE_PUBLIC_ENDPOINT=localhost:59000
OBJECT_STORE_ACCESS_KEY=local_minio
OBJECT_STORE_SECRET_KEY=local_minio_password
OBJECT_STORE_EXPORT_BUCKET=private-exports
OBJECT_STORE_REGION=us-east-1
OBJECT_STORE_SECURE=false
EXPORT_DOWNLOAD_TTL=10m
EXPORT_OBJECT_TTL=24h
ALERT_WEBHOOK_URL=
ALERT_WEBHOOK_SECRET=
ALERT_WEBHOOK_TIMEOUT=5s
CONTENT_GENERATOR=local
CONTENT_SAFETY_PROVIDER=local
CONTENT_SAFETY_ENDPOINT=
CONTENT_SAFETY_API_KEY=
CONTENT_SAFETY_API_KEY_FILE=
CONTENT_SAFETY_POLICY_REF=
CONTENT_SAFETY_TIMEOUT=5s
ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
ARK_API_KEY=
ARK_API_KEY_FILE=
ARK_CONTENT_MODEL=
ARK_REQUEST_TIMEOUT=30s
EMBEDDING_PROVIDER=local
ARK_EMBEDDING_MODEL=
+35
View File
@@ -0,0 +1,35 @@
* text=auto
.gitattributes text eol=lf
.gitignore text eol=lf
.dockerignore text eol=lf
.env.example text eol=lf
*.go text eol=lf
*.mod text eol=lf
*.sum text eol=lf
*.sh text eol=lf
*.sql text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.json text eol=lf
*.toml text eol=lf
*.md text eol=lf
*.html text eol=lf
*.css text eol=lf
*.js text eol=lf
*.mjs text eol=lf
*.cjs text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.Dockerfile text eol=lf
Dockerfile text eol=lf
*.ps1 text eol=crlf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.zip binary
*.pdf binary
+86
View File
@@ -0,0 +1,86 @@
name: ci
on:
push:
pull_request:
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: web/package-lock.json
- name: Check OpenAPI breaking changes
if: github.event_name == 'pull_request'
run: |
git fetch --no-tags origin "${GITHUB_BASE_REF}" --depth=1
git show "origin/${GITHUB_BASE_REF}:openapi/openapi.yaml" > /tmp/openapi-base.yaml
go run github.com/oasdiff/oasdiff@v1.29.1 breaking /tmp/openapi-base.yaml openapi/openapi.yaml --fail-on ERR
- name: Go format
run: test -z "$(gofmt -l cmd internal)"
- name: Go vet and test
run: |
go vet ./...
go test -race -coverprofile=coverage.out ./...
- name: Generate API types and build web
working-directory: web
run: |
npm ci
npm run generate:api
npm run typecheck
npm run build
- name: Validate compose
run: docker compose config --quiet
- name: Build backend image
run: docker build -f deploy/backend.Dockerfile -t data-terminal-cloud-backend:test .
- name: Build web image
run: docker build -f deploy/web.Dockerfile -t data-terminal-cloud-web:test .
- name: Go vulnerability and license policy
run: |
go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
go install github.com/google/go-licenses/v2@v2.0.1
govulncheck ./...
go-licenses check --ignore data-terminal-cloud ./cmd/...
- name: Frontend vulnerability and license policy
working-directory: web
run: |
npm audit --audit-level=high
npx --yes license-checker-rseidelsohn@5.0.1 --production --failOn 'GPL-2.0;GPL-3.0;AGPL-1.0;AGPL-3.0;SSPL-1.0;BUSL-1.1'
- name: Repository secret scan
run: >-
docker run --rm -v "$PWD:/src" zricethezav/gitleaks:v8.30.1@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f
detect --source=/src --config=/src/.gitleaks.toml --no-banner
- name: Infrastructure configuration scan
run: >-
docker run --rm -v "$PWD:/src" aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969
config --exit-code 1 --severity HIGH,CRITICAL /src
- name: Image vulnerability scan
run: |
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969 image --exit-code 1 --ignore-unfixed --severity HIGH,CRITICAL data-terminal-cloud-backend:test
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969 image --exit-code 1 --ignore-unfixed --severity HIGH,CRITICAL data-terminal-cloud-web:test
- name: Generate CycloneDX software bills of materials
run: |
mkdir -p artifacts
docker run --rm -v "$PWD:/src" anchore/syft:v1.51.0@sha256:678bfa565b60f747aac0f8e964fe5588a24445b8d0a480e91f6efd70020dfbb0 dir:/src -o cyclonedx-json=/src/artifacts/source.cdx.json
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v "$PWD/artifacts:/artifacts" anchore/syft:v1.51.0@sha256:678bfa565b60f747aac0f8e964fe5588a24445b8d0a480e91f6efd70020dfbb0 data-terminal-cloud-backend:test -o cyclonedx-json=/artifacts/backend-image.cdx.json
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v "$PWD/artifacts:/artifacts" anchore/syft:v1.51.0@sha256:678bfa565b60f747aac0f8e964fe5588a24445b8d0a480e91f6efd70020dfbb0 data-terminal-cloud-web:test -o cyclonedx-json=/artifacts/web-image.cdx.json
- name: Record immutable build metadata
run: |
backend_digest="$(docker image inspect --format='{{.Id}}' data-terminal-cloud-backend:test)"
web_digest="$(docker image inspect --format='{{.Id}}' data-terminal-cloud-web:test)"
jq -n --arg source_commit "$GITHUB_SHA" --arg run_id "$GITHUB_RUN_ID" --arg run_attempt "$GITHUB_RUN_ATTEMPT" --arg backend_digest "$backend_digest" --arg web_digest "$web_digest" '{source_commit:$source_commit,run_id:$run_id,run_attempt:$run_attempt,backend_image_digest:$backend_digest,web_image_digest:$web_digest}' > artifacts/build-metadata.json
- uses: actions/upload-artifact@v4
with:
name: security-and-sbom
path: artifacts/*
+18
View File
@@ -0,0 +1,18 @@
.env
.idea/
.vscode/
.DS_Store
*.log
coverage.out
coverage*.out
*.coverage.out
/coverage
*.exe
/opsconfig
bin/
dist/
tmp/
web/node_modules/
web/dist/
.gocache/
test/e2e/screenshots/
+11
View File
@@ -0,0 +1,11 @@
title = "Data terminal cloud secret scanning"
[extend]
useDefault = true
[allowlist]
description = "HTTP idempotency keys are replay identifiers, not credentials"
condition = "AND"
regexTarget = "line"
regexes = ['''Idempotency-Key''']
paths = ['''(^|/)internal/httpapi/conversation_handlers_test\.go$''']
+337
View File
@@ -0,0 +1,337 @@
# 数据采集终端—云端系统
这是依据《数据采集终端—云端系统研发需求规格说明书》建立的正式产品工程。当前已完成工程框架、公共能力、本地模拟语音闭环、家庭用户SaaS核心业务、字幕到人工确认记忆闭环,以及隐私、可靠设备命令、签名固件灰度、双轨录音、认证SSE状态事件流、具体资源分享、运营配置灰度发布、用量费用额度、告警处置、集成诊断和敏感数据临时提权闭环。
## 当前已实现
- Go模块化单体的`api`、`callbacks`和`worker`三个进程入口。
- 统一环境配置、JSON结构化日志、请求关联编号、请求体限制、安全响应头和优雅停机。
- PostgreSQL、pgvector和Redis连接及存活/就绪健康检查。
- OpenAPI 3.1基线与React类型生成。
- 设备API兼容门禁:拉取请求自动检查OpenAPI破坏性变化,v2新增行为使用显式可选能力位,不依赖固件`User-Agent`;终端不根据云端`engine_mode`改变采集或播放。
- React/TypeScript/Vite研发工作台,覆盖加载、失败、重试和状态展示。
- Web接口统一具备15秒超时、页面切换取消和明确的超时重试提示,避免操作长期悬挂。
- Web入口HTML禁止陈旧缓存,哈希静态资源长期缓存,并返回CSP、防嵌套、MIME嗅探和浏览器权限安全头。
- PostgreSQL首批迁移:扩展、租户、用户、审计、幂等记录和outbox可靠待办。
- Keycloak OIDC授权码+PKCE登录、`issuer + subject`账号映射和家庭角色权限。
- Web登录会话在访问令牌到期前自动续期;续期失败或令牌过期时立即退出业务视图并提示重新登录。
- 幂等并发串行化、事务审计、outbox领取/重试/死信和进程崩溃锁恢复。
- OpenTelemetry分布式追踪、Prometheus HTTP/依赖/outbox指标与结构化日志关联。
- Redis令牌桶限流、HMAC签名游标分页和ETag/If-Match乐观并发组件。
- 每设备独立请求签名、防重放、启动握手、版本化配置读取和可运行模拟设备。
- 逐设备生产密钥:设备表只保存`device-secret://<name>`引用,API从受控Secrets/KMS CSI目录按请求读取对应密钥;引用禁止路径穿越,密钥轮换原子替换文件后无需重启服务,本地模拟仍使用静态开发密钥。
- 两阶段通话主链:事务冻结授权/背景、房间分配、确定性短期Token、入房确认和可靠启动。
- 智能体启动结果闭环:设备入房后读取`wait/continue/leave_room`权威动作;供应商永久拒绝启动时通话、RTC任务、状态历史和审计原子收敛,模拟终端按稳定失败码及时退房。
- 背景资料准备失败闭环:白名单字段类型异常时保存`FAILED/context_preparation_failed`权威通话,不创建RTC房间;设备422、幂等重放、家庭详情和运营诊断展示同一结果。
- 活动通话授权撤回闭环:撤回通话正在使用的字幕、原录音、故事整理或个性化记忆授权时,同一事务保存停止状态和可靠Agent停止任务;设备心跳/遥测取得停止动作。
- 可运行模拟语音供应商:固定任务幂等受理、签名异步回调、重复事件去重和`ACTIVE`状态闭环。
- 序列化通话心跳:PostgreSQL权威租约、Redis热租约和设备在线状态,重复/乱序请求不续租。
- 可靠停止与终态收敛:固定任务Stop、结束/失败回调、幂等重放和租约释放。
- 后台异常回收:预留过期、心跳超时、启动/停止卡住任务扫描,对账动作和系统审计。
- 家庭、长者、稳定事实和五类分项授权首批网页API:服务端租户隔离、家庭角色能力、强ETag、事务审计、授权追加历史、撤回幂等和不可变授权快照。
- 设备列表、详情、在线状态、设备影子配置和一次性绑定码:非敏感配置白名单、敏感字段拒绝、Redis实时状态降级和安全重复绑定。
- 家庭业务React工作台:家庭切换、长者档案、稳定事实登记/确认、分项授权/撤回、设备绑定与配置、通话列表/详情及角色只读控制。
- 角色化业务导航:家庭用户只加载家庭业务,运营角色可按需切换运行配置、费用额度、告警处置和联调诊断,避免无权限页面与全部工作台同时加载。
- 家庭成员协作:成员列表、一次性邀请/撤销/接受、跨客户组织受邀账号映射、角色变更、成员移除和家庭所有权转移。
- 通话查询API:长者维度游标分页、状态过滤、脱敏详情、冻结快照摘要和完整状态历史。
- 长者扩展档案、稳定事实、授权记录、设备绑定、最终话轮、摘要、故事版本和已确认记忆数据库模型;V8迁移已完成空库升降级验证。
- 模型内容候选化:只使用可追溯长者话轮生成候选;人工确认和当前有效记忆授权共同控制长期记忆写入。
- 可切换内容生成器:本地使用确定性适配器,生产可通过火山方舟OpenAI兼容接口生成结构化摘要、开放话题、故事和事实候选;模型网络调用不占用数据库事务,写入前重新核对通话状态、授权和话轮指纹,敏感内容在本地拦截后不发送外部模型。
- 新通话在数据库层按租户、长者、当前授权、状态、敏感级别、有效期和索引状态检索已确认记忆,并把实际版本冻结到背景快照。
- 可切换记忆向量:本地使用1536维确定性向量,生产可通过火山方舟OpenAI兼容`/embeddings`接口完成索引和查询;两端共用同一模型及维度,外部调用均位于数据库事务外,查询服务临时不可用时通话可无记忆降级启动。
- 隐私数据访问请求:所有者幂等提交、Outbox/Worker子任务、八类数据范围盘点、无正文结果和审计。
- 私有数据导出:按长者和分类生成JSON,MinIO私有存储、SHA-256校验、短期签名下载和过期对象清理。
- 主体级隐私删除:精确称呼确认、活动通话保护、立即冻结读取/上传/召回,以及PostgreSQL、Redis、对象存储、pgvector、外部索引和私有导出六子系统的可重试对账与最小删除证明。
- 设备命令可靠链路:家庭端幂等排队、设备HMAC主动拉取、重复投递计数、确认/成功/失败回执和过期终态;Web设备卡片可下发诊断命令。
- 固件安全发布与管理页:Ed25519受信公钥、浏览器私有直传、大小与SHA-256校验、签名清单验证、确定性灰度、可靠命令、短期下载、暂停、已验证版本回滚,以及逐设备下载/安装/确认进度。
- 双轨录音媒体链路:near/far分片清单幂等、15分钟单对象直传、Outbox可靠校验、WAV PCM16容器/时长/大小/SHA-256核验、延迟删除本地密文及设备回执,以及PLAY/DOWNLOAD独立授权、授权列表/撤销、React双轨播放下载、5分钟私有访问和成功/拒绝审计。
- 联调问题闭环:按客户组织记录问题现象、严重级别、设备/通话/请求编号、责任方、根因、修复版本和脱敏回归证据;未关闭HIGH/CRITICAL形成发布阻断汇总,创建幂等、更新强ETag并全程审计。
- 认证SSE状态事件流:业务事务内生成最小事件,15分钟`Last-Event-ID`续传、活动家庭成员过滤、跨租户游标拒绝、重复事件抑制、设备上线去重/超时离线、React自动重连和2秒合并刷新,避免连续事件放大为整页请求风暴。
- 具体资源分享闭环:故事、通话文字和录音分片的登录定向或匿名口令授权、幂等创建、授权历史、撤销/过期、跨租户拒绝、故事共享编辑与强并发控制、5分钟录音短期地址、逐次访问审计及React管理页。
- 内部运营配置:独立平台运营角色、五类不可变版本、结构与敏感键校验、离线评测证据、受控凭据/音色/模型引用、双人审批、租户/试验对象/设备组灰度、稳定分桶、并发影响上限、投放台账、资源撤销和不可变回滚;新通话按范围优先级命中并冻结实际路由配置编号。
- 用量费用与额度:供应商七类最终用量签名回调、事件和计量双层幂等、不可变价格版本与双人发布、费用行版本/单价冻结、运营周期汇总、家庭通话用量展示,以及客户组织/设备日月费用、并发通话和最长时长硬额度。
- 真实RTC/语音供应商适配:本地使用确定性开发Token与Mock任务协议;生产可切换为火山RTC正式Token格式和经官方Go SDK签名的`StartVoiceChat/StopVoiceChat`,稳定任务编号、超时重试和停止幂等语义保持不变;冻结的`s2s_pure`、`s2s_hybrid`或`cascade`模式会选择各自独立的供应商模板。
- 生产启动安全门禁:生产环境强制OIDC HTTPS、数据库传输安全、Redis认证、对象存储TLS、火山RTC正式Token、签名VoiceChat OpenAPI、非占位且相互独立的签名密钥,并拒绝本地模拟供应商配置。
- k6研发压测入口和Prometheus基础告警:覆盖服务可用性、5xx、p95、Outbox积压/死信、隐私部分失败及固件失败/停滞。
- 分层限流和供应商容量保护:分别按网络、用户、客户组织和设备限流,并对供应商启动RPS与跨Worker活动任务数执行保护。
- 后台任务仅重试显式临时错误:网络、超时、供应商临时错误、对象暂不可见和可恢复数据库错误采用带随机变化的指数退避;永久错误立即进入死信。
- 集成诊断工作台:按设备、通话、房间、供应商任务和请求编号精确检索脱敏状态,并提供配置刷新、安全停止、状态对账、设备吊销、已注册死信受控重试和脱敏JSON诊断包。
- 敏感数据临时提权:按单次通话申请字幕读取/录音播放,最长4小时、申请人禁止自批、另一人审批、到期自动失效、5分钟录音短链及逐次成功/拒绝审计。
- Grafana业务、技术、安全三类仪表盘,以及供应商通知积压和证书30天/7天到期监测。
- 告警Webhook通知与联通测试:未静默的新告警和超时升级与事件记录同事务进入Outbox,Worker按临时/永久错误分类重试,并使用稳定事件编号和HMAC-SHA256签名投递;运营人员可针对活动路由发起不生成正式告警的联通测试,查询投递状态并受控重试失败任务。
- 告警接收器一致性报告:离线核对全部活动路由、云端`DELIVERED`任务、真实网关唯一验签回执、主/升级值班组映射和一次正式演练;本地模拟报告固定不能放行阶段8真实接收器门禁。
- KMS/HSM轮换一致性报告:绑定候选构建、工作负载身份、提供器版本、云审计、新旧凭据认证和业务连续性,同时覆盖动态读取与滚动重启;本地环境密钥演练固定不能放行真实云密钥门禁。
- 生产恢复一致性报告:同一候选必须覆盖PostgreSQL恢复、Redis重建、三个服务滚动重启和供应商停机恢复,并证明数据一致、业务连续和无重复效果;本地恢复子集不能放行生产门禁。
- 外部基础设施探针:API周期校验OIDC JWKS可读取且至少包含一把公钥;生产API/Worker对方舟和火山RTC执行无业务副作用的DNS、TCP和证书校验TLS握手,结果进入Prometheus且不因供应商短时故障驱逐健康Pod。
- 生产基础设施一致性报告:按同一候选核对域名TLS、OIDC、PostgreSQL、Redis、对象存储、网络隔离和监控的真实控制项及证据引用;本地Compose可达性不会放行生产门禁。
- Safari兼容性一致性报告:以两个真实Safari版本、两种视口和六条关键流程组成24项精简矩阵;Chrome/Edge或Playwright WebKit不能冒充真实Safari验收。
- 真实终端RTC一致性报告:至少3台候选终端以6通精简矩阵覆盖三种语音方式正常闭环、凭证续签、网络切换恢复和受控停止;模拟供应商不能放行。
- 真实Agent模型一致性报告:重新计算已有10类Agent固定评测,并绑定Embedding六类召回/隔离、内容安全六类策略、数据条款和额度;本地模型不能放行。
- 远程CI供应链一致性报告:绑定完整源提交、托管CI运行、14项构建与安全检查、后端/Web镜像摘要和源码/双镜像三份CycloneDX SBOM;本地工具链不能放行。
- 发布候选证据包:技术验收快照通过后,把十类外部证据索引、OpenAPI、迁移、Kubernetes清单、关键交付文档和可用SBOM封装为逐文件SHA-256清单ZIP;真机固件门禁要求至少3台设备各完成4个硬件场景,`PILOT`类型还强制携带同租户、同候选版本/构建摘要、同自然周的7份日报和1份完整周报,任何缺口保持待验,不会把本地模拟冒充生产验收。
- 试点运行日报:绑定候选版本与不可变构建摘要后,按IANA业务时区自然日下载设备、通话、供应商、上传、告警和分币种费用的脱敏快照;日报有租户隔离、幂等和审计,但不保存服务端正文,也不代替真机体验、供应商账单或发布审批。
- 试点运行周报:绑定同一候选构建后,按周一开始的业务自然周下载内容审核、记忆、隐私请求、固件和配置差异;比例同时展示样本,无样本不伪造0%,投诉因无平台权威表固定要求附客服或CRM证据。
- Docker Compose本地环境:PostgreSQL、Redis、MinIO、Keycloak、迁移、三个后端进程、模拟供应商和Web。
- Kubernetes/Kustomize预生产与生产基线:API、Callbacks、Worker、Web、独立迁移Job、Secret只读卷、探针、资源限额、滚动发布、PDB/HPA、Ingress和入口网络隔离;清单不包含真实密钥。
- Secrets Store CSI生产覆盖层:应用和迁移Job可直接挂载外部KMS/Secret Manager内容,不经过普通ConfigMap或明文环境变量;逐设备密钥支持挂载后即时轮换,其他进程级密钥通过受控滚动重启生效。
- 本地检查脚本与持续集成基础流水线。
## 快速启动
前置条件:Go 1.26、Node.js 24、Docker Desktop与Docker Compose。
完整容器环境:
```powershell
Set-Location E:\Codex\story\platform
.\scripts\dev.ps1
```
启动成功后访问:
- Web:<http://localhost:3100>
- API存活检查:<http://localhost:58080/health/live>
- API就绪检查:<http://localhost:58080/health/ready>
- MinIO控制台:<http://localhost:59001>
- Keycloak:<http://localhost:58180>
- Prometheus:<http://localhost:59190>
- Grafana:<http://localhost:59300>
- 模拟语音供应商存活检查:<http://localhost:59200/health/live>
- 模拟告警接收器存活检查:<http://localhost:59210/health/live>
本地Web点击“登录研发环境”后可使用`owner`、`editor`或`outsider`三个合成账号,密码均为`LocalOnly123!`。账号只用于本机联调,权限说明见[家庭角色权限矩阵](docs/permission-matrix.md)。
Web镜像在容器启动时根据`WEB_OIDC_AUTHORITY`和`WEB_OIDC_CLIENT_ID`生成不缓存的`runtime-config.js`。dev、preprod和prod可复用同一镜像,只需为各环境设置对应OIDC地址和客户端编号;这些值是浏览器可见的公开配置,不能用于保存客户端密钥。
真实火山RTC模式由两组独立开关启用:API设置`RTC_TOKEN_ISSUER=volcengine`、`RTC_APP_ID`和`RTC_APP_KEY`,Worker设置`VOICE_PROVIDER=volcengine`、`VOLC_ACCESS_KEY_ID`、`VOLC_SECRET_ACCESS_KEY`、区域、接口版本以及`VOLC_VOICE_CHAT_TEMPLATE_FILE`。生产模板采用`voice-chat-template-bundle-v1`,分别提供`s2s_pure`、`s2s_hybrid`和`cascade`三个完整VoiceChat `Config/AgentConfig`;平台按通话冻结模式选择模板,再覆盖AppId、RoomId、TaskId、目标设备用户、智能体用户,并追加本次已授权且脱敏的背景摘要。可从[模板示例](deploy/volcengine/voice-chat-template.example.json)复制结构并按照已开通的官方能力替换每个完整`Config`对象;不要把AppKey、AK/SK、ASR/TTS Token提交到仓库。
生产API必须设置`DEVICE_SECRET_DIRECTORY`为容器内绝对路径,并把Docker/Kubernetes Secret或云KMS CSI卷只读挂载到该目录。每台设备的`credential_ref`写为`device-secret://terminal-001`,对应文件名为`terminal-001`,文件内容为至少32字节的独立随机密钥。解析器拒绝相对路径、子目录、越界符号、缺失文件和短密钥;每次验签重新读取文件,因此密钥文件原子轮换后立即生效。`DEVICE_LOCAL_SECRET`只允许本地模拟使用。
数据库连接串、Redis、RTC AppKey、火山AK/SK、方舟API Key、对象存储凭据、回调/告警签名等敏感配置同时支持`NAME_FILE=/run/secrets/name`。文件路径必须是容器内绝对路径、目标必须是普通文件且不超过64KiB;读取时只去掉末尾换行,不在错误中打印内容。`NAME`与`NAME_FILE`同时存在会拒绝启动,防止轮换时仍意外使用旧的明文环境变量。
独立启动后端时,告警外部通知在未配置地址时安全关闭;本地Compose默认连接仓库内的验签模拟接收器,便于验证真实Worker投递而不访问外部网络。接入企业协作平台或通知网关时,为Worker设置`ALERT_WEBHOOK_URL`、至少32字符的`ALERT_WEBHOOK_SECRET`和可选的`ALERT_WEBHOOK_TIMEOUT`;生产地址必须使用HTTPS。接收端应使用`X-Voice-Event-ID`去重,并按`v1=HMAC-SHA256(secret, timestamp + "." + body)`校验`X-Voice-Signature`。运营工作台可针对活动路由发起联通测试;测试原因只写审计,不发送给接收端。
外部依赖探针默认每30秒执行一次,可通过`EXTERNAL_PROBE_INTERVAL`调整。OIDC探针验证JWKS HTTP状态、JSON格式和非空密钥集;方舟、火山RTC及托管内容安全探针只验证DNS、TCP、TLS 1.2以上协议和受信证书,不调用推理、创建房间、启动语音任务或发送待分类正文。探针失败只产生指标与告警,不进入Kubernetes readiness,避免供应商故障引发Pod重启风暴。
内容生成默认使用`CONTENT_GENERATOR=local`。接入火山方舟时设置`CONTENT_GENERATOR=ark`、`ARK_BASE_URL`、`ARK_API_KEY`、`ARK_CONTENT_MODEL`和`ARK_REQUEST_TIMEOUT`;Worker调用`${ARK_BASE_URL}/chat/completions`。生产环境拒绝本地生成器、HTTP地址、占位API Key或本地模型编号。
内容安全默认使用`CONTENT_SAFETY_PROVIDER=local`作为开发基线。生产Worker必须配置`managed_http`、HTTPS端点、独立API Key、经批准的策略版本和超时;高风险输入先在本地拦截,其余输入及模型输出通过托管策略复核,策略服务异常时停止生成和落库。协议、错误语义与接入步骤见[《生产内容安全服务适配》](docs/content-safety-provider.md)。
进入目标环境联调前,可运行`scripts/stage8-preflight.ps1`检查候选提交、远程仓库、Compose本地基线和十类外部证据索引状态。预检不会读取密钥值或把本地模拟结果视为外部验收通过,详细说明见[《真机、真实供应商、试点与生产发布外部验收清单》](docs/external-acceptance-checklist.md)。
记忆向量默认使用`EMBEDDING_PROVIDER=local`。接入方舟时,API和Worker必须同时设置`EMBEDDING_PROVIDER=ark`、同一个`ARK_BASE_URL`、`ARK_API_KEY`和`ARK_EMBEDDING_MODEL`;平台请求并强校验1536维向量。生产环境拒绝本地向量提供器,避免索引向量和查询向量落入不同空间。
生产集群部署清单位于[`deploy/kubernetes`](deploy/kubernetes),详细的镜像构建、外部依赖、Secret/CSI文件名、迁移、发布、轮换、检查和回滚步骤见[《Kubernetes部署手册》](docs/kubernetes-deployment.md)。基线中的`.example.invalid`、全零RTC App ID和`replace-with-*`均是故意保留的不可用占位值,生产启动门禁会拒绝这些值,必须由环境覆盖层替换后才能发布。
只启动基础依赖并在宿主机开发:
```powershell
docker compose up -d postgres redis minio keycloak migrate
go run ./cmd/api
```
宿主机默认使用API端口`58080—58082`、PostgreSQL端口`55432`、Redis端口`56379`、OTLP端口`54317`和Prometheus端口`59190`,避免与开发机现有服务冲突;可在`.env`中覆盖。容器间仍使用各组件的标准端口。
另开终端启动前端:
```powershell
Set-Location E:\Codex\story\platform\web
npm ci
npm run generate:api
npm run dev
```
## 质量检查
日常开发默认执行轻量功能就绪检查:
```powershell
Set-Location E:\Codex\story\platform
.\scripts\check-functional.ps1
```
该脚本检查必要容器、Web与三个后端进程、模拟供应商、模拟告警接收器、数据库/Redis/对象存储/OIDC依赖、Outbox积压和近期错误日志。功能改动再配合对应Go包测试或单项烟测,不要求每次执行全量回归。
发布候选或CI执行全量质量门禁:
```powershell
Set-Location E:\Codex\story\platform
.\scripts\check.ps1
```
该脚本执行Go格式检查、静态检查、单元测试、OpenAPI类型生成、前端类型检查、前端构建和Compose语法检查。
阶段8候选证据包的输入、十类外部门禁、真机固件场景覆盖、状态语义、归档结构和命令见[《发布候选证据包说明》](docs/release-evidence-bundle.md),真实终端RTC六通矩阵见[《真实终端与RTC/语音供应商一致性证据》](docs/real-device-rtc-evidence.md),生成/Embedding/内容安全见[《真实Agent模型质量一致性证据》](docs/real-agent-models-evidence.md),生产七类组件采集见[《生产基础设施一致性证据》](docs/production-infrastructure-evidence.md),真实Safari精简矩阵见[《Safari兼容性一致性证据》](docs/safari-compatibility-evidence.md)。可先执行`.\scripts\smoke-release-evidence.ps1`验证本地真实OIDC快照到ZIP清单的纵向链路;该烟测不会把外部待验项改为通过。试点每日证据见[《试点运行日报开发说明》](docs/pilot-daily-operations.md),周度复盘口径、外部投诉缺口、接口和轻量烟测见[《试点运行周报开发说明》](docs/pilot-weekly-review.md)。
完整本地环境运行后执行身份与数据库集成冒烟:
```powershell
.\scripts\smoke-auth.ps1
```
执行设备签名、启动和配置联调:
```powershell
docker compose --profile simulation run --rm sim-device
```
执行模拟设备断网重连、完整通话、重复请求、字幕补交、遥测和命令场景:
```powershell
docker compose --profile simulation run --rm -e SIM_SCENARIO=offline,conversation,transcript,telemetry,commands sim-device
```
还可用`SIM_SCENARIO=media`和`SIM_CONVERSATION_ID`验证已授权通话的录音直传、后台校验、延迟删除及设备删除回执;详见`docs/device-integration.md`。
执行设备状态、错误和命令结果的批量遥测、幂等、事件判重及冲突回滚烟测:
```powershell
.\scripts\smoke-device-telemetry.ps1
```
执行创建、激活、供应商启动、重复回调、心跳租约、停止和最终`COMPLETED`状态的完整冒烟:
```powershell
.\scripts\smoke-conversation.ps1
```
执行家庭、长者、授权、角色隔离、ETag和授权快照纵向冒烟:
```powershell
.\scripts\smoke-saas.ps1
```
执行设备读取、在线状态、影子配置、敏感字段拒绝和一次性绑定码烟测:
```powershell
.\scripts\smoke-device-management.ps1
```
执行成员邀请、跨客户组织接受、角色并发、所有权保护与转移烟测:
```powershell
.\scripts\smoke-members.ps1
```
执行隐私数据访问请求的权限、幂等、后台子任务和范围盘点烟测:
```powershell
.\scripts\smoke-privacy.ps1
```
执行私有桶、导出范围、短期签名下载、摘要和过期删除烟测:
```powershell
.\scripts\smoke-export.ps1
```
执行主体立即冻结、活动通话保护、六子系统删除对账、正文脱敏和墓碑证明烟测(UTF-8脚本建议使用PowerShell 7):
```powershell
pwsh -NoProfile -File .\scripts\smoke-privacy-delete.ps1
```
执行设备命令排队、签名拉取和分阶段回执烟测:
```powershell
pwsh -NoProfile -File .\scripts\smoke-device-commands.ps1
```
执行固件角色、跨域私有直传、签名校验、列表查询、非法签名拒绝、灰度暂停、下载/安装进度和回滚烟测:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-firmware-rollout.ps1
```
执行双轨录音直传、后台校验、错误摘要拒绝、本地删除回执、独立播放/下载授权和访问审计烟测:
```powershell
pwsh -NoProfile -File .\scripts\smoke-media-upload.ps1
```
执行SSE断点续传、权限隔离、过期游标及设备在线/离线状态事件烟测:
```powershell
pwsh -NoProfile -File .\scripts\smoke-web-events.ps1
pwsh -NoProfile -File .\scripts\smoke-device-presence-events.ps1
```
执行具体资源分享的幂等、权限、撤销、过期和访问审计烟测:
```powershell
pwsh -NoProfile -File .\scripts\smoke-sharing.ps1
```
执行运营角色、评测/资源门禁、灰度投放、资源撤销、回滚和新通话版本冻结烟测:
```powershell
pwsh -NoProfile -File .\scripts\smoke-ops-config.ps1
```
执行价格双人发布、七类用量去重核价、运营/家庭脱敏汇总、预算快照和硬额度烟测:
```powershell
pwsh -NoProfile -File .\scripts\smoke-billing.ps1
```
执行告警路由、自动发现、超时升级、版本冲突、确认—缓解—关闭、静默和重新打开烟测:
```powershell
pwsh -NoProfile -File .\scripts\smoke-alerts.ps1
```
执行联调诊断的五类精确检索、跨租户拒绝、安全停止、状态对账、配置刷新、死信受控重试、脱敏诊断包、合成设备吊销和审计关联烟测:
```powershell
pwsh -NoProfile -File .\scripts\smoke-diagnostics.ps1
pwsh -NoProfile -File .\scripts\smoke-acceptance-snapshot.ps1
```
执行敏感数据临时访问申请、自批拒绝、双人审批、申请人绑定、字幕读取和逐次审计烟测:
```powershell
pwsh -NoProfile -File .\scripts\smoke-ops-sensitive-access.ps1
```
执行需求规定的本地专项容量、恢复和HTTP安全门禁:
```powershell
# 50 RPS持续30分钟
docker run --rm -v "${PWD}/test/load:/scripts:ro" grafana/k6 run `
-e BASE_URL=http://host.docker.internal:58080 `
-e PROFILE=acceptance /scripts/api-readiness.js
# 500回调/秒持续10分钟,并校验30万唯一事件
pwsh -NoProfile -File .\scripts\load-provider-callbacks.ps1 -Profile acceptance -ConversationPool 100
pwsh -NoProfile -File .\scripts\load-conversation-state.ps1
pwsh -NoProfile -File .\scripts\load-sse-connections.ps1 -Connections 500
pwsh -NoProfile -File .\scripts\load-media-manifest.ps1
pwsh -NoProfile -File .\scripts\load-privacy-delete.ps1
pwsh -NoProfile -File .\scripts\verify-postgres-restore.ps1
pwsh -NoProfile -File .\scripts\verify-runtime-recovery.ps1
pwsh -NoProfile -File .\scripts\verify-provider-outage.ps1
pwsh -NoProfile -File .\scripts\security-http-smoke.ps1
```
## 目录说明
```text
cmd/ 三个可部署进程入口
internal/ 平台公共能力及后续领域模块
migrations/ 仅追加的数据库迁移
openapi/ 对外接口契约基线
web/ 家庭用户和运营网页
deploy/ 容器构建与运行配置
docs/ 架构决策与研发基线
scripts/ 本地启动和质量检查脚本
test/ 契约、集成和端到端测试
```
公共接口约束与监控指标见[可观测性与接口治理设计](docs/observability-and-api-governance.md),设备签名和模拟联调见[设备接入与启动配置联调说明](docs/device-integration.md),设备命令见[设备命令可靠下发说明](docs/device-commands.md),固件流程见[固件制品、签名、灰度与回滚开发说明](docs/firmware-rollout.md),A/B升级与启动失败回退见[设备A/B分区固件升级与启动确认协议](docs/device-firmware-ab-protocol.md),设备/云端时间线对账见[固件真机联调一致性证据采集与校验说明](docs/firmware-conformance-evidence.md),双轨录音见[双轨录音上传、校验与访问开发说明](docs/media-upload-and-access.md),网页状态通知见[Web状态事件流开发说明](docs/web-event-stream.md),具体分享见[具体资源分享开发说明](docs/resource-sharing.md),运营配置见[内部运营身份与运行配置版本开发说明](docs/operations-config.md),用量费用与额度见[用量、费用与额度开发说明](docs/usage-cost-quota.md),告警闭环见[告警监测与运营处置开发说明](docs/alert-operations.md),真实网关归档见[告警接收器一致性证据](docs/alert-receiver-conformance-evidence.md),云密钥轮换见[KMS/HSM轮换一致性证据](docs/kms-hsm-rotation-evidence.md),生产恢复见[生产等价恢复演练一致性证据](docs/production-recovery-evidence.md),生产基础设施见[生产基础设施一致性证据](docs/production-infrastructure-evidence.md),联调工作台及发布候选验收快照见[集成诊断工作台开发说明](docs/integration-diagnostics.md),试点每日证据见[试点运行日报开发说明](docs/pilot-daily-operations.md),试点周度复盘口径、外部投诉缺口、接口和轻量烟测见[试点运行周报开发说明](docs/pilot-weekly-review.md),敏感数据提权见[运营敏感数据临时访问开发说明](docs/ops-sensitive-access.md),外部上线阶段见[真机、真实供应商、试点与生产发布外部验收清单](docs/external-acceptance-checklist.md),生产门禁和压测告警见[压测、告警与生产安全门禁](docs/load-alerting-and-production-security.md),专项实测证据见[性能、恢复、安全与兼容性专项验证报告](docs/performance-recovery-security-compatibility.md),隐私数据访问见[隐私数据请求开发说明](docs/privacy-requests.md),私有导出见[私有数据导出开发说明](docs/data-export.md)。
从框架搭建到生产落地的阶段任务、里程碑、人员、测试门禁、依赖和风险见[详细开发计划](docs/development-plan.md),当前实际完成证据见[开发状态与门禁记录](docs/development-status.md)。
## 安全提醒
本地环境只允许使用合成数据。不要把生产Token、证书、设备私钥、供应商长期密钥、真实录音或真实个人资料写入仓库、`.env`、日志或测试夹具。
+65
View File
@@ -0,0 +1,65 @@
{
"report": {
"schema": "agent-eval-report-v1",
"suite_version": "agent-quality-baseline-v1",
"suite_sha256": "b5cd3b798a01a48ddf0081f2fcc2a917f93681a3ee2fa55bbf4190466de3bef8",
"agent_version": "agent-baseline-v1",
"model_config_ref": "runtime-config://voice/agent-baseline-v1",
"environment": "mock",
"executed_at": "2026-08-24T10:00:00Z",
"evaluated_at": "2026-08-24T01:24:36.953421Z",
"result": "PASSED",
"metrics": {
"cases_total": 10,
"cases_passed": 10,
"conversation_success_rate": 1,
"first_response_p95_ms": 1260,
"interruption_recovery_rate": 1,
"average_response_chars": 36.8,
"salutation_accuracy": 1,
"memory_accuracy": 1,
"fabricated_facts": 0,
"story_source_completeness": 1,
"sensitive_information_handling_rate": 1,
"category_counts": {
"conflicting_fact": 1,
"emotional_topic": 1,
"long_pause": 1,
"memory_recall": 1,
"name_date_ambiguity": 1,
"network_fluctuation": 1,
"normal_chat": 1,
"repeated_expression": 1,
"sensitive_information": 1,
"user_interruption": 1
}
},
"failed_checks": []
},
"registration_payload": {
"evidence_type": "AGENT_QUALITY_V1",
"suite_version": "agent-quality-baseline-v1",
"result": "PASSED",
"metrics": {
"agent_version": "agent-baseline-v1",
"average_response_chars": 36.8,
"cases_passed": 10,
"cases_total": 10,
"conversation_success_rate": 1,
"environment": "mock",
"executed_at": "2026-08-24T10:00:00Z",
"fabricated_facts": 0,
"failed_check_count": 0,
"first_response_p95_ms": 1260,
"interruption_recovery_rate": 1,
"memory_accuracy": 1,
"model_config_ref": "runtime-config://voice/agent-baseline-v1",
"report_schema": "agent-eval-report-v1",
"salutation_accuracy": 1,
"sensitive_information_handling_rate": 1,
"story_source_completeness": 1,
"suite_sha256": "b5cd3b798a01a48ddf0081f2fcc2a917f93681a3ee2fa55bbf4190466de3bef8"
},
"report_ref": "artifact://agent-eval-report.mock.v1.json"
}
}
+413
View File
@@ -0,0 +1,413 @@
# 原始需求来源段落清单
> 此文件由`scripts/extract-requirements.ps1`从原始DOCX机械提取,用于稳定引用来源,不替代原需求文档。`Pxxxx`是`word/document.xml`正文段落的零基顺序号;空段落保留编号但不输出正文。
- 来源文件:数据采集终端-云端系统研发需求规格说明书(1).docx
- SHA-256:`bd009e3de1aebe07a4b50c3ead29ff840d6dc9bc0d9ade0ebb9faa94d45fca23`
- 正文段落总数:517
- 非空段落数:402
## 非空段落
- `P0000` [Normal] 研发需求规格|版本 1.2|2026-08-21
- `P0001` [Normal] 数据采集终端
- `P0002` [Normal] 云端智能服务与网页管理平台研发需求规格说明书
- `P0003` [Normal] 硬件终端使用流程、SaaS平台业务开发、Agent编排与大模型优化
- `P0006` [Normal] 文件使用范围:项目内部与受邀供应商
- `P0008` [heading 1] 编写说明
- `P0009` [Normal] 本文件用于业务方、硬件与固件团队、SaaS平台业务开发人员、Agent编排与大模型优化人员、测试人员和运维人员共同确认研发内容。文档先解释硬件终端怎样被使用,再说明一次通话怎样经过云端形成字幕、故事和记忆,最后分别列出两个云端角色的研发任务、交接方式和验收要求。
- `P0010` [Normal] 全文尽量使用完整、直白的中文表达。SaaS平台业务开发是指网页业务系统、账号权限、设备和数据管理等工作;Agent编排与大模型优化是指语音智能体的启动、通话状态、背景资料、记忆、提示词和内容整理等工作。接口路径、程序字段、状态值和第三方产品名称必须保持准确,因此这些位置保留必要的英文或代码写法,并在旁边解释中文含义。
- `P0012` [heading 1] 目录
- `P0014` [heading 1] 0. 研发启动说明
- `P0015` [Normal] 本章把项目目的、已经确定的技术关系、两个云端角色的分工和交付顺序集中说明。后续架构设计、开发报价、人员安排、任务拆分和验收均以本章为共同基线。
- `P0016` [heading 2] 0.1 项目要解决的问题
- `P0017` [Normal] 数据采集终端面向长者的日常语音交流。长者通过带听筒的固定式设备与语音智能体自然聊天,家庭用户通过网页完成设备绑定、授权设置和内容核对。云端工作的核心价值,是把硬件的一次摘机通话安全地连接到语音智能体,并把通话结果整理为可查看、可核对、可继续使用的数据。
- `P0018` [Normal] 云端系统需要同时解决三类问题:第一,设备能否安全、稳定地开始和结束一次通话;第二,语音智能体能否根据长者背景和已确认记忆自然交流;第三,通话后的字幕、录音、故事和记忆能否被家庭用户清楚地管理、修订和删除。
- `P0019` [heading 2] 0.2 已经确定的关键方案
- `P0021` [heading 2] 0.3 两个云端角色的分工结论
- `P0024` [heading 2] 0.4 交付顺序
- `P0026` [heading 1] 1. 产品与硬件终端背景
- `P0027` [Normal] 云端研发人员需要先理解硬件端实际怎样被使用。数据采集终端不是普通网页聊天入口,而是一台长期放在长者生活环境中的固定式语音设备。长者日常不需要打开网页或输入账号,主要通过听筒完成交流;网页更多由家庭用户负责设置和核对。
- `P0028` [heading 2] 1.1 硬件终端是什么
- `P0029` [Normal] 本项目中的数据采集终端采用带听筒的固定式语音交互形态。听筒中的话筒采集长者声音,受话器播放语音智能体回答;叉簧或等效的开始/结束触发用于识别摘机和挂断;设备通过 Wi-Fi 连接自建云端和火山引擎实时音视频服务。
- `P0030` [Normal] 设备端负责采集和播放实时语音、保存必要的本地状态、执行云端下发的非敏感配置,并在授权有效时将长者声音和智能体声音分别保存为加密录音分片。自建云端负责控制和业务数据,不承担实时语音转发。
- `P0032` [Normal] 图 1 硬件终端、两个云端角色和第三方语音服务之间的关系
- `P0033` [heading 2] 1.2 硬件端主要组成和作用
- `P0035` [heading 2] 1.3 家庭首次配置流程
- `P0036` [Normal] 1. 设备通电并完成 Wi-Fi 配置,使用每台设备独立的身份访问云端,取得服务器时间、最低固件要求和基础运行配置。
- `P0037` [Normal] 2. 家庭用户登录网页管理平台,创建家庭和长者档案,填写称呼、语言、交流偏好、生活背景和禁聊主题。
- `P0038` [Normal] 3. 家庭用户分别确认最终字幕、原录音、故事整理、个性化记忆和家庭协作等授权。
- `P0039` [Normal] 4. 家庭用户在网页中输入一次性设备绑定码,把设备绑定到指定家庭和长者。
- `P0040` [Normal] 5. SaaS角色验证设备是否在线、是否已经被其他家庭绑定,并保存当前绑定关系和历史记录。
- `P0041` [Normal] 6. 设备下一次读取配置时获得新的绑定和运行配置,网页显示设备在线状态、固件版本和最后同步时间。
- `P0042` [heading 2] 1.4 长者日常通话流程
- `P0044` [Normal] 图 2 一次日常通话中各方的主要动作
- `P0045` [Normal] 1. 长者拿起听筒。设备识别摘机动作,并向云端请求创建本次通话。
- `P0046` [Normal] 2. SaaS角色检查设备身份、当前绑定长者、分项授权、设备状态和使用额度,并向Agent角色提供本次启动所需的业务资料。
- `P0047` [Normal] 3. Agent角色创建通话记录,整理本次可以使用的长者背景资料和已确认记忆,选择语音处理方式。
- `P0048` [Normal] 4. Agent角色为设备生成实时音视频房间资料和短期入房凭证,设备使用这些资料加入指定房间。
- `P0049` [Normal] 5. 设备确认已经进入房间后,Agent角色才启动豆包语音智能体,避免设备未入房时提前产生计费和遗留任务。
- `P0050` [Normal] 6. 长者通过话筒说话,豆包语音智能体理解并回答,设备通过受话器播放回答。实时语音不经过自建应用服务器。
- `P0051` [Normal] 7. 通话期间,设备周期上报网络、实时音视频、本地录音队列和设备状态;Agent角色维护本次通话的短期租约和供应商任务状态。
- `P0052` [Normal] 8. 长者挂断后,设备请求停止;Agent角色停止语音智能体并回收任务,SaaS角色更新页面上的通话完成状态。
- `P0053` [heading 2] 1.5 通话结束后发生什么
- `P0054` [Normal] 1. Agent角色接收供应商返回的最终字幕、状态和用量,按说话人和时间整理为每轮对话。
- `P0055` [Normal] 2. 在授权有效时,Agent角色生成摘要、可继续交流的话题、故事候选和事实候选,并保留对应的原始字幕来源。
- `P0056` [Normal] 3. SaaS角色在网页中展示候选内容,家庭用户可以确认、修改或拒绝。
- `P0057` [Normal] 4. 经过人工确认且允许用于个性化交流的内容成为已确认记忆;Agent角色在下一次通话中按授权检索和使用。
- `P0058` [Normal] 5. 在原录音保存授权有效时,设备在空闲且网络符合要求时直接上传两条录音轨道;SaaS角色负责校验、播放授权、下载、分享、导出和删除。
- `P0059` [heading 2] 1.6 异常情况下的用户体验原则
- `P0060` [Normal] • 设备未绑定、授权不足或固件低于最低安全版本时,设备不能进入看似正常但实际无法工作的通话状态,应获得明确错误并通过设备既有提示方式告知用户。
- `P0061` [Normal] • 网络暂时不可用时,设备可以进行有限次数重试,但不能重复创建多次通话或多个供应商语音任务。
- `P0062` [Normal] • 设备已经入房但智能体启动失败时,Agent角色应停止并清理本次通话;设备收到可识别的失败状态后退出通话。
- `P0063` [Normal] • 设备在通话中断网、断电或重启时,Agent角色通过租约和后台检查回收供应商任务;设备恢复联网后上报最后状态。
- `P0064` [Normal] • 设备本地存在待上传录音时,SaaS角色应在网页中显示上传队列和失败原因;只有云端确认文件完整后,设备才延迟删除本地密文。
- `P0065` [heading 1] 2. 从一次通话看整个系统怎样工作
- `P0066` [Normal] 本章把硬件端、SaaS角色、Agent角色、火山引擎与豆包以及云端文件存储放在同一条业务链中说明。阅读者可以先理解语音走哪条链路、控制信息走哪条链路,再进入后续的角色需求。
- `P0067` [heading 2] 2.1 五个参与部分
- `P0069` [heading 2] 2.2 三条不同的数据链路
- `P0072` [Normal] 图 3 两个云端角色共同形成“对话—核对—记忆—再对话”闭环
- `P0073` [heading 2] 2.3 一次通话的数据从哪里来、到哪里去
- `P0075` [heading 2] 2.4 必须遵守的系统关系
- `P0076` [Normal] • 实时语音不得进入SaaS业务接口、Agent控制接口、日志系统或自建应用服务器磁盘。
- `P0077` [Normal] • 设备上传录音时直接访问云端文件存储;SaaS业务接口只签发短期上传授权并校验上传结果。
- `P0078` [Normal] • SaaS网页不直接调用火山引擎或豆包接口;第三方接口细节统一由Agent角色的供应商对接模块封装。
- `P0079` [Normal] • 每次通话只关联一个客户组织、一个家庭、一个长者、一台设备和一个供应商语音任务。
- `P0080` [Normal] • PostgreSQL是业务数据的最终依据;Redis只保存租约、在线状态、限流和短期缓存。
- `P0081` [Normal] • 跨进程后台任务先可靠写入数据库再执行;重复执行不能生成重复内容、重复录音记录或重复费用。
- `P0082` [Normal] • 每类业务数据只设一个主责角色;另一个角色通过内部接口读取或提交结果,不直接修改对方模块负责的数据。
- `P0083` [heading 1] 3. SaaS角色与Agent角色怎样协同
- `P0084` [Normal] 两个角色的划分以业务责任为主,而不是简单按照前端和后端划分。SaaS角色负责稳定的业务规则、网页和数据管理;Agent角色负责一次实时对话的运行控制和模型效果。两者必须在通话启动、结果交付、人工核对和记忆回用等位置明确交接。
- `P0085` [heading 2] 3.1 一句话理解两个角色
- `P0088` [heading 2] 3.2 SaaS平台业务开发的责任边界
- `P0090` [heading 2] 3.3 Agent编排与大模型优化的责任边界
- `P0092` [heading 2] 3.4 五个必须明确的交接点
- `P0094` [heading 2] 3.5 数据主责和内部接口规则
- `P0097` [heading 2] 3.6 联调和评审机制
- `P0098` [Normal] • 两个角色共同维护一份接口和数据字典,先冻结创建通话、授权快照、字幕、候选内容、已确认记忆和停止动作的内部结构。
- `P0099` [Normal] • 每个交接点至少提供成功、超时、重复、乱序、授权撤回和删除中的典型测试样例。
- `P0100` [Normal] • SaaS角色提供模拟的长者档案、授权、绑定和人工核对结果;Agent角色提供模拟供应商、字幕回调和机器候选。
- `P0101` [Normal] • 任何破坏内部接口或数据含义的改动,必须先更新接口说明、迁移影响和兼容方案,再合并代码。
- `P0102` [Normal] • 阶段验收采用端到端业务场景,不以“各自模块单独演示成功”代替两角色联调。
- `P0103` [heading 1] 4. 公共技术架构与代码组织
- `P0104` [Normal] 两个角色共享同一套数据库、缓存、云端文件存储、部署和监控基础设施。公共架构应让两人可以并行开发,同时保证数据边界、接口契约和异常恢复一致。
- `P0105` [heading 2] 4.1 推荐的整体架构
- `P0107` [heading 2] 4.2 默认技术方案
- `P0108` [Normal] 以下方案作为外包交付的默认基线。外包团队可以提出成熟度相当的替代方案,但应在启动评审中同时说明团队能力、开发成本、迁移影响、运维影响和回退方式,并取得业务方书面确认。
- `P0110` [heading 2] 4.3 建议的代码目录和主责角色
- `P0112` [heading 2] 4.4 工程实现规则
- `P0113` [Normal] • 首期采用模块化单体。不同业务模块通过明确的程序接口协作,不允许一个模块直接修改另一个模块的数据表。
- `P0114` [Normal] • 数据库事务不得跨越火山引擎、云端文件存储、用户登录服务或大模型服务的网络调用,避免长事务和无法回滚。
- `P0115` [Normal] • 调用外部系统前先保存要执行的业务意图,调用后再保存结果。网络超时或进程重启后,可以继续确认状态或安全重试。
- `P0116` [Normal] • 配置、数据库升级脚本、接口文档、前后端字段类型、测试样例和运维文档必须与代码版本同步。
- `P0117` [Normal] • 生产长期密钥不得进入代码仓库、容器镜像、普通环境文件、业务数据库、日志或前端构建文件。
- `P0118` [heading 2] 4.5 两角色共同遵守的接口规则
- `P0119` [Normal] • 模块接口的输入和输出使用明确的业务名称,不把火山引擎原始字段直接暴露给SaaS模块。
- `P0120` [Normal] • SaaS模块向Agent模块提供的授权和长者资料必须带版本、来源和有效状态;Agent不得自行猜测缺失授权。
- `P0121` [Normal] • Agent模块产生的字幕、摘要和候选内容必须带通话编号、长者编号、来源轮次和生成版本;SaaS不得只保存脱离来源的正文。
- `P0122` [Normal] • 任何状态变更先写入本模块的业务记录,再调用外部系统;网络结果未知时通过同一业务编号查询或对账。
- `P0123` [Normal] • 模块之间的写操作必须幂等;重复请求、重复通知和后台重试不得造成双通话、双记忆或重复删除。
- `P0124` [heading 1] 5. SaaS平台业务开发需求
- `P0125` [Normal] SaaS平台业务开发负责网页业务系统、设备管理和数据治理。这个角色既包括React网页,也包括与账号、家庭、长者、授权、设备、录音、故事核对和隐私处理有关的后端业务。
- `P0126` [heading 2] 5.1 页面结构
- `P0128` [heading 2] 5.2 登录、家庭和成员
- `P0129` [heading 3] 5.2.1 使用标准网页登录 〔首期〕
- `P0131` [heading 3] 5.2.2 管理家庭、成员邀请和角色 〔首期〕
- `P0133` [heading 2] 5.3 长者资料与分项授权
- `P0134` [heading 3] 5.3.1 创建和维护长者档案 〔首期〕
- `P0136` [heading 3] 5.3.2 分别管理不同的数据授权 〔首期〕
- `P0138` [heading 2] 5.4 设备身份、绑定和转移
- `P0139` [heading 3] 5.4.1 每台设备使用独立身份访问云端 〔首期〕
- `P0141` [heading 3] 5.4.2 使用一次性绑定码绑定或转移设备 〔首期〕
- `P0143` [heading 3] 5.4.3 在网页中查看设备并完成绑定、解绑或转移申请 〔首期〕
- `P0145` [heading 2] 5.5 设备状态、配置、命令和固件
- `P0146` [heading 3] 5.5.1 维护设备在线状态和配置同步状态 〔首期〕
- `P0148` [heading 3] 5.5.2 在网页中查看设备详情并编辑非敏感配置 〔首期〕
- `P0150` [heading 3] 5.5.3 下发受控命令并管理固件升级 〔家庭试点前〕
- `P0152` [heading 3] 5.5.4 在网页中查看命令和固件升级结果 〔家庭试点前〕
- `P0154` [heading 2] 5.6 通话、字幕和网页展示
- `P0155` [heading 3] 5.6.1 查看通话列表和通话详情 〔首期〕
- `P0157` [heading 3] 5.6.2 按授权播放两条录音轨道 〔首期〕
- `P0159` [heading 2] 5.7 录音上传、校验和访问
- `P0160` [heading 3] 5.7.1 按录音分片签发短期上传地址 〔首期〕
- `P0162` [heading 3] 5.7.2 上传完成后校验录音 〔首期〕
- `P0164` [heading 3] 5.7.3 播放和下载必须单独授权 〔首期〕
- `P0166` [heading 2] 5.8 故事核对和已确认记忆管理
- `P0167` [heading 3] 5.8.1 核对故事和事实候选 〔首期〕
- `P0169` [heading 3] 5.8.2 管理已确认记忆 〔首期〕
- `P0171` [heading 2] 5.9 分享、导出、撤回和删除
- `P0172` [heading 3] 5.9.1 按具体资源和具体权限分享 〔家庭试点前〕
- `P0174` [heading 3] 5.9.2 提交数据访问、导出、更正、撤回和删除请求 〔首期〕
- `P0176` [heading 3] 5.9.3 实时推送状态变化但不推送敏感正文 〔首期〕
- `P0178` [heading 2] 5.10 内部运营、配置发布、费用和告警
- `P0179` [heading 3] 5.10.1 按脱敏信息处理设备和通话故障 〔家庭试点前〕
- `P0181` [heading 3] 5.10.2 管理配置发布、费用和告警 〔家庭试点前〕
- `P0183` [heading 3] 5.10.3 按通话统计用量、费用和额度 〔首期〕
- `P0185` [heading 2] 5.11 SaaS角色整体验收要求
- `P0186` [Normal] • 家庭用户能够从创建长者、设置授权、绑定设备一直完成到查看通话、核对故事和管理已确认记忆的完整流程。
- `P0187` [Normal] • 同一用户在不同家庭、不同长者和不同资源之间的权限边界清楚,越权请求不泄露资源是否存在。
- `P0188` [Normal] • 设备在线状态、配置下发、录音上传、导出和删除等异步过程都有明确的处理中、成功和失败状态。
- `P0189` [Normal] • 所有网页操作在无数据、加载中、权限不足、网络失败和并发修改时都有可理解的页面反馈。
- `P0190` [Normal] • SaaS角色向Agent角色提供的长者、授权、绑定、配置和已确认记忆都带稳定编号、版本和有效状态。
- `P0191` [heading 1] 6. Agent编排与大模型优化需求
- `P0192` [Normal] Agent编排与大模型优化负责一次语音通话怎样被创建、启动、运行、停止和整理。这个角色既包含稳定的服务端状态控制,也包含提示词、对话策略、记忆使用、模型路由、摘要和故事抽取的持续优化。
- `P0193` [heading 2] 6.1 角色的输入和输出
- `P0195` [heading 2] 6.2 一次通话的状态和完整生命周期
- `P0196` [heading 3] 6.2.1 创建通话并预留实时音视频房间 〔首期〕
- `P0198` [heading 3] 6.2.2 设备确认入房后再启动语音智能体 〔首期〕
- `P0200` [heading 3] 6.2.3 通话期间保活并下发非实时动作 〔首期〕
- `P0202` [heading 3] 6.2.4 主动结束通话并收敛到最终状态 〔首期〕
- `P0204` [heading 3] 6.2.5 后台自动检查并修复卡住的通话 〔首期〕
- `P0206` [heading 2] 6.3 语音处理方式与供应商对接
- `P0208` [heading 3] 6.3.1 云端签发短期入房凭证 〔首期〕
- `P0210` [heading 3] 6.3.2 把火山引擎与豆包的接口细节集中封装 〔首期〕
- `P0212` [heading 3] 6.3.3 每次通话选择一种语音处理方式 〔首期〕
- `P0214` [heading 3] 6.3.4 管理智能体人设、音色、模型和路由配置 〔家庭试点前〕
- `P0216` [heading 2] 6.4 长者背景资料、记忆检索与长期记忆
- `P0217` [heading 3] 6.4.1 通话开始前整理最小且必要的长者背景资料 〔首期〕
- `P0219` [heading 3] 6.4.2 使用自有数据库检索已确认记忆 〔首期〕
- `P0221` [heading 3] 6.4.3 验证并可选使用供应商原生记忆 〔家庭试点前〕
- `P0223` [heading 3] 6.4.4 通话过程中按需要调用自有记忆检索工具 〔家庭试点前〕
- `P0225` [heading 2] 6.5 供应商状态通知、最终字幕与会话摘要
- `P0226` [heading 3] 6.5.1 可靠接收供应商状态通知 〔首期〕
- `P0228` [heading 3] 6.5.2 保存最终字幕并标记每轮对话 〔首期〕
- `P0230` [heading 3] 6.5.3 生成通话摘要和下次可继续的话题 〔首期〕
- `P0232` [heading 2] 6.6 故事、事实候选与已确认记忆
- `P0233` [heading 3] 6.6.1 从最终字幕整理故事和事实候选 〔首期〕
- `P0235` [heading 3] 6.6.2 人工核对后生成正式记忆 〔首期〕
- `P0237` [heading 2] 6.7 面向长者的对话策略优化
- `P0238` [heading 3] 6.7.1 让语音智能体说得清楚、听得懂并愿意继续聊 〔首期〕
- `P0240` [heading 3] 6.7.2 保持身份、人设和交流边界一致 〔首期〕
- `P0242` [heading 3] 6.7.3 调整回答时延、打断和语音处理方式 〔家庭试点前〕
- `P0244` [heading 2] 6.8 摘要、故事和记忆质量优化
- `P0245` [heading 3] 6.8.1 摘要和开放话题要忠于原始对话 〔首期〕
- `P0247` [heading 3] 6.8.2 故事和事实候选要可核对、可修改 〔首期〕
- `P0249` [heading 3] 6.8.3 已确认记忆的检索和使用要准确 〔首期〕
- `P0251` [heading 2] 6.9 Agent评测、发布和持续优化
- `P0252` [heading 3] 6.9.1 建立可重复的评测集和质量指标 〔首期〕
- `P0254` [heading 3] 6.9.2 配置发布、灰度和回滚 〔家庭试点前〕
- `P0256` [heading 3] 6.9.3 建立从用户核对结果到模型优化的反馈闭环 〔家庭试点前〕
- `P0258` [heading 2] 6.10 Agent角色整体验收要求
- `P0259` [Normal] • 真实设备连续完成多次创建、入房、激活、对话、挂断和异常恢复,供应商侧无遗留语音任务。
- `P0260` [Normal] • 三种语音处理方式在真实账号下均有标准启动请求、状态通知样例、时延和成本对比。
- `P0261` [Normal] • 每次通话可以追溯使用的长者资料、已确认记忆、人设、提示词、音色、模型和路由版本。
- `P0262` [Normal] • 最终字幕、摘要、故事和事实候选均保留来源;机器候选不会自动成为正式记忆。
- `P0263` [Normal] • 长者友好对话、记忆准确、打断、敏感信息和提示注入测试达到项目设定的质量门槛。
- `P0264` [heading 1] 7. 硬件、SaaS与Agent接口及联调
- `P0265` [Normal] 本章面向硬件与固件团队、SaaS开发人员、Agent开发人员和联调测试人员。接口路径、字段和程序状态必须保持准确,因此保留必要的英文代码,并增加每个角色在接口中的具体动作。
- `P0266` [heading 2] 7.1 设备接口中的责任分配
- `P0268` [heading 2] 7.2 创建通话请求示例
- `P0271` [heading 2] 7.3 创建通话响应示例
- `P0274` [heading 2] 7.4 通话状态
- `P0276` [heading 2] 7.5 三方联调要求
- `P0277` [Normal] • 项目团队交付可运行的模拟设备。模拟设备能够执行启动、读取配置、创建通话、确认入房、发送心跳、停止、断网、重复请求、字幕补交和录音上传。
- `P0278` [Normal] • Agent角色交付模拟火山引擎服务和可重复播放的脱敏状态通知样例,覆盖任务开始、最终字幕、智能体开始回答、被打断、用量、错误和停止。
- `P0279` [Normal] • 硬件团队提供设备能力表、音频编码与采样率、智能体用户订阅方式、心跳字段、两条录音轨道分片清单格式和设备命令结果格式。
- `P0280` [Normal] • 接口变更先更新 OpenAPI 接口说明和兼容性说明。会导致旧固件无法工作的变更使用新的接口版本,不能依赖固件 User-Agent 猜测兼容方式。
- `P0281` [Normal] • 设备不根据 engine_mode 改变实时音视频采集和播放逻辑。该字段只用于云端诊断、成本统计和通话记录。
- `P0282` [Normal] • 联调过程中,双方共同维护问题清单,记录问题现象、设备编号、通话编号、发生时间、日志关联编号、责任方、解决结果和回归结论。
- `P0283` [heading 2] 7.6 必须联合验证的交接场景
- `P0284` [Normal] • SaaS判定设备未绑定、授权不足或额度达到时,Agent不创建房间和供应商任务,设备获得稳定错误码。
- `P0285` [Normal] • SaaS校验通过但Agent准备背景资料失败时,本次通话进入可诊断失败状态,网页和设备看到同一结果。
- `P0286` [Normal] • Agent已经创建房间但设备未入房时,预留到期后自动清理,不产生供应商语音任务。
- `P0287` [Normal] • 家庭用户在通话中撤回更严格授权或运营人员停止通话时,SaaS生成操作请求,Agent停止任务并向设备下发动作。
- `P0288` [Normal] • Agent写入故事候选后,SaaS页面可以跳转到字幕来源;家庭用户确认后,Agent在下一次通话中能检索新记忆。
- `P0289` [Normal] • 设备上传录音失败不影响字幕和故事页面;录音完成校验后,网页状态和设备本地删除动作最终一致。
- `P0290` [heading 1] 8. 数据如何保存、使用和删除
- `P0291` [Normal] 本章说明从最终字幕到正式记忆的处理过程,以及账号、家庭、长者、设备、通话、录音和隐私请求等数据对象之间的关系。程序表名和字段名只作为开发参考,业务含义均使用中文说明。
- `P0293` [Normal] 图 4 从原始对话到可用于后续交流的记忆
- `P0294` [heading 2] 8.1 核心数据对象和主责角色
- `P0296` [heading 2] 8.2 通用数据规则
- `P0297` [Normal] • 所有业务编号使用不可猜测的随机编号。所有客户组织业务数据明确保存客户组织编号。
- `P0298` [Normal] • 客户组织编号、长者编号和资源权限必须从登录信息和服务端关系中推导,不能相信浏览器或设备自行上报的客户组织边界。
- `P0299` [Normal] • 时间统一保存为协调世界时,并带完整时区格式。设备同时上报单调时钟,用于对齐录音和字幕时间。
- `P0300` [Normal] • 授权、故事、记忆、配置和费用均采用版本化保存。逻辑删除、法律或审计保留、最终物理清除分别建模。
- `P0301` [Normal] • 短期入房凭证、云端文件存储短期上传或下载地址、供应商长期密钥不得写入业务数据库。
- `P0302` [Normal] • 数据库字段、云端文件存储路径、向量索引附加信息和缓存键都必须包含或可推导明确的客户组织和长者边界。
- `P0303` [Normal] • 任何模型生成内容都要保存来源和生成版本。没有来源的机器内容不能进入人工确认流程。
- `P0304` [heading 2] 8.3 分项授权及撤回后的处理
- `P0306` [heading 2] 8.4 每次通话的数据保存方式
- `P0308` [heading 2] 8.5 数据生命周期的共同原则
- `P0309` [Normal] • 原始字幕先经过授权和敏感信息检查,再生成摘要、故事或事实候选。
- `P0310` [Normal] • 候选内容只有在有权限的家庭用户确认后,才能成为正式记忆。
- `P0311` [Normal] • 正式记忆仍然保留来源、确认人、版本和敏感级别,不能与原始来源脱离。
- `P0312` [Normal] • 撤回授权、停用、修订或删除后,新通话立即停止使用旧内容;后台继续清理数据库、对象和外部索引。
- `P0313` [Normal] • 任何后台清理都应显示处理中、成功或部分失败状态,不能仅凭数据库软删除就向用户显示全部完成。
- `P0314` [heading 1] 9. 接口通用规则、状态通知和错误提示
- `P0315` [Normal] 本章列出所有接口共同遵守的规则,以及网页管理接口、服务器推送事件和稳定错误码。接口路径与程序错误码必须保持精确,因此在中文解释旁保留原始代码。
- `P0316` [heading 2] 9.1 所有接口共同遵守的规则
- `P0317` [Normal] • 网页管理接口统一使用“/api/v1”作为基础路径;设备通信接口统一使用“/device/v2”;供应商状态通知接口统一使用“/callbacks/v1”。
- `P0318` [Normal] • 请求和响应使用 JSON。错误响应使用统一格式,至少包含稳定错误码、中文说明、请求关联编号、是否可以重试和必要的字段级错误详情。
- `P0319` [Normal] • 会产生写入、启动、停止、导出或删除等外部影响的 POST 请求,必须携带“Idempotency-Key(重复请求识别码)”。服务端在有效窗口内保存结果,并检查同一识别码的请求内容是否一致。
- `P0320` [Normal] • 列表接口使用不透明的下一页标记(程序字段 cursor)和每页数量。默认每页 20 条,最大值由具体接口说明。
- `P0321` [Normal] • 修改资源时使用版本标记(ETag/If-Match)或显式版本号。发现多人并发修改时返回冲突,不静默覆盖。
- `P0322` [Normal] • “X-Request-Id”只用于串联日志和故障排查,不能作为身份认证或重复请求凭据。
- `P0323` [Normal] • OpenAPI 接口文件是前后端和设备端的接口事实来源。持续集成自动检查字段类型生成和破坏性变更。
- `P0324` [Normal] • 接口响应不返回云端文件存储桶名称、内部数据库主键、供应商长期密钥、完整供应商原始错误、完整提示词或永久录音地址。
- `P0325` [heading 2] 9.2 网页管理接口资源
- `P0327` [heading 2] 9.3 网页状态通知事件
- `P0329` [heading 2] 9.4 稳定错误码
- `P0331` [heading 1] 10. 安全、隐私与权限要求
- `P0332` [heading 2] 10.1 各类凭证放在哪里
- `P0335` [heading 2] 10.2 用户、家庭、长者和资源权限
- `P0336` [Normal] • 用户身份通过标准身份服务确认。访问一个资源时,同时检查用户是否属于当前客户组织、在家庭中的角色、对该长者是否具有代理或管理权限,以及是否获得具体资源的分享授权。
- `P0337` [Normal] • 家庭所有者或管理员不自动获得原录音访问权。收听和下载必须分别有明确授权。
- `P0338` [Normal] • 设备只能访问自身当前绑定长者的通话、配置、录音上传和遥测资源。设备不能按任意长者编号请求数据。
- `P0339` [Normal] • 内部运营入口启用强制多因素认证、最小权限、临时权限提升和完整审计。客服默认看不到字幕正文和原录音。
- `P0340` [Normal] • 云端文件存储访问和向量检索使用与业务接口相同的客户组织和长者边界,不能只在网页层判断权限。
- `P0341` [Normal] • 所有管理动作、授权、播放、下载、故事确认、配置发布、设备吊销和长期密钥轮换都写入审计。
- `P0342` [heading 2] 10.3 数据保护
- `P0343` [Normal] • 所有网络接口使用 TLS 1.2 或更高版本。设备接口使用双向证书认证或经过批准的每设备独立请求签名。
- `P0344` [Normal] • PostgreSQL、数据库备份和云端文件存储使用云端密钥管理服务加密;云端文件存储桶禁止公开读取。
- `P0345` [Normal] • 日志默认不记录姓名全文、完整字幕、完整提示词、Wi-Fi 密码或详细网络信息、短期入房凭证、云端文件存储短期地址和完整文件路径。
- `P0346` [Normal] • 高敏感数据进入模型或故事整理前执行识别、最小化和策略过滤。对于验证码、账户信息和敏感医疗数据,默认不进入普通故事内容。
- `P0347` [Normal] • 导出文件加密存储,只提供短期下载地址,到期自动清理;每次下载生成访问审计。
- `P0348` [Normal] • 数据删除覆盖 PostgreSQL 正文、缓存、云端文件存储、pgvector、自有或供应商记忆索引和导出文件,并通过对账确认各处结果。
- `P0349` [heading 2] 10.4 安全开发要求
- `P0350` [Normal] • 持续集成自动执行密钥泄露扫描、静态代码安全检查、依赖漏洞与许可证检查、软件材料清单、容器镜像和基础设施配置检查。
- `P0351` [Normal] • 所有外部输入限制请求体大小、数组长度、字符串长度和嵌套深度。JSON、供应商通知、录音分片清单和文件元数据都必须按结构规则校验。
- `P0352` [Normal] • 按设备、用户、客户组织和网络地址分别限流。启动语音智能体的请求还要受到供应商每秒请求数和同时任务数保护。
- `P0353` [Normal] • 生产环境的对外访问白名单只允许火山引擎与豆包、云端文件存储、用户登录服务、时间服务和必要监控目标。
- `P0354` [Normal] • 上线前必须完成客户组织越权、云端文件存储越权、供应商通知重放、提示注入、长期密钥泄露和遗留语音任务专项测试。
- `P0355` [heading 1] 11. 性能、可靠性与兼容性要求
- `P0357` [heading 2] 11.1 服务指标
- `P0359` [heading 2] 11.2 可靠性
- `P0360` [Normal] • 供应商状态通知和所有后台任务按“可能重复送达”设计,业务处理必须支持重复执行而不产生重复副作用。
- `P0361` [Normal] • 启动、停止、录音上传、数据导出、数据删除和设备命令均支持安全重试,并有最终状态对账。
- `P0362` [Normal] • 接口服务单实例重启、Redis 重启和后台任务进程重启不能产生双重供应商任务或永久卡住的通话。
- `P0363` [Normal] • 数据库结构升级应可以回滚,或提供明确的前向修复方案;生产发布支持滚动更新。
- `P0364` [Normal] • 供应商故障时,云端暂停创建新的供应商任务,继续回收已知任务,保持历史网页数据可访问并产生告警。
- `P0365` [heading 2] 11.3 首期压力测试基线
- `P0366` [Normal] 实时语音和大文件不经过自建应用服务器,因此压力测试重点是通话状态、供应商通知、数据库、网页状态推送和后台任务。下表是最低验收负载,正式生产容量在启动阶段根据设备注册数、日活跃设备数和同时通话数再确认。
- `P0368` [heading 2] 11.4 可维护性和兼容性
- `P0369` [Normal] • 后端核心模块单元测试覆盖率目标不低于 70%;通话状态、权限、删除和供应商对接的关键分支不低于 85%。
- `P0370` [Normal] • 接口说明、数据库升级脚本、配置结构和状态通知结构均进行版本控制和兼容性检查。
- `P0371` [Normal] • 日志、指标和调用链统一使用请求关联编号、设备编号、通话编号、供应商任务编号和录音上传批次编号,便于跨系统排查。
- `P0372` [Normal] • 网页前端支持最近两个主流版本的 Chrome、Edge 和 Safari;核心页面适配 1280 像素以上桌面,并兼顾平板。
- `P0373` [Normal] • 首期语言为中文;时间、时区和文案结构应支持后续增加其他语言。
- `P0374` [heading 1] 12. 云资源、部署与运行维护要求
- `P0375` [heading 2] 12.1 环境划分
- `P0377` [heading 2] 12.2 起步资源配置
- `P0379` [heading 2] 12.3 配置和长期密钥管理
- `P0380` [Normal] • 开发、上线前验证和生产环境分别使用独立的火山引擎应用编号、接口凭证、状态通知地址、数据库和云端文件存储桶。
- `P0381` [Normal] • 配置分为普通技术配置、受控业务配置和长期密钥。普通配置可以随代码版本管理;长期密钥只保存引用,不保存明文。
- `P0382` [Normal] • 供应商配置、人设、音色、语音处理路由、额度和数据保留策略使用发布版本,支持审计和回滚。
- `P0383` [Normal] • 轮换长期密钥不需要重新构建容器镜像。轮换步骤、验证方式和回退方式写入运维手册。
- `P0384` [heading 2] 12.4 监控与告警
- `P0386` [Normal] • 告警至少覆盖供应商不可用、语音任务启动或停止异常、遗留任务、数据库或 Redis 故障、证书过期、供应商通知积压、删除超期、录音上传失败和费用异常增长。
- `P0387` [Normal] • 分别提供业务仪表盘、技术仪表盘和安全仪表盘。告警信息包含对应运维手册链接和设备、通话或请求关联编号。
- `P0388` [heading 2] 12.5 备份、恢复与故障演练
- `P0389` [Normal] • PostgreSQL 使用自动备份和时间点恢复,并定期在隔离环境中实际恢复验证。
- `P0390` [Normal] • 云端文件存储启用版本、生命周期和删除保护策略;正式删除流程必须能够按确认规则清除历史版本。
- `P0391` [Normal] • Redis 按可重新生成的缓存处理,不能作为唯一业务证据。
- `P0392` [Normal] • 每次正式发布前后保留数据库升级备份点。项目至少完成数据库恢复、长期密钥轮换、供应商故障和隐私删除四类演练。
- `P0393` [heading 1] 13. 测试、验收与上线条件
- `P0394` [heading 2] 13.1 测试层次
- `P0396` [heading 2] 13.2 必须通过的业务场景
- `P0397` [Normal] 1. 真实或模拟设备能够完成“创建通话—进入房间—确认入房—启动智能体—双向交流—挂断—停止任务—通话完成”的完整流程,设备、供应商和数据库状态一致。
- `P0398` [Normal] 2. 创建、确认入房、发送心跳和停止请求分别重复发送时,不产生双通话、双供应商任务、重复用量或错误最终状态。
- `P0399` [Normal] 3. 设备进入房间失败或房间预留过期时,供应商语音任务不会提前启动。
- `P0400` [Normal] 4. 启动语音智能体接口超时且结果未知时,云端保持同一供应商任务编号,通过状态通知或查询确认结果,不盲目创建新任务。
- `P0401` [Normal] 5. 设备突然断电、断网或退出房间后,后台自动停止并回收供应商语音任务。
- `P0402` [Normal] 6. 端到端语音、端到端语音加记忆工具、语音识别—大模型—语音合成三段式可以按新通话切换,终端入房和音频采播方式保持一致。
- `P0403` [Normal] 7. 供应商状态通知重复、延迟或乱序到达时,最终字幕、通话状态和用量不重复并最终一致。
- `P0404` [Normal] 8. 两位长者交替通话时,长者背景资料、记忆检索、故事候选和来源字幕不串用。
- `P0405` [Normal] 9. 分别撤回最终字幕、原录音、故事整理、个性化记忆和家庭协作授权后,各相关处理立即采用更严格策略。
- `P0406` [Normal] 10. 长者声音和智能体声音录音分片出现重复上传、漏传、错误校验值、过期上传地址或云端文件存储延迟可见时,均有明确且最终一致的结果。
- `P0407` [Normal] 11. 没有录音访问授权的家庭所有者、管理员、编辑者和查看者均无法播放或下载原录音。
- `P0408` [Normal] 12. 故事候选没有来源时不能确认;人工修改保留版本;被拒绝的候选不进入正式记忆。
- `P0409` [Normal] 13. 删除请求覆盖数据库正文、云端文件存储、自有向量索引、供应商记忆和导出文件,并能查看每个子系统的失败和重试。
- `P0410` [Normal] 14. 被吊销设备、过期入房凭证、错误房间和错误用户无法建立有效通话。
- `P0411` [Normal] 15. 火山引擎或豆包服务故障时,系统暂停新任务,回收已知任务,历史网页数据仍可访问并产生告警。
- `P0412` [Normal] 16. 生产长期密钥不出现在代码仓库、容器镜像、日志、错误响应、网页前端和业务数据库中。
- `P0413` [heading 2] 13.3 上线条件
- `P0414` [Normal] • 所有首期需求、验收用例、缺陷和遗留风险已经形成可追踪对应关系,阻断级缺陷为 0。
- `P0415` [Normal] • 使用真实火山引擎账号完成短期入房凭证、启动、更新、停止、三种语音处理方式、字幕与状态通知、用量和配额验证。
- `P0416` [Normal] • 接口说明、数据库升级、配置、运维手册、监控、告警、备份恢复和安全扫描全部通过持续集成检查。
- `P0417` [Normal] • 客户组织隔离、录音越权、供应商通知重放、遗留任务回收和隐私删除专项测试通过。
- `P0418` [Normal] • 生产域名、证书、长期密钥、用户登录、云端文件存储、备份、告警接收人和应急联系人已经配置。
- `P0419` [Normal] • 业务方、外包团队和硬件团队完成联合验收,并签署上线检查清单。
- `P0420` [heading 2] 13.4 两个角色分别需要提供的验收证据
- `P0422` [heading 2] 13.5 新增的Agent质量验收场景
- `P0423` [Normal] 17. 长者说话较慢、停顿较长或重复表达时,智能体不会频繁抢话,并能自然确认不清楚的信息。
- `P0424` [Normal] 18. 智能体一次只推进一个主要问题,回答长度适合听筒收听;被打断后不继续播放已经失效的长回答。
- `P0425` [Normal] 19. 已确认记忆、机器候选和模型推断被清楚区分;不存在把未确认内容说成过去事实的情况。
- `P0426` [Normal] 20. 故事和事实候选的关键内容均可追溯到字幕;家庭用户高频修改和拒绝的问题可以按模型版本统计。
- `P0427` [Normal] 21. 提示词、人设、音色、模型和路由的新版本经过评测、灰度和回滚验证,错误配置不能直接影响全部设备。
- `P0428` [heading 1] 14. 外包交付内容与协作规则
- `P0429` [Normal] 交付物既要覆盖每个角色独立负责的代码和文档,也要覆盖两个角色的内部接口、端云联调和共同运维。验收时不能只检查网页或只检查语音通话,需要证明完整业务闭环可以复现。
- `P0430` [heading 2] 14.1 按角色划分的交付物
- `P0432` [heading 2] 14.2 通用交付内容
- `P0434` [heading 2] 14.3 代码、账号和资产归属
- `P0435` [Normal] • 代码仓库、持续集成与部署账号、云资源账号和域名由业务方控制。外包人员使用实名、最小权限账号。
- `P0436` [Normal] • 所有第三方依赖列明许可证、版本和用途。未经批准,不得引入限制商业使用或来源不明的组件。
- `P0437` [Normal] • 所有配置和文档纳入项目仓库管理。个人网盘、个人云账号和个人长期密钥不得进入正式交付链路。
- `P0438` [Normal] • 交付完成时移除外包人员个人权限,并完成生产权限、密钥管理、用户登录、监控和告警接收人的正式移交。
- `P0439` [heading 2] 14.4 变更和评审
- `P0440` [Normal] • 架构、权限、数据保留、接口不兼容变更、外部供应商和新增付费服务必须提交变更单。
- `P0441` [Normal] • 变更单至少说明变更原因、影响范围、兼容性、数据迁移、费用、安全、测试和回退方案。
- `P0442` [Normal] • 每个研发阶段进行代码、接口、数据和安全评审,评审结论和待办事项进入项目跟踪系统。
- `P0443` [Normal] • 演示通过不能替代自动化测试、正式文档、异常清理路径和真实账号验证证据。
- `P0444` [heading 2] 14.5 单项任务完成标准
- `P0445` [Normal] • 需求编号已经关联对应代码、测试、接口或配置变更。
- `P0446` [Normal] • 正常、超时、重试、重复、乱序、撤回、删除和清理路径均有测试结果。
- `P0447` [Normal] • 日志、指标和调用链可以定位问题,同时不泄露个人数据和长期密钥。
- `P0448` [Normal] • 数据库升级、回滚或前向修复、接口说明、项目说明和运维手册已经同步更新。
- `P0449` [Normal] • 代码完成评审,持续集成全部通过,阻断级安全问题为 0。
- `P0450` [Normal] • 功能已经部署到目标环境,并通过业务方可以独立复现的验收步骤。
- `P0451` [heading 1] 15. 研发阶段与首批任务
- `P0452` [heading 2] 15.1 两个角色并行推进的研发阶段
- `P0454` [heading 2] 15.2 硬件联调最小闭环
- `P0455` [heading 3] 15.2.1 目标
- `P0456` [Normal] 优先打通真实设备与云端之间的会前握手和实时语音闭环。设备先从自建云端取得本次通话编号、房间资料、短期入房凭证、需要订阅的智能体用户编号和控制参数;设备确认已经进入房间后,云端再启动豆包语音智能体。该阶段按可验证完成条件验收,不以固定周数作为完成判断。
- `P0457` [heading 3] 15.2.2 本阶段建设内容
- `P0458` [Normal] • 一套可以持续部署的共享开发环境,包括SaaS业务服务、Agent编排服务、PostgreSQL、Redis、供应商状态通知接收服务、模拟供应商、模拟设备和最小联调工作台。
- `P0459` [Normal] • 设备独立身份、设备启动信息、基础配置版本、服务器时间同步、设备能力表、最低固件策略和请求审计。
- `P0460` [Normal] • 两阶段通话启动:先创建通话并下发房间资料,设备成功进入房间后再确认入房并启动语音智能体。
- `P0461` [Normal] • 真实语音链路先使用固定的端到端语音模式、单一音色和单一供应商配置,验证设备、实时音视频和语音智能体的稳定配合;程序字段保留后续切换能力。
- `P0462` [Normal] • 最小长者背景资料,包括固定智能体人设、安全规则、测试长者称呼、少量测试背景和本次交流目标。
- `P0463` [Normal] • 完整任务控制,包括启动、停止、心跳、短期租约、超时、供应商状态通知、后台异常回收和统一错误提示。
- `P0464` [Normal] • 联调可观测性:请求关联编号、设备编号、通话编号、房间编号和供应商任务编号在日志和工作台中可以互相定位,同时不显示完整短期凭证。
- `P0465` [heading 3] 15.2.3 会前握手和语音启动步骤
- `P0466` [Normal] 1. 设备开机后使用独立身份访问“GET /device/v2/bootstrap”,上报型号、固件版本和设备时间,完成服务器时间同步、设备状态和最低固件版本检查。
- `P0467` [Normal] 2. 设备访问“GET /device/v2/config”,提交当前配置版本和能力信息,获取实时音视频编码、采样率、心跳间隔、上传策略和其他非敏感运行配置。
- `P0468` [Normal] 3. 用户拿起听筒后,设备携带“Idempotency-Key(重复请求识别码)”调用“POST /device/v2/conversations”,请求创建本次通话。
- `P0469` [Normal] 4. SaaS角色检查设备状态、长者绑定、测试授权、同时通话限制和额度;Agent角色继续创建通话,生成通话编号、房间编号、设备用户编号、智能体用户编号和固定供应商任务编号。
- `P0470` [Normal] 5. Agent角色整理最小长者背景资料,并使用实时音视频应用密钥为指定房间和用户生成短期入房凭证。响应中不包含应用密钥、火山引擎访问密钥或豆包长期凭证。
- `P0471` [Normal] 6. 设备按照响应中的应用编号、房间编号、设备用户编号和短期入房凭证加入实时音视频房间,开始发送麦克风语音,并订阅智能体用户编号。
- `P0472` [Normal] 7. 设备收到“加入房间成功”事件后,调用“POST /device/v2/conversations/{id}/activate”,上报加入时间、实时音视频状态和当前配置版本。云端可以结合火山引擎房间通知二次核验。
- `P0473` [Normal] 8. Agent角色把通话从“房间资料已下发”改为“正在启动智能体”,使用创建通话时固定的供应商任务编号调用 StartVoiceChat,成功后进入“通话中”。
- `P0474` [Normal] 9. 通话期间设备定期发送心跳,上报实时音视频、网络、固件、本地录音队列和设备状态;SaaS角色与Agent角色合并返回续租、配置更新、短期凭证续签或停止动作。
- `P0475` [Normal] 10. 用户挂断后,设备调用停止接口。Agent角色重复执行也安全地调用 StopVoiceChat,并等待供应商通知或查询把通话改为“已完成”。设备失联、房间预留过期或租约超时由后台自动回收。
- `P0476` [heading 3] 15.2.4 握手关键字段
- `P0478` [heading 3] 15.2.5 联调工作台和交付件
- `P0479` [Normal] • 最小联调工作台显示设备在线状态、固件、配置版本、当前通话、通话状态、短期凭证签发与过期时间、实时音视频状态、智能体状态、供应商通知和最近错误;不显示完整短期凭证。
- `P0480` [Normal] • 支持按设备编号、通话编号、房间编号、供应商任务编号和请求关联编号查询;有权限人员可以执行停止、状态对账和测试配置刷新。
- `P0481` [Normal] • 交付设备通信接口 OpenAPI、请求响应样例、设备接入说明、通话状态说明、错误码、测试设备证书发行说明和可运行模拟设备。
- `P0482` [Normal] • 交付真实火山引擎账号的脱敏标准请求响应、状态通知样例、环境配置清单、日志字段、监控仪表盘和联调故障排查手册。
- `P0483` [Normal] • 硬件团队提供设备能力表、实时音视频加入房间成功和失败事件、心跳字段、挂断原因、本地时间和固件版本;双方共同维护联调问题清单。
- `P0484` [heading 3] 15.2.6 验收标准
- `P0485` [Normal] • 一台真实设备可以完成设备启动、读取配置、创建通话、加入实时音视频房间、确认入房、实时双向语音、发送心跳、挂断、停止和任务回收的完整闭环。
- `P0486` [Normal] • 设备、接口响应、网页、日志、数据库和容器镜像中不存在实时音视频应用密钥、火山引擎访问密钥、豆包长期凭证或完整短期入房凭证。
- `P0487` [Normal] • 错误房间、错误用户、过期短期凭证和被吊销设备无法进入有效通话;正常凭证可以在有效期内入房。
- `P0488` [Normal] • 创建、确认入房、心跳和停止请求重复发送时,不产生双通话、双供应商任务、重复计量或错误最终状态。
- `P0489` [Normal] • 真实设备连续完成至少 10 次通话;每次供应商任务、房间、租约和通话状态最终完成,无遗留的“通话中”或“正在停止”任务。
- `P0490` [Normal] • 设备创建通话后未入房、入房后未确认、通话中断网、设备重启和云端重启时,均能超时回收或恢复,并形成可以定位的错误记录。
- `P0491` [Normal] • 联调工作台能够追踪握手各步骤耗时、启动和停止结果、供应商通知和错误码;硬件人员可以独立按照文档复现完整流程。
- `P0492` [Normal] • 最小长者背景资料正确带入测试称呼、背景和本次交流目标,证明背景资料由云端整理并随本次通话生效。
- `P0494` [heading 2] 15.3 首批可以立即启动的任务
- `P0496` [heading 1] 16. 启动前需要确认的事项
- `P0497` [Normal] 以下事项不影响代码仓库、通话状态、核心数据对象、模拟供应商和网页基础框架启动,但涉及真实账号、生产部署和用户数据的功能应在对应研发阶段开始前确认。
- `P0499` [heading 1] 附录 A. 其他接口和请求头
- `P0500` [heading 2] A.1 供应商状态通知和内部工具接口
- `P0502` [heading 2] A.2 关键请求头
- `P0504` [heading 1] 附录 B. 后台任务状态与重试原则
- `P0505` [heading 2] B.1 后台对象状态
- `P0507` [heading 2] B.2 自动重试原则
- `P0508` [Normal] • 只有明确标记为可以重试的网络错误、超时和供应商临时错误才自动重试。
- `P0509` [Normal] • 重试采用逐步增加等待时间并加入随机变化,设置最大次数;超过最大次数后进入人工处理或死信状态。
- `P0510` [Normal] • 启动语音智能体结果未知时保持同一供应商任务编号,并先通过状态通知或查询确认结果;停止语音智能体可以安全重复执行。
- `P0511` [Normal] • 供应商状态通知和可靠后台待办按至少一次处理设计,所有外部副作用使用业务唯一键去重。
- `P0512` [Normal] • 权限不足、授权缺失、参数错误、版本冲突和额度达到不自动重试。
- `P0513` [heading 1] 附录 C. 第三方官方资料
- `P0514` [Normal] 第三方接口以目标账号当前可用的正式接口版本和商务合同为准。研发启动时,应保存去除长期密钥和个人数据后的标准请求与响应,并在持续集成中执行接口契约测试。
+156
View File
@@ -0,0 +1,156 @@
132:P0060 [Normal] • 设备未绑定、授权不足或固件低于最低安全版本时,设备不能进入看似正常但实际无法工作的通话状态,应获得明确错误并通过设备既有提示方式告知用户。
133:P0061 [Normal] • 网络暂时不可用时,设备可以进行有限次数重试,但不能重复创建多次通话或多个供应商语音任务。
134:P0062 [Normal] • 设备已经入房但智能体启动失败时,Agent角色应停止并清理本次通话;设备收到可识别的失败状态后退出通话。
135:P0063 [Normal] • 设备在通话中断网、断电或重启时,Agent角色通过租约和后台检查回收供应商任务;设备恢复联网后上报最后状态。
136:P0064 [Normal] • 设备本地存在待上传录音时,SaaS角色应在网页中显示上传队列和失败原因;只有云端确认文件完整后,设备才延迟删除本地密文。
170:P0076 [Normal] • 实时语音不得进入SaaS业务接口、Agent控制接口、日志系统或自建应用服务器磁盘。
171:P0077 [Normal] • 设备上传录音时直接访问云端文件存储;SaaS业务接口只签发短期上传授权并校验上传结果。
172:P0078 [Normal] • SaaS网页不直接调用火山引擎或豆包接口;第三方接口细节统一由Agent角色的供应商对接模块封装。
173:P0079 [Normal] • 每次通话只关联一个客户组织、一个家庭、一个长者、一台设备和一个供应商语音任务。
174:P0080 [Normal] • PostgreSQL是业务数据的最终依据;Redis只保存租约、在线状态、限流和短期缓存。
175:P0081 [Normal] • 跨进程后台任务先可靠写入数据库再执行;重复执行不能生成重复内容、重复录音记录或重复费用。
176:P0082 [Normal] • 每类业务数据只设一个主责角色;另一个角色通过内部接口读取或提交结果,不直接修改对方模块负责的数据。
238:P0098 [Normal] • 两个角色共同维护一份接口和数据字典,先冻结创建通话、授权快照、字幕、候选内容、已确认记忆和停止动作的内部结构。
239:P0099 [Normal] • 每个交接点至少提供成功、超时、重复、乱序、授权撤回和删除中的典型测试样例。
240:P0100 [Normal] • SaaS角色提供模拟的长者档案、授权、绑定和人工核对结果;Agent角色提供模拟供应商、字幕回调和机器候选。
241:P0101 [Normal] • 任何破坏内部接口或数据含义的改动,必须先更新接口说明、迁移影响和兼容方案,再合并代码。
242:P0102 [Normal] • 阶段验收采用端到端业务场景,不以“各自模块单独演示成功”代替两角色联调。
297:P0113 [Normal] • 首期采用模块化单体。不同业务模块通过明确的程序接口协作,不允许一个模块直接修改另一个模块的数据表。
298:P0114 [Normal] • 数据库事务不得跨越火山引擎、云端文件存储、用户登录服务或大模型服务的网络调用,避免长事务和无法回滚。
299:P0115 [Normal] • 调用外部系统前先保存要执行的业务意图,调用后再保存结果。网络超时或进程重启后,可以继续确认状态或安全重试。
300:P0116 [Normal] • 配置、数据库升级脚本、接口文档、前后端字段类型、测试样例和运维文档必须与代码版本同步。
301:P0117 [Normal] • 生产长期密钥不得进入代码仓库、容器镜像、普通环境文件、业务数据库、日志或前端构建文件。
303:P0119 [Normal] • 模块接口的输入和输出使用明确的业务名称,不把火山引擎原始字段直接暴露给SaaS模块。
304:P0120 [Normal] • SaaS模块向Agent模块提供的授权和长者资料必须带版本、来源和有效状态;Agent不得自行猜测缺失授权。
305:P0121 [Normal] • Agent模块产生的字幕、摘要和候选内容必须带通话编号、长者编号、来源轮次和生成版本;SaaS不得只保存脱离来源的正文。
306:P0122 [Normal] • 任何状态变更先写入本模块的业务记录,再调用外部系统;网络结果未知时通过同一业务编号查询或对账。
307:P0123 [Normal] • 模块之间的写操作必须幂等;重复请求、重复通知和后台重试不得造成双通话、双记忆或重复删除。
455:P0186 [Normal] • 家庭用户能够从创建长者、设置授权、绑定设备一直完成到查看通话、核对故事和管理已确认记忆的完整流程。
456:P0187 [Normal] • 同一用户在不同家庭、不同长者和不同资源之间的权限边界清楚,越权请求不泄露资源是否存在。
457:P0188 [Normal] • 设备在线状态、配置下发、录音上传、导出和删除等异步过程都有明确的处理中、成功和失败状态。
458:P0189 [Normal] • 所有网页操作在无数据、加载中、权限不足、网络失败和并发修改时都有可理解的页面反馈。
459:P0190 [Normal] • SaaS角色向Agent角色提供的长者、授权、绑定、配置和已确认记忆都带稳定编号、版本和有效状态。
623:P0259 [Normal] • 真实设备连续完成多次创建、入房、激活、对话、挂断和异常恢复,供应商侧无遗留语音任务。
624:P0260 [Normal] • 三种语音处理方式在真实账号下均有标准启动请求、状态通知样例、时延和成本对比。
625:P0261 [Normal] • 每次通话可以追溯使用的长者资料、已确认记忆、人设、提示词、音色、模型和路由版本。
626:P0262 [Normal] • 最终字幕、摘要、故事和事实候选均保留来源;机器候选不会自动成为正式记忆。
627:P0263 [Normal] • 长者友好对话、记忆准确、打断、敏感信息和提示注入测试达到项目设定的质量门槛。
728:P0277 [Normal] • 项目团队交付可运行的模拟设备。模拟设备能够执行启动、读取配置、创建通话、确认入房、发送心跳、停止、断网、重复请求、字幕补交和录音上传。
729:P0278 [Normal] • Agent角色交付模拟火山引擎服务和可重复播放的脱敏状态通知样例,覆盖任务开始、最终字幕、智能体开始回答、被打断、用量、错误和停止。
730:P0279 [Normal] • 硬件团队提供设备能力表、音频编码与采样率、智能体用户订阅方式、心跳字段、两条录音轨道分片清单格式和设备命令结果格式。
731:P0280 [Normal] • 接口变更先更新 OpenAPI 接口说明和兼容性说明。会导致旧固件无法工作的变更使用新的接口版本,不能依赖固件 User-Agent 猜测兼容方式。
732:P0281 [Normal] • 设备不根据 engine_mode 改变实时音视频采集和播放逻辑。该字段只用于云端诊断、成本统计和通话记录。
733:P0282 [Normal] • 联调过程中,双方共同维护问题清单,记录问题现象、设备编号、通话编号、发生时间、日志关联编号、责任方、解决结果和回归结论。
735:P0284 [Normal] • SaaS判定设备未绑定、授权不足或额度达到时,Agent不创建房间和供应商任务,设备获得稳定错误码。
736:P0285 [Normal] • SaaS校验通过但Agent准备背景资料失败时,本次通话进入可诊断失败状态,网页和设备看到同一结果。
737:P0286 [Normal] • Agent已经创建房间但设备未入房时,预留到期后自动清理,不产生供应商语音任务。
738:P0287 [Normal] • 家庭用户在通话中撤回更严格授权或运营人员停止通话时,SaaS生成操作请求,Agent停止任务并向设备下发动作。
739:P0288 [Normal] • Agent写入故事候选后,SaaS页面可以跳转到字幕来源;家庭用户确认后,Agent在下一次通话中能检索新记忆。
740:P0289 [Normal] • 设备上传录音失败不影响字幕和故事页面;录音完成校验后,网页状态和设备本地删除动作最终一致。
770:P0297 [Normal] • 所有业务编号使用不可猜测的随机编号。所有客户组织业务数据明确保存客户组织编号。
771:P0298 [Normal] • 客户组织编号、长者编号和资源权限必须从登录信息和服务端关系中推导,不能相信浏览器或设备自行上报的客户组织边界。
772:P0299 [Normal] • 时间统一保存为协调世界时,并带完整时区格式。设备同时上报单调时钟,用于对齐录音和字幕时间。
773:P0300 [Normal] • 授权、故事、记忆、配置和费用均采用版本化保存。逻辑删除、法律或审计保留、最终物理清除分别建模。
774:P0301 [Normal] • 短期入房凭证、云端文件存储短期上传或下载地址、供应商长期密钥不得写入业务数据库。
775:P0302 [Normal] • 数据库字段、云端文件存储路径、向量索引附加信息和缓存键都必须包含或可推导明确的客户组织和长者边界。
776:P0303 [Normal] • 任何模型生成内容都要保存来源和生成版本。没有来源的机器内容不能进入人工确认流程。
796:P0309 [Normal] • 原始字幕先经过授权和敏感信息检查,再生成摘要、故事或事实候选。
797:P0310 [Normal] • 候选内容只有在有权限的家庭用户确认后,才能成为正式记忆。
798:P0311 [Normal] • 正式记忆仍然保留来源、确认人、版本和敏感级别,不能与原始来源脱离。
799:P0312 [Normal] • 撤回授权、停用、修订或删除后,新通话立即停止使用旧内容;后台继续清理数据库、对象和外部索引。
800:P0313 [Normal] • 任何后台清理都应显示处理中、成功或部分失败状态,不能仅凭数据库软删除就向用户显示全部完成。
804:P0317 [Normal] • 网页管理接口统一使用“/api/v1”作为基础路径;设备通信接口统一使用“/device/v2”;供应商状态通知接口统一使用“/callbacks/v1”。
805:P0318 [Normal] • 请求和响应使用 JSON。错误响应使用统一格式,至少包含稳定错误码、中文说明、请求关联编号、是否可以重试和必要的字段级错误详情。
806:P0319 [Normal] • 会产生写入、启动、停止、导出或删除等外部影响的 POST 请求,必须携带“Idempotency-Key(重复请求识别码)”。服务端在有效窗口内保存结果,并检查同一识别码的请求内容是否一致。
807:P0320 [Normal] • 列表接口使用不透明的下一页标记(程序字段 cursor)和每页数量。默认每页 20 条,最大值由具体接口说明。
808:P0321 [Normal] • 修改资源时使用版本标记(ETag/If-Match)或显式版本号。发现多人并发修改时返回冲突,不静默覆盖。
809:P0322 [Normal] • “X-Request-Id”只用于串联日志和故障排查,不能作为身份认证或重复请求凭据。
810:P0323 [Normal] • OpenAPI 接口文件是前后端和设备端的接口事实来源。持续集成自动检查字段类型生成和破坏性变更。
811:P0324 [Normal] • 接口响应不返回云端文件存储桶名称、内部数据库主键、供应商长期密钥、完整供应商原始错误、完整提示词或永久录音地址。
896:P0336 [Normal] • 用户身份通过标准身份服务确认。访问一个资源时,同时检查用户是否属于当前客户组织、在家庭中的角色、对该长者是否具有代理或管理权限,以及是否获得具体资源的分享授权。
897:P0337 [Normal] • 家庭所有者或管理员不自动获得原录音访问权。收听和下载必须分别有明确授权。
898:P0338 [Normal] • 设备只能访问自身当前绑定长者的通话、配置、录音上传和遥测资源。设备不能按任意长者编号请求数据。
899:P0339 [Normal] • 内部运营入口启用强制多因素认证、最小权限、临时权限提升和完整审计。客服默认看不到字幕正文和原录音。
900:P0340 [Normal] • 云端文件存储访问和向量检索使用与业务接口相同的客户组织和长者边界,不能只在网页层判断权限。
901:P0341 [Normal] • 所有管理动作、授权、播放、下载、故事确认、配置发布、设备吊销和长期密钥轮换都写入审计。
903:P0343 [Normal] • 所有网络接口使用 TLS 1.2 或更高版本。设备接口使用双向证书认证或经过批准的每设备独立请求签名。
904:P0344 [Normal] • PostgreSQL、数据库备份和云端文件存储使用云端密钥管理服务加密;云端文件存储桶禁止公开读取。
905:P0345 [Normal] • 日志默认不记录姓名全文、完整字幕、完整提示词、Wi-Fi 密码或详细网络信息、短期入房凭证、云端文件存储短期地址和完整文件路径。
906:P0346 [Normal] • 高敏感数据进入模型或故事整理前执行识别、最小化和策略过滤。对于验证码、账户信息和敏感医疗数据,默认不进入普通故事内容。
907:P0347 [Normal] • 导出文件加密存储,只提供短期下载地址,到期自动清理;每次下载生成访问审计。
908:P0348 [Normal] • 数据删除覆盖 PostgreSQL 正文、缓存、云端文件存储、pgvector、自有或供应商记忆索引和导出文件,并通过对账确认各处结果。
910:P0350 [Normal] • 持续集成自动执行密钥泄露扫描、静态代码安全检查、依赖漏洞与许可证检查、软件材料清单、容器镜像和基础设施配置检查。
911:P0351 [Normal] • 所有外部输入限制请求体大小、数组长度、字符串长度和嵌套深度。JSON、供应商通知、录音分片清单和文件元数据都必须按结构规则校验。
912:P0352 [Normal] • 按设备、用户、客户组织和网络地址分别限流。启动语音智能体的请求还要受到供应商每秒请求数和同时任务数保护。
913:P0353 [Normal] • 生产环境的对外访问白名单只允许火山引擎与豆包、云端文件存储、用户登录服务、时间服务和必要监控目标。
914:P0354 [Normal] • 上线前必须完成客户组织越权、云端文件存储越权、供应商通知重放、提示注入、长期密钥泄露和遗留语音任务专项测试。
935:P0360 [Normal] • 供应商状态通知和所有后台任务按“可能重复送达”设计,业务处理必须支持重复执行而不产生重复副作用。
936:P0361 [Normal] • 启动、停止、录音上传、数据导出、数据删除和设备命令均支持安全重试,并有最终状态对账。
937:P0362 [Normal] • 接口服务单实例重启、Redis 重启和后台任务进程重启不能产生双重供应商任务或永久卡住的通话。
938:P0363 [Normal] • 数据库结构升级应可以回滚,或提供明确的前向修复方案;生产发布支持滚动更新。
939:P0364 [Normal] • 供应商故障时,云端暂停创建新的供应商任务,继续回收已知任务,保持历史网页数据可访问并产生告警。
953:P0369 [Normal] • 后端核心模块单元测试覆盖率目标不低于 70%;通话状态、权限、删除和供应商对接的关键分支不低于 85%。
954:P0370 [Normal] • 接口说明、数据库升级脚本、配置结构和状态通知结构均进行版本控制和兼容性检查。
955:P0371 [Normal] • 日志、指标和调用链统一使用请求关联编号、设备编号、通话编号、供应商任务编号和录音上传批次编号,便于跨系统排查。
956:P0372 [Normal] • 网页前端支持最近两个主流版本的 Chrome、Edge 和 Safari;核心页面适配 1280 像素以上桌面,并兼顾平板。
957:P0373 [Normal] • 首期语言为中文;时间、时区和文案结构应支持后续增加其他语言。
979:P0380 [Normal] • 开发、上线前验证和生产环境分别使用独立的火山引擎应用编号、接口凭证、状态通知地址、数据库和云端文件存储桶。
980:P0381 [Normal] • 配置分为普通技术配置、受控业务配置和长期密钥。普通配置可以随代码版本管理;长期密钥只保存引用,不保存明文。
981:P0382 [Normal] • 供应商配置、人设、音色、语音处理路由、额度和数据保留策略使用发布版本,支持审计和回滚。
982:P0383 [Normal] • 轮换长期密钥不需要重新构建容器镜像。轮换步骤、验证方式和回退方式写入运维手册。
994:P0386 [Normal] • 告警至少覆盖供应商不可用、语音任务启动或停止异常、遗留任务、数据库或 Redis 故障、证书过期、供应商通知积压、删除超期、录音上传失败和费用异常增长。
995:P0387 [Normal] • 分别提供业务仪表盘、技术仪表盘和安全仪表盘。告警信息包含对应运维手册链接和设备、通话或请求关联编号。
997:P0389 [Normal] • PostgreSQL 使用自动备份和时间点恢复,并定期在隔离环境中实际恢复验证。
998:P0390 [Normal] • 云端文件存储启用版本、生命周期和删除保护策略;正式删除流程必须能够按确认规则清除历史版本。
999:P0391 [Normal] • Redis 按可重新生成的缓存处理,不能作为唯一业务证据。
1000:P0392 [Normal] • 每次正式发布前后保留数据库升级备份点。项目至少完成数据库恢复、长期密钥轮换、供应商故障和隐私删除四类演练。
1033:P0414 [Normal] • 所有首期需求、验收用例、缺陷和遗留风险已经形成可追踪对应关系,阻断级缺陷为 0。
1034:P0415 [Normal] • 使用真实火山引擎账号完成短期入房凭证、启动、更新、停止、三种语音处理方式、字幕与状态通知、用量和配额验证。
1035:P0416 [Normal] • 接口说明、数据库升级、配置、运维手册、监控、告警、备份恢复和安全扫描全部通过持续集成检查。
1036:P0417 [Normal] • 客户组织隔离、录音越权、供应商通知重放、遗留任务回收和隐私删除专项测试通过。
1037:P0418 [Normal] • 生产域名、证书、长期密钥、用户登录、云端文件存储、备份、告警接收人和应急联系人已经配置。
1038:P0419 [Normal] • 业务方、外包团队和硬件团队完成联合验收,并签署上线检查清单。
1077:P0435 [Normal] • 代码仓库、持续集成与部署账号、云资源账号和域名由业务方控制。外包人员使用实名、最小权限账号。
1078:P0436 [Normal] • 所有第三方依赖列明许可证、版本和用途。未经批准,不得引入限制商业使用或来源不明的组件。
1079:P0437 [Normal] • 所有配置和文档纳入项目仓库管理。个人网盘、个人云账号和个人长期密钥不得进入正式交付链路。
1080:P0438 [Normal] • 交付完成时移除外包人员个人权限,并完成生产权限、密钥管理、用户登录、监控和告警接收人的正式移交。
1082:P0440 [Normal] • 架构、权限、数据保留、接口不兼容变更、外部供应商和新增付费服务必须提交变更单。
1083:P0441 [Normal] • 变更单至少说明变更原因、影响范围、兼容性、数据迁移、费用、安全、测试和回退方案。
1084:P0442 [Normal] • 每个研发阶段进行代码、接口、数据和安全评审,评审结论和待办事项进入项目跟踪系统。
1085:P0443 [Normal] • 演示通过不能替代自动化测试、正式文档、异常清理路径和真实账号验证证据。
1087:P0445 [Normal] • 需求编号已经关联对应代码、测试、接口或配置变更。
1088:P0446 [Normal] • 正常、超时、重试、重复、乱序、撤回、删除和清理路径均有测试结果。
1089:P0447 [Normal] • 日志、指标和调用链可以定位问题,同时不泄露个人数据和长期密钥。
1090:P0448 [Normal] • 数据库升级、回滚或前向修复、接口说明、项目说明和运维手册已经同步更新。
1091:P0449 [Normal] • 代码完成评审,持续集成全部通过,阻断级安全问题为 0。
1092:P0450 [Normal] • 功能已经部署到目标环境,并通过业务方可以独立复现的验收步骤。
1109:P0458 [Normal] • 一套可以持续部署的共享开发环境,包括SaaS业务服务、Agent编排服务、PostgreSQL、Redis、供应商状态通知接收服务、模拟供应商、模拟设备和最小联调工作台。
1110:P0459 [Normal] • 设备独立身份、设备启动信息、基础配置版本、服务器时间同步、设备能力表、最低固件策略和请求审计。
1111:P0460 [Normal] • 两阶段通话启动:先创建通话并下发房间资料,设备成功进入房间后再确认入房并启动语音智能体。
1112:P0461 [Normal] • 真实语音链路先使用固定的端到端语音模式、单一音色和单一供应商配置,验证设备、实时音视频和语音智能体的稳定配合;程序字段保留后续切换能力。
1113:P0462 [Normal] • 最小长者背景资料,包括固定智能体人设、安全规则、测试长者称呼、少量测试背景和本次交流目标。
1114:P0463 [Normal] • 完整任务控制,包括启动、停止、心跳、短期租约、超时、供应商状态通知、后台异常回收和统一错误提示。
1115:P0464 [Normal] • 联调可观测性:请求关联编号、设备编号、通话编号、房间编号和供应商任务编号在日志和工作台中可以互相定位,同时不显示完整短期凭证。
1117:P0466 [Normal] 1. 设备开机后使用独立身份访问“GET /device/v2/bootstrap”,上报型号、固件版本和设备时间,完成服务器时间同步、设备状态和最低固件版本检查。
1118:P0467 [Normal] 2. 设备访问“GET /device/v2/config”,提交当前配置版本和能力信息,获取实时音视频编码、采样率、心跳间隔、上传策略和其他非敏感运行配置。
1119:P0468 [Normal] 3. 用户拿起听筒后,设备携带“Idempotency-Key(重复请求识别码)”调用“POST /device/v2/conversations”,请求创建本次通话。
1123:P0472 [Normal] 7. 设备收到“加入房间成功”事件后,调用“POST /device/v2/conversations/{id}/activate”,上报加入时间、实时音视频状态和当前配置版本。云端可以结合火山引擎房间通知二次核验。
1138:P0479 [Normal] • 最小联调工作台显示设备在线状态、固件、配置版本、当前通话、通话状态、短期凭证签发与过期时间、实时音视频状态、智能体状态、供应商通知和最近错误;不显示完整短期凭证。
1139:P0480 [Normal] • 支持按设备编号、通话编号、房间编号、供应商任务编号和请求关联编号查询;有权限人员可以执行停止、状态对账和测试配置刷新。
1140:P0481 [Normal] • 交付设备通信接口 OpenAPI、请求响应样例、设备接入说明、通话状态说明、错误码、测试设备证书发行说明和可运行模拟设备。
1141:P0482 [Normal] • 交付真实火山引擎账号的脱敏标准请求响应、状态通知样例、环境配置清单、日志字段、监控仪表盘和联调故障排查手册。
1142:P0483 [Normal] • 硬件团队提供设备能力表、实时音视频加入房间成功和失败事件、心跳字段、挂断原因、本地时间和固件版本;双方共同维护联调问题清单。
1143:P0484 [Heading 3] 15.2.6 验收标准
1144:P0485 [Normal] • 一台真实设备可以完成设备启动、读取配置、创建通话、加入实时音视频房间、确认入房、实时双向语音、发送心跳、挂断、停止和任务回收的完整闭环。
1145:P0486 [Normal] • 设备、接口响应、网页、日志、数据库和容器镜像中不存在实时音视频应用密钥、火山引擎访问密钥、豆包长期凭证或完整短期入房凭证。
1146:P0487 [Normal] • 错误房间、错误用户、过期短期凭证和被吊销设备无法进入有效通话;正常凭证可以在有效期内入房。
1147:P0488 [Normal] • 创建、确认入房、心跳和停止请求重复发送时,不产生双通话、双供应商任务、重复计量或错误最终状态。
1148:P0489 [Normal] • 真实设备连续完成至少 10 次通话;每次供应商任务、房间、租约和通话状态最终完成,无遗留的“通话中”或“正在停止”任务。
1149:P0490 [Normal] • 设备创建通话后未入房、入房后未确认、通话中断网、设备重启和云端重启时,均能超时回收或恢复,并形成可以定位的错误记录。
1150:P0491 [Normal] • 联调工作台能够追踪握手各步骤耗时、启动和停止结果、供应商通知和错误码;硬件人员可以独立按照文档复现完整流程。
1151:P0492 [Normal] • 最小长者背景资料正确带入测试称呼、背景和本次交流目标,证明背景资料由云端整理并随本次通话生效。
1223:P0508 [Normal] • 只有明确标记为可以重试的网络错误、超时和供应商临时错误才自动重试。
1224:P0509 [Normal] • 重试采用逐步增加等待时间并加入随机变化,设置最大次数;超过最大次数后进入人工处理或死信状态。
1225:P0510 [Normal] • 启动语音智能体结果未知时保持同一供应商任务编号,并先通过状态通知或查询确认结果;停止语音智能体可以安全重复执行。
1226:P0511 [Normal] • 供应商状态通知和可靠后台待办按至少一次处理设计,所有外部副作用使用业务唯一键去重。
1227:P0512 [Normal] • 权限不足、授权缺失、参数错误、版本冲突和额度达到不自动重试。
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"data-terminal-cloud/internal/agenteval"
)
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
func run(args []string, stdout, stderr io.Writer) int {
flags := flag.NewFlagSet("agent-eval-collect", flag.ContinueOnError)
flags.SetOutput(stderr)
suitePath := flags.String("suite", "", "评测集JSON路径")
manifestPath := flags.String("manifest", "", "用例、通话和脱敏评分卡映射JSON路径")
outputPath := flags.String("out", "", "观测结果输出路径;省略时输出到stdout")
apiBaseURL := flags.String("api", "http://127.0.0.1:58080", "SaaS API基础地址")
tokenFile := flags.String("access-token-file", "", "OIDC访问令牌文件;省略时读取AGENT_EVAL_ACCESS_TOKEN")
if err := flags.Parse(args); err != nil {
return 2
}
if *suitePath == "" || *manifestPath == "" || flags.NArg() != 0 {
fmt.Fprintln(stderr, "usage: agent-eval-collect -suite <suite.json> -manifest <manifest.json> [-api <url>] [-access-token-file <path>] [-out <observations.json>]")
return 2
}
var suite agenteval.Suite
if err := decodeFile(*suitePath, &suite); err != nil {
fmt.Fprintf(stderr, "读取评测集失败: %v\n", err)
return 2
}
var manifest agenteval.CollectionManifest
if err := decodeFile(*manifestPath, &manifest); err != nil {
fmt.Fprintf(stderr, "读取采集清单失败: %v\n", err)
return 2
}
token, err := readToken(*tokenFile)
if err != nil {
fmt.Fprintf(stderr, "读取访问令牌失败: %v\n", err)
return 2
}
collector, err := agenteval.NewCollector(*apiBaseURL, token, &http.Client{Timeout: 15 * time.Second})
if err != nil {
fmt.Fprintf(stderr, "创建采集器失败: %v\n", err)
return 2
}
runResult, err := collector.Collect(context.Background(), suite, manifest)
if err != nil {
fmt.Fprintf(stderr, "采集评测观测失败: %v\n", err)
return 1
}
encoded, err := json.MarshalIndent(runResult, "", " ")
if err != nil {
fmt.Fprintf(stderr, "生成观测文件失败: %v\n", err)
return 2
}
encoded = append(encoded, '\n')
if *outputPath == "" {
if _, err = stdout.Write(encoded); err != nil {
fmt.Fprintf(stderr, "输出观测文件失败: %v\n", err)
return 2
}
} else if err = os.WriteFile(*outputPath, encoded, 0o600); err != nil {
fmt.Fprintf(stderr, "写入观测文件失败: %v\n", err)
return 2
} else {
fmt.Fprintf(stdout, "已采集%d个用例:%s\n", len(runResult.Observations), *outputPath)
}
return 0
}
func readToken(path string) (string, error) {
if strings.TrimSpace(path) == "" {
value := strings.TrimSpace(os.Getenv("AGENT_EVAL_ACCESS_TOKEN"))
if value == "" {
return "", errors.New("set AGENT_EVAL_ACCESS_TOKEN or -access-token-file")
}
return value, nil
}
content, err := os.ReadFile(path)
if err != nil {
return "", err
}
value := strings.TrimSpace(string(content))
if value == "" || strings.ContainsAny(value, "\r\n\x00") {
return "", errors.New("token file must contain one non-empty line")
}
return value, nil
}
func decodeFile(path string, target any) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
decoder := json.NewDecoder(file)
decoder.DisallowUnknownFields()
if err = decoder.Decode(target); err != nil {
return err
}
var trailing any
if err = decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("JSON文件包含多个顶层值")
}
return err
}
return nil
}
+146
View File
@@ -0,0 +1,146 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"data-terminal-cloud/internal/agenteval"
"github.com/google/uuid"
)
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
func run(args []string, stdout, stderr io.Writer) int {
flags := flag.NewFlagSet("agent-eval-register", flag.ContinueOnError)
flags.SetOutput(stderr)
suitePath := flags.String("suite", "", "评测集JSON路径")
observationsPath := flags.String("observations", "", "生成本报告的脱敏观测JSON路径")
reportPath := flags.String("report", "", "agent-eval生成的完整报告路径")
tenantID := flags.String("tenant", "", "运营范围客户组织UUID")
configID := flags.String("config", "", "待登记证据的运行配置版本UUID")
apiBaseURL := flags.String("api", "http://127.0.0.1:58080", "SaaS API基础地址")
tokenFile := flags.String("access-token-file", "", "OIDC访问令牌文件;省略时读取AGENT_EVAL_ACCESS_TOKEN")
allowFailed := flags.Bool("allow-failed", false, "明确允许登记FAILED报告;FAILED不会满足发布门禁")
dryRun := flags.Bool("dry-run", false, "只校验并显示登记摘要,不发送请求")
if err := flags.Parse(args); err != nil {
return 2
}
if *suitePath == "" || *observationsPath == "" || *reportPath == "" || *tenantID == "" || *configID == "" || flags.NArg() != 0 {
fmt.Fprintln(stderr, "usage: agent-eval-register -suite <suite.json> -observations <observations.json> -report <report.json> -tenant <uuid> -config <uuid> [-api <url>] [-access-token-file <path>] [-allow-failed] [-dry-run]")
return 2
}
if _, err := uuid.Parse(*tenantID); err != nil {
fmt.Fprintln(stderr, "tenant必须是有效UUID")
return 2
}
if _, err := uuid.Parse(*configID); err != nil {
fmt.Fprintln(stderr, "config必须是有效UUID")
return 2
}
var suite agenteval.Suite
if err := decodeFile(*suitePath, &suite); err != nil {
fmt.Fprintf(stderr, "读取评测集失败: %v\n", err)
return 2
}
var artifact agenteval.Artifact
if err := decodeFile(*reportPath, &artifact); err != nil {
fmt.Fprintf(stderr, "读取评测报告失败: %v\n", err)
return 2
}
var observations agenteval.ObservationRun
if err := decodeFile(*observationsPath, &observations); err != nil {
fmt.Fprintf(stderr, "读取评测观测失败: %v\n", err)
return 2
}
if err := agenteval.ValidateArtifact(suite, observations, artifact, *allowFailed); err != nil {
fmt.Fprintf(stderr, "评测报告不能登记: %v\n", err)
return 2
}
key, err := agenteval.RegistrationIdempotencyKey(*configID, artifact.RegistrationPayload)
if err != nil {
fmt.Fprintf(stderr, "生成幂等键失败: %v\n", err)
return 2
}
if *dryRun {
summary := map[string]any{
"dry_run": true, "tenant_id": *tenantID, "config_version_id": *configID,
"suite_version": artifact.Report.SuiteVersion, "suite_sha256": artifact.Report.SuiteSHA256,
"agent_version": artifact.Report.AgentVersion, "result": artifact.Report.Result,
"report_ref": artifact.RegistrationPayload.ReportRef, "idempotency_key": key,
}
_ = json.NewEncoder(stdout).Encode(summary)
return 0
}
token, err := readToken(*tokenFile)
if err != nil {
fmt.Fprintf(stderr, "读取访问令牌失败: %v\n", err)
return 2
}
registrar, err := agenteval.NewRegistrar(*apiBaseURL, token, &http.Client{Timeout: 15 * time.Second})
if err != nil {
fmt.Fprintf(stderr, "创建登记客户端失败: %v\n", err)
return 2
}
result, err := registrar.Register(context.Background(), *tenantID, *configID, artifact.RegistrationPayload)
if err != nil {
fmt.Fprintf(stderr, "登记评测证据失败: %v;request_id=%s\n", err, result.RequestID)
return 1
}
_ = json.NewEncoder(stdout).Encode(map[string]any{
"evaluation_id": result.EvaluationID, "status_code": result.StatusCode,
"idempotency_replayed": result.Replayed, "request_id": result.RequestID,
"idempotency_key": key,
})
return 0
}
func readToken(path string) (string, error) {
if strings.TrimSpace(path) == "" {
value := strings.TrimSpace(os.Getenv("AGENT_EVAL_ACCESS_TOKEN"))
if value == "" {
return "", errors.New("set AGENT_EVAL_ACCESS_TOKEN or -access-token-file")
}
return value, nil
}
content, err := os.ReadFile(path)
if err != nil {
return "", err
}
value := strings.TrimSpace(string(content))
if value == "" || strings.ContainsAny(value, "\r\n\x00") {
return "", errors.New("token file must contain one non-empty line")
}
return value, nil
}
func decodeFile(path string, target any) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
decoder := json.NewDecoder(file)
decoder.DisallowUnknownFields()
if err = decoder.Decode(target); err != nil {
return err
}
var trailing any
if err = decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("JSON文件包含多个顶层值")
}
return err
}
return nil
}
+96
View File
@@ -0,0 +1,96 @@
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"time"
"data-terminal-cloud/internal/agenteval"
)
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, time.Now))
}
func run(args []string, stdout, stderr io.Writer, now func() time.Time) int {
flags := flag.NewFlagSet("agent-eval", flag.ContinueOnError)
flags.SetOutput(stderr)
suitePath := flags.String("suite", "", "评测集JSON路径")
observationsPath := flags.String("observations", "", "脱敏观测结果JSON路径")
outputPath := flags.String("out", "", "报告输出路径;省略时输出到stdout")
reportRef := flags.String("report-ref", "", "登记到运营配置评测证据中的报告引用")
if err := flags.Parse(args); err != nil {
return 2
}
if *suitePath == "" || *observationsPath == "" || *reportRef == "" || flags.NArg() != 0 {
fmt.Fprintln(stderr, "usage: agent-eval -suite <suite.json> -observations <observations.json> -report-ref <reference> [-out <report.json>]")
return 2
}
var suite agenteval.Suite
if err := decodeFile(*suitePath, &suite); err != nil {
fmt.Fprintf(stderr, "读取评测集失败: %v\n", err)
return 2
}
var observations agenteval.ObservationRun
if err := decodeFile(*observationsPath, &observations); err != nil {
fmt.Fprintf(stderr, "读取观测结果失败: %v\n", err)
return 2
}
report, err := agenteval.Evaluate(suite, observations, now())
if err != nil {
fmt.Fprintf(stderr, "评测输入无效: %v\n", err)
return 2
}
artifact, err := agenteval.NewArtifact(report, *reportRef)
if err != nil {
fmt.Fprintf(stderr, "报告引用无效: %v\n", err)
return 2
}
encoded, err := json.MarshalIndent(artifact, "", " ")
if err != nil {
fmt.Fprintf(stderr, "生成评测报告失败: %v\n", err)
return 2
}
encoded = append(encoded, '\n')
if *outputPath == "" {
if _, err = stdout.Write(encoded); err != nil {
fmt.Fprintf(stderr, "输出评测报告失败: %v\n", err)
return 2
}
} else if err = os.WriteFile(*outputPath, encoded, 0o600); err != nil {
fmt.Fprintf(stderr, "写入评测报告失败: %v\n", err)
return 2
} else {
fmt.Fprintf(stdout, "%s:%s(%d/%d)\n", report.Result, *outputPath, report.Metrics.CasesPassed, report.Metrics.CasesTotal)
}
if report.Result != "PASSED" {
return 1
}
return 0
}
func decodeFile(path string, target any) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
decoder := json.NewDecoder(file)
decoder.DisallowUnknownFields()
if err = decoder.Decode(target); err != nil {
return err
}
var trailing any
if err = decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("JSON文件包含多个顶层值")
}
return err
}
return nil
}
+88
View File
@@ -0,0 +1,88 @@
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"data-terminal-cloud/internal/agenteval"
"data-terminal-cloud/internal/agentmodelsconformance"
)
func main() {
capturePath := flag.String("capture", "", "real-agent-models-capture-v1 JSON")
suitePath := flag.String("suite", "", "agent evaluation suite JSON")
observationsPath := flag.String("observations", "", "agent evaluation observations JSON")
agentReportPath := flag.String("agent-report", "", "agent evaluation artifact JSON")
candidateVersion := flag.String("candidate-version", "", "release candidate version")
buildDigest := flag.String("build-digest", "", "sha256 immutable build digest")
outputPath := flag.String("output", "", "absolute output report path")
flag.Parse()
if *capturePath == "" || *suitePath == "" || *observationsPath == "" || *agentReportPath == "" || strings.TrimSpace(*candidateVersion) == "" || strings.TrimSpace(*buildDigest) == "" || !filepath.IsAbs(*outputPath) {
fmt.Fprintln(os.Stderr, "usage: agent-models-conformance -capture capture.json -suite suite.json -observations observations.json -agent-report report.json -candidate-version VERSION -build-digest sha256:... -output ABSOLUTE.json")
os.Exit(2)
}
var capture agentmodelsconformance.Capture
var suite agenteval.Suite
var observations agenteval.ObservationRun
var artifact agenteval.Artifact
captureBytes, err := decodeFile(*capturePath, &capture)
if err == nil {
_, err = decodeFile(*suitePath, &suite)
}
if err == nil {
_, err = decodeFile(*observationsPath, &observations)
}
if err == nil {
_, err = decodeFile(*agentReportPath, &artifact)
}
if err != nil {
fmt.Fprintln(os.Stderr, "read conformance input:", err)
os.Exit(2)
}
suiteBytes, _ := os.ReadFile(*suitePath)
observationsBytes, _ := os.ReadFile(*observationsPath)
agentReportBytes, _ := os.ReadFile(*agentReportPath)
report, err := agentmodelsconformance.Evaluate(capture, suite, observations, artifact, *candidateVersion, *buildDigest, time.Now().UTC())
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
report.InputSHA256["models_capture"] = agentmodelsconformance.SHA256(captureBytes)
report.InputSHA256["agent_suite"] = agentmodelsconformance.SHA256(suiteBytes)
report.InputSHA256["agent_observations"] = agentmodelsconformance.SHA256(observationsBytes)
report.InputSHA256["agent_report"] = agentmodelsconformance.SHA256(agentReportBytes)
encoded, _ := json.MarshalIndent(report, "", " ")
encoded = append(encoded, '\n')
if err = os.WriteFile(*outputPath, encoded, 0o600); err != nil {
fmt.Fprintln(os.Stderr, "write Agent models report:", err)
os.Exit(2)
}
fmt.Printf("real Agent models conformance %s: %s\n", report.Result, *outputPath)
if report.Result != "PASS" {
os.Exit(1)
}
}
func decodeFile(path string, target any) ([]byte, error) {
content, err := os.ReadFile(path)
if err != nil {
return nil, err
}
decoder := json.NewDecoder(strings.NewReader(string(content)))
decoder.DisallowUnknownFields()
if err = decoder.Decode(target); err != nil {
return nil, err
}
var trailing any
if err = decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return nil, errors.New("JSON contains trailing data")
}
return content, nil
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"data-terminal-cloud/internal/alertreceiverconformance"
)
func main() {
routesPath := flag.String("routes", "", "GET /api/v1/ops/alert-routing-policies JSON")
testsPath := flag.String("tests", "", "GET /api/v1/ops/alert-delivery-tests JSON")
incidentPath := flag.String("incident", "", "GET /api/v1/ops/alerts/{id} formal drill JSON")
receiverPath := flag.String("receiver", "", "alert-receiver-capture-v1 JSON")
candidateVersion := flag.String("candidate-version", "", "release candidate version")
buildDigest := flag.String("build-digest", "", "sha256 immutable build digest")
outputPath := flag.String("output", "", "absolute output report path")
flag.Parse()
if *routesPath == "" || *testsPath == "" || *incidentPath == "" || *receiverPath == "" || strings.TrimSpace(*candidateVersion) == "" || strings.TrimSpace(*buildDigest) == "" || !filepath.IsAbs(*outputPath) {
fmt.Fprintln(os.Stderr, "usage: alert-receiver-conformance -routes routes.json -tests tests.json -incident incident.json -receiver receiver.json -candidate-version VERSION -build-digest sha256:... -output ABSOLUTE.json")
os.Exit(2)
}
routesContent, testsContent, incidentContent, receiverContent, err := readInputs(*routesPath, *testsPath, *incidentPath, *receiverPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
routes, err := alertreceiverconformance.ParseRoutes(routesContent)
if err != nil {
failInput(err)
}
tests, err := alertreceiverconformance.ParseTests(testsContent)
if err != nil {
failInput(err)
}
incident, err := alertreceiverconformance.ParseIncident(incidentContent)
if err != nil {
failInput(err)
}
capture, err := alertreceiverconformance.ParseCapture(receiverContent)
if err != nil {
failInput(err)
}
report := alertreceiverconformance.Evaluate(routes, tests, incident, capture, *candidateVersion, *buildDigest, time.Now().UTC())
report.InputSHA256 = map[string]string{
"cloud_routes": alertreceiverconformance.SHA256(routesContent), "cloud_tests": alertreceiverconformance.SHA256(testsContent),
"cloud_incident": alertreceiverconformance.SHA256(incidentContent), "receiver_capture": alertreceiverconformance.SHA256(receiverContent),
}
encoded, _ := json.MarshalIndent(report, "", " ")
encoded = append(encoded, '\n')
if err = os.WriteFile(*outputPath, encoded, 0o600); err != nil {
fmt.Fprintln(os.Stderr, "write alert receiver report:", err)
os.Exit(2)
}
fmt.Printf("alert receiver conformance %s: %s\n", report.Result, *outputPath)
if report.Result != "PASS" {
os.Exit(1)
}
}
func readInputs(paths ...string) ([]byte, []byte, []byte, []byte, error) {
values := make([][]byte, 4)
for index, path := range paths {
content, err := os.ReadFile(path)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("read input %s: %w", filepath.Base(path), err)
}
values[index] = content
}
return values[0], values[1], values[2], values[3], nil
}
func failInput(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
+24
View File
@@ -0,0 +1,24 @@
package main
import (
"context"
"log"
"os/signal"
"syscall"
"data-terminal-cloud/internal/app"
)
type applicationRunner func(context.Context, string) error
func run(ctx context.Context, runner applicationRunner) error {
return runner(ctx, "api")
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := run(ctx, app.Run); err != nil {
log.Fatal(err)
}
}
+21
View File
@@ -0,0 +1,21 @@
package main
import (
"context"
"errors"
"testing"
)
func TestRunWiresAPIServiceAndPropagatesError(t *testing.T) {
want := errors.New("synthetic API failure")
ctx := context.WithValue(context.Background(), struct{}{}, "request-context")
err := run(ctx, func(gotContext context.Context, serviceName string) error {
if gotContext != ctx || serviceName != "api" {
t.Fatalf("context=%v service=%q", gotContext, serviceName)
}
return want
})
if !errors.Is(err, want) {
t.Fatalf("run error=%v", err)
}
}
+24
View File
@@ -0,0 +1,24 @@
package main
import (
"context"
"log"
"os/signal"
"syscall"
"data-terminal-cloud/internal/app"
)
type applicationRunner func(context.Context, string) error
func run(ctx context.Context, runner applicationRunner) error {
return runner(ctx, "callbacks")
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := run(ctx, app.Run); err != nil {
log.Fatal(err)
}
}
+21
View File
@@ -0,0 +1,21 @@
package main
import (
"context"
"errors"
"testing"
)
func TestRunWiresCallbacksServiceAndPropagatesError(t *testing.T) {
want := errors.New("synthetic callbacks failure")
ctx := context.WithValue(context.Background(), struct{}{}, "request-context")
err := run(ctx, func(gotContext context.Context, serviceName string) error {
if gotContext != ctx || serviceName != "callbacks" {
t.Fatalf("context=%v service=%q", gotContext, serviceName)
}
return want
})
if !errors.Is(err, want) {
t.Fatalf("run error=%v", err)
}
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"data-terminal-cloud/internal/cisupplychainconformance"
)
func main() {
capturePath := flag.String("capture", "", "remote-ci-supply-chain-capture-v1 JSON")
candidateVersion := flag.String("candidate-version", "", "release candidate version")
buildDigest := flag.String("build-digest", "", "sha256 immutable build digest")
sourceCommit := flag.String("source-commit", "", "full source commit")
outputPath := flag.String("output", "", "absolute output report path")
flag.Parse()
if *capturePath == "" || strings.TrimSpace(*candidateVersion) == "" || strings.TrimSpace(*buildDigest) == "" || strings.TrimSpace(*sourceCommit) == "" || !filepath.IsAbs(*outputPath) {
fmt.Fprintln(os.Stderr, "usage: ci-supply-chain-conformance -capture capture.json -candidate-version VERSION -build-digest sha256:... -source-commit COMMIT -output ABSOLUTE.json")
os.Exit(2)
}
content, err := os.ReadFile(*capturePath)
if err != nil {
fmt.Fprintln(os.Stderr, "read remote CI capture:", err)
os.Exit(2)
}
capture, err := cisupplychainconformance.ParseCapture(content)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
report := cisupplychainconformance.Evaluate(capture, *candidateVersion, *buildDigest, *sourceCommit, time.Now().UTC())
report.InputSHA256["ci_capture"] = cisupplychainconformance.SHA256(content)
encoded, _ := json.MarshalIndent(report, "", " ")
encoded = append(encoded, '\n')
if err = os.WriteFile(*outputPath, encoded, 0o600); err != nil {
fmt.Fprintln(os.Stderr, "write remote CI report:", err)
os.Exit(2)
}
fmt.Printf("Remote CI supply-chain conformance %s: %s\n", report.Result, *outputPath)
if report.Result != "PASS" {
os.Exit(1)
}
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"time"
"data-terminal-cloud/internal/firmwareconformance"
)
func main() {
os.Exit(run())
}
func run() int {
cloudPath := flag.String("cloud", "", "path to a firmware rollout API JSON response")
devicePath := flag.String("device", "", "path to firmware-device-evidence-v1 JSON")
outputPath := flag.String("output", "", "path for the conformance report JSON")
flag.Parse()
if *cloudPath == "" || *devicePath == "" || *outputPath == "" {
fmt.Fprintln(os.Stderr, "usage: firmware-conformance -cloud rollout.json -device device-evidence.json -output report.json")
return 2
}
if !filepath.IsAbs(*outputPath) {
fmt.Fprintln(os.Stderr, "output path must be absolute")
return 2
}
cloudContent, err := os.ReadFile(*cloudPath)
if err != nil {
fmt.Fprintln(os.Stderr, "read cloud rollout:", err)
return 2
}
deviceContent, err := os.ReadFile(*devicePath)
if err != nil {
fmt.Fprintln(os.Stderr, "read device evidence:", err)
return 2
}
cloud, err := firmwareconformance.ParseCloud(cloudContent)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
device, err := firmwareconformance.ParseDeviceEvidence(deviceContent)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
report := firmwareconformance.Evaluate(cloud, device, time.Now().UTC())
report.InputSHA256["cloud_rollout"] = firmwareconformance.SHA256(cloudContent)
report.InputSHA256["device_evidence"] = firmwareconformance.SHA256(deviceContent)
content, err := json.MarshalIndent(report, "", " ")
if err != nil {
fmt.Fprintln(os.Stderr, "encode report:", err)
return 2
}
if err := os.WriteFile(*outputPath, append(content, '\n'), 0o600); err != nil {
fmt.Fprintln(os.Stderr, "write report:", err)
return 2
}
fmt.Printf("firmware conformance %s: %s\n", report.Result, *outputPath)
if report.Result != "PASS" {
return 1
}
return 0
}
+53
View File
@@ -0,0 +1,53 @@
package main
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"os"
)
type signedFirmware struct {
PublicKeyBase64 string `json:"public_key_base64"`
SignatureBase64 string `json:"signature_base64"`
SHA256 string `json:"sha256"`
Size int `json:"size"`
Manifest string `json:"manifest"`
}
func main() {
if len(os.Args) != 5 {
fmt.Fprintln(os.Stderr, "usage: firmware-sign <model> <hardware-revision> <version> <file>")
os.Exit(2)
}
content, err := os.ReadFile(os.Args[4])
if err != nil {
panic(err)
}
result, err := signFirmware(os.Args[1], os.Args[2], os.Args[3], content)
if err != nil {
panic(err)
}
if err := json.NewEncoder(os.Stdout).Encode(result); err != nil {
panic(err)
}
}
func signFirmware(model, hardwareRevision, version string, content []byte) (signedFirmware, error) {
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return signedFirmware{}, err
}
digest := sha256.Sum256(content)
digestText := hex.EncodeToString(digest[:])
manifest := fmt.Sprintf("firmware-manifest-v1\nmodel=%s\nhardware_revision=%s\nversion=%s\nsha256=%s\nsize=%d\n", model, hardwareRevision, version, digestText, len(content))
return signedFirmware{
PublicKeyBase64: base64.StdEncoding.EncodeToString(publicKey),
SignatureBase64: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, []byte(manifest))),
SHA256: digestText, Size: len(content), Manifest: manifest,
}, nil
}
+41
View File
@@ -0,0 +1,41 @@
package main
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"strings"
"testing"
)
func TestSignFirmwareProducesVerifiableManifest(t *testing.T) {
content := []byte("deterministic firmware fixture")
result, err := signFirmware("terminal-a", "rev-b", "1.2.3", content)
if err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(content)
if result.SHA256 != hex.EncodeToString(digest[:]) || result.Size != len(content) {
t.Fatalf("digest=%s size=%d", result.SHA256, result.Size)
}
for _, fragment := range []string{"model=terminal-a", "hardware_revision=rev-b", "version=1.2.3", "sha256=" + result.SHA256} {
if !strings.Contains(result.Manifest, fragment) {
t.Fatalf("manifest missing %q: %s", fragment, result.Manifest)
}
}
publicKey, err := base64.StdEncoding.DecodeString(result.PublicKeyBase64)
if err != nil {
t.Fatal(err)
}
signature, err := base64.StdEncoding.DecodeString(result.SignatureBase64)
if err != nil {
t.Fatal(err)
}
if !ed25519.Verify(ed25519.PublicKey(publicKey), []byte(result.Manifest), signature) {
t.Fatal("firmware signature did not verify")
}
if ed25519.Verify(ed25519.PublicKey(publicKey), []byte(result.Manifest+"tampered"), signature) {
t.Fatal("tampered manifest unexpectedly verified")
}
}
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"data-terminal-cloud/internal/infrastructureconformance"
)
func main() {
capturePath := flag.String("capture", "", "production-infrastructure-capture-v1 JSON")
candidateVersion := flag.String("candidate-version", "", "release candidate version")
buildDigest := flag.String("build-digest", "", "sha256 immutable build digest")
outputPath := flag.String("output", "", "absolute output report path")
flag.Parse()
if *capturePath == "" || strings.TrimSpace(*candidateVersion) == "" || strings.TrimSpace(*buildDigest) == "" || !filepath.IsAbs(*outputPath) {
fmt.Fprintln(os.Stderr, "usage: infrastructure-conformance -capture capture.json -candidate-version VERSION -build-digest sha256:... -output ABSOLUTE.json")
os.Exit(2)
}
content, err := os.ReadFile(*capturePath)
if err != nil {
fmt.Fprintln(os.Stderr, "read infrastructure capture:", err)
os.Exit(2)
}
capture, err := infrastructureconformance.ParseCapture(content)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
report := infrastructureconformance.Evaluate(capture, *candidateVersion, *buildDigest, time.Now().UTC())
report.InputSHA256["infrastructure_capture"] = infrastructureconformance.SHA256(content)
encoded, _ := json.MarshalIndent(report, "", " ")
encoded = append(encoded, '\n')
if err = os.WriteFile(*outputPath, encoded, 0o600); err != nil {
fmt.Fprintln(os.Stderr, "write infrastructure report:", err)
os.Exit(2)
}
fmt.Printf("production infrastructure conformance %s: %s\n", report.Result, *outputPath)
if report.Result != "PASS" {
os.Exit(1)
}
}
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"data-terminal-cloud/internal/kmsrotationconformance"
)
func main() {
capturePath := flag.String("capture", "", "kms-rotation-capture-v1 JSON")
candidateVersion := flag.String("candidate-version", "", "release candidate version")
buildDigest := flag.String("build-digest", "", "sha256 immutable build digest")
outputPath := flag.String("output", "", "absolute output report path")
flag.Parse()
if *capturePath == "" || strings.TrimSpace(*candidateVersion) == "" || strings.TrimSpace(*buildDigest) == "" || !filepath.IsAbs(*outputPath) {
fmt.Fprintln(os.Stderr, "usage: kms-rotation-conformance -capture capture.json -candidate-version VERSION -build-digest sha256:... -output ABSOLUTE.json")
os.Exit(2)
}
content, err := os.ReadFile(*capturePath)
if err != nil {
fmt.Fprintln(os.Stderr, "read KMS rotation capture:", err)
os.Exit(2)
}
capture, err := kmsrotationconformance.ParseCapture(content)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
report := kmsrotationconformance.Evaluate(capture, *candidateVersion, *buildDigest, time.Now().UTC())
report.InputSHA256["rotation_capture"] = kmsrotationconformance.SHA256(content)
encoded, _ := json.MarshalIndent(report, "", " ")
encoded = append(encoded, '\n')
if err = os.WriteFile(*outputPath, encoded, 0o600); err != nil {
fmt.Fprintln(os.Stderr, "write KMS rotation report:", err)
os.Exit(2)
}
fmt.Printf("KMS rotation conformance %s: %s\n", report.Result, *outputPath)
if report.Result != "PASS" {
os.Exit(1)
}
}
+172
View File
@@ -0,0 +1,172 @@
package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"io"
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
type connection struct {
body io.ReadCloser
cursor int64
}
func main() {
url := flag.String("url", "http://localhost:58080/api/v1/events", "SSE endpoint")
count := flag.Int("connections", 500, "simultaneous connections")
rate := flag.Int("open-rate", 40, "new connections per second")
hold := flag.Duration("hold", 5*time.Second, "hold after all connections are ready")
reconnects := flag.Int("reconnects", 50, "connections to reopen with Last-Event-ID")
flag.Parse()
token := strings.TrimSpace(os.Getenv("ACCESS_TOKEN"))
if token == "" || *count < 1 || *count > 5000 || *rate < 1 || *rate > 1000 || *reconnects < 0 || *reconnects > *count {
fatal("ACCESS_TOKEN and safe positive load parameters are required")
}
transport := &http.Transport{
DialContext: (&net.Dialer{Timeout: 3 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
MaxIdleConns: *count + *reconnects,
MaxIdleConnsPerHost: *count + *reconnects,
IdleConnTimeout: 30 * time.Second,
}
client := &http.Client{Transport: transport}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*count/(*rate)+20)*time.Second)
defer cancel()
connections, err := openBatch(ctx, client, *url, token, *count, *rate, nil)
if err != nil {
fatal(err.Error())
}
if err = waitHealthy(connections, *hold); err != nil {
closeAll(connections)
fatal(err.Error())
}
cursors := make([]int64, 0, *reconnects)
for i := 0; i < *reconnects; i++ {
if connections[i].cursor < 1 {
closeAll(connections)
fatal("SSE fixture did not provide a positive resumable cursor")
}
cursors = append(cursors, connections[i].cursor)
}
closeAll(connections)
reconnectCtx, reconnectCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer reconnectCancel()
reopened, err := openBatch(reconnectCtx, client, *url, token, *reconnects, *rate, cursors)
if err != nil {
fatal("Last-Event-ID reconnect failed: " + err.Error())
}
closeAll(reopened)
fmt.Printf("SSE connection load passed: simultaneous=%d, open_rate=%d/s, held=%s, Last-Event-ID_reconnects=%d\n", *count, *rate, hold.String(), *reconnects)
}
func openBatch(ctx context.Context, client *http.Client, url, token string, count, rate int, cursors []int64) ([]connection, error) {
results := make([]connection, count)
errorsByIndex := make([]error, count)
var wg sync.WaitGroup
interval := time.Second / time.Duration(rate)
started := time.Now()
for i := 0; i < count; i++ {
target := started.Add(time.Duration(i) * interval)
if wait := time.Until(target); wait > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
}
wg.Add(1)
go func(index int) {
defer wg.Done()
cursor := int64(0)
if cursors != nil {
cursor = cursors[index]
}
results[index], errorsByIndex[index] = openOne(ctx, client, url, token, cursor)
}(i)
}
wg.Wait()
for i, err := range errorsByIndex {
if err != nil {
closeAll(results)
return nil, fmt.Errorf("connection %d: %w", i, err)
}
}
return results, nil
}
func openOne(ctx context.Context, client *http.Client, url, token string, cursor int64) (connection, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return connection{}, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "text/event-stream")
if cursor > 0 {
req.Header.Set("Last-Event-ID", strconv.FormatInt(cursor, 10))
}
response, err := client.Do(req)
if err != nil {
return connection{}, err
}
if response.StatusCode != http.StatusOK || !strings.HasPrefix(response.Header.Get("Content-Type"), "text/event-stream") {
body, _ := io.ReadAll(io.LimitReader(response.Body, 512))
response.Body.Close()
return connection{}, fmt.Errorf("unexpected response %d: %s", response.StatusCode, strings.TrimSpace(string(body)))
}
reader := bufio.NewReader(response.Body)
readCursor := int64(0)
for {
line, readErr := reader.ReadString('\n')
if readErr != nil {
response.Body.Close()
return connection{}, readErr
}
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "id:") {
readCursor, _ = strconv.ParseInt(strings.TrimSpace(strings.TrimPrefix(line, "id:")), 10, 64)
}
if line == "" {
break
}
}
return connection{body: response.Body, cursor: readCursor}, nil
}
func waitHealthy(items []connection, hold time.Duration) error {
if len(items) == 0 {
return errors.New("no SSE connections were opened")
}
timer := time.NewTimer(hold)
defer timer.Stop()
<-timer.C
for i, item := range items {
if item.body == nil || item.cursor < 1 {
return fmt.Errorf("connection %d is not healthy", i)
}
}
return nil
}
func closeAll(items []connection) {
for _, item := range items {
if item.body != nil {
_ = item.body.Close()
}
}
}
func fatal(message string) {
fmt.Fprintln(os.Stderr, message)
os.Exit(1)
}
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestOpenOneAndBatchCarryAuthenticationAndCursor(t *testing.T) {
var requests atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requests.Add(1)
if request.Header.Get("Authorization") != "Bearer token-1" || request.Header.Get("Accept") != "text/event-stream" {
t.Errorf("headers=%v", request.Header)
}
if request.URL.Query().Get("resume") == "yes" && request.Header.Get("Last-Event-ID") != "41" {
t.Errorf("Last-Event-ID=%q", request.Header.Get("Last-Event-ID"))
}
writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
_, _ = io.WriteString(writer, "id: 42\nevent: heartbeat\ndata: {}\n\n")
}))
defer server.Close()
item, err := openOne(context.Background(), server.Client(), server.URL+"?resume=yes", "token-1", 41)
if err != nil || item.cursor != 42 || item.body == nil {
t.Fatalf("item=%+v err=%v", item, err)
}
closeAll([]connection{item})
items, err := openBatch(context.Background(), server.Client(), server.URL, "token-1", 2, 1000, nil)
if err != nil || len(items) != 2 || requests.Load() != 3 {
t.Fatalf("items=%d requests=%d err=%v", len(items), requests.Load(), err)
}
if err := waitHealthy(items, time.Millisecond); err != nil {
t.Fatal(err)
}
closeAll(items)
}
func TestSSEValidationFailures(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(writer, "denied")
}))
defer server.Close()
if _, err := openOne(context.Background(), server.Client(), server.URL, "bad", 0); err == nil || !strings.Contains(err.Error(), "unexpected response 401") {
t.Fatalf("unexpected response error=%v", err)
}
if err := waitHealthy(nil, 0); err == nil {
t.Fatal("empty connection set accepted")
}
if err := waitHealthy([]connection{{}}, 0); err == nil || !strings.Contains(err.Error(), "not healthy") {
t.Fatalf("unhealthy error=%v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := openBatch(ctx, server.Client(), server.URL, "token", 2, 1, nil); err == nil {
t.Fatal("cancelled batch unexpectedly succeeded")
}
}
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"data-terminal-cloud/internal/mockalertreceiver"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)).With("service", "mock-alert-receiver")
receiver, err := mockalertreceiver.New(mockalertreceiver.Options{
Secret: env("ALERT_WEBHOOK_SECRET", "local-alert-webhook-secret-32-bytes-minimum"), ClockSkew: 5 * time.Minute,
}, logger)
if err != nil {
logger.Error("configure mock alert receiver", "error", err)
os.Exit(1)
}
server := &http.Server{Addr: env("HTTP_ADDR", "0.0.0.0:8084"), Handler: receiver.Handler(), ReadHeaderTimeout: 5 * time.Second}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
<-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = server.Shutdown(shutdown)
}()
logger.Info("mock alert receiver started", "address", server.Addr)
if err = server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("mock alert receiver stopped unexpectedly", "error", err)
os.Exit(1)
}
}
func env(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"data-terminal-cloud/internal/mockprovider"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)).With("service", "mock-provider")
if err := run(ctx, logger); err != nil {
logger.Error("mock provider stopped", "error", err)
os.Exit(1)
}
}
func run(ctx context.Context, logger *slog.Logger) error {
duplicates, err := strconv.Atoi(env("MOCK_PROVIDER_DUPLICATE_CALLBACKS", "2"))
if err != nil {
return fmt.Errorf("invalid duplicate callback count: %w", err)
}
delay, err := time.ParseDuration(env("MOCK_PROVIDER_START_DELAY", "100ms"))
if err != nil {
return fmt.Errorf("invalid start delay: %w", err)
}
provider, err := mockprovider.New(mockprovider.Options{
APIKey: env("MOCK_PROVIDER_API_KEY", "local-mock-provider-key"),
CallbackURL: env("MOCK_PROVIDER_CALLBACK_URL", "http://localhost:58081/callbacks/v1/vertc/voice-chat"),
CallbackSecret: env("PROVIDER_CALLBACK_SECRET", "local-provider-callback-secret-32-bytes-minimum"),
StartDelay: delay, DuplicateCallbacks: duplicates,
}, logger)
if err != nil {
return fmt.Errorf("create mock provider: %w", err)
}
server := &http.Server{
Addr: env("HTTP_ADDR", "0.0.0.0:59200"), Handler: provider.Handler(),
ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second,
}
serverError := make(chan error, 1)
go func() {
logger.Info("mock provider listening", "address", server.Addr)
serverError <- server.ListenAndServe()
}()
select {
case <-ctx.Done():
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownContext); err != nil {
return fmt.Errorf("shutdown mock provider: %w", err)
}
case err := <-serverError:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
}
return nil
}
func env(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"context"
"io"
"log/slog"
"strings"
"testing"
"time"
)
func TestRunValidatesConfiguration(t *testing.T) {
logger := slog.New(slog.NewJSONHandler(io.Discard, nil))
t.Setenv("MOCK_PROVIDER_DUPLICATE_CALLBACKS", "invalid")
if err := run(context.Background(), logger); err == nil || !strings.Contains(err.Error(), "duplicate callback") {
t.Fatalf("duplicate validation error=%v", err)
}
t.Setenv("MOCK_PROVIDER_DUPLICATE_CALLBACKS", "2")
t.Setenv("MOCK_PROVIDER_START_DELAY", "invalid")
if err := run(context.Background(), logger); err == nil || !strings.Contains(err.Error(), "start delay") {
t.Fatalf("delay validation error=%v", err)
}
}
func TestRunStartsAndShutsDown(t *testing.T) {
logger := slog.New(slog.NewJSONHandler(io.Discard, nil))
t.Setenv("HTTP_ADDR", "127.0.0.1:0")
t.Setenv("MOCK_PROVIDER_START_DELAY", "1ms")
t.Setenv("MOCK_PROVIDER_DUPLICATE_CALLBACKS", "1")
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if err := run(ctx, logger); err != nil {
t.Fatalf("provider lifecycle: %v", err)
}
}
func TestEnvUsesValueAndFallback(t *testing.T) {
t.Setenv("MOCK_PROVIDER_TEST_VALUE", "configured")
if got := env("MOCK_PROVIDER_TEST_VALUE", "fallback"); got != "configured" {
t.Fatalf("value=%q", got)
}
if got := env("MOCK_PROVIDER_MISSING_VALUE", "fallback"); got != "fallback" {
t.Fatalf("fallback=%q", got)
}
}
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"data-terminal-cloud/internal/recoveryconformance"
)
func main() {
capturePath := flag.String("capture", "", "production-recovery-capture-v1 JSON")
candidateVersion := flag.String("candidate-version", "", "release candidate version")
buildDigest := flag.String("build-digest", "", "sha256 immutable build digest")
outputPath := flag.String("output", "", "absolute output report path")
flag.Parse()
if *capturePath == "" || strings.TrimSpace(*candidateVersion) == "" || strings.TrimSpace(*buildDigest) == "" || !filepath.IsAbs(*outputPath) {
fmt.Fprintln(os.Stderr, "usage: recovery-conformance -capture capture.json -candidate-version VERSION -build-digest sha256:... -output ABSOLUTE.json")
os.Exit(2)
}
content, err := os.ReadFile(*capturePath)
if err != nil {
fmt.Fprintln(os.Stderr, "read recovery capture:", err)
os.Exit(2)
}
capture, err := recoveryconformance.ParseCapture(content)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
report := recoveryconformance.Evaluate(capture, *candidateVersion, *buildDigest, time.Now().UTC())
report.InputSHA256["recovery_capture"] = recoveryconformance.SHA256(content)
encoded, _ := json.MarshalIndent(report, "", " ")
encoded = append(encoded, '\n')
if err = os.WriteFile(*outputPath, encoded, 0o600); err != nil {
fmt.Fprintln(os.Stderr, "write recovery report:", err)
os.Exit(2)
}
fmt.Printf("production recovery conformance %s: %s\n", report.Result, *outputPath)
if report.Result != "PASS" {
os.Exit(1)
}
}
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"data-terminal-cloud/internal/releaseevidence"
)
type repeatedStringFlag []string
func (value *repeatedStringFlag) String() string { return fmt.Sprint([]string(*value)) }
func (value *repeatedStringFlag) Set(input string) error {
*value = append(*value, input)
return nil
}
func main() {
root := flag.String("root", ".", "project root")
candidateVersion := flag.String("candidate-version", "", "candidate version")
buildDigest := flag.String("build-digest", "", "sha256 image or immutable build digest")
sourceCommit := flag.String("source-commit", "", "source commit; LAB may use an explicit local label")
acceptanceSnapshot := flag.String("acceptance-snapshot", "", "technical acceptance snapshot JSON")
evidenceIndex := flag.String("evidence-index", "", "external evidence index JSON")
var pilotDailySnapshots repeatedStringFlag
flag.Var(&pilotDailySnapshots, "pilot-daily-snapshot", "PILOT daily snapshot JSON; repeat exactly seven times for a PILOT bundle")
pilotWeeklySnapshot := flag.String("pilot-weekly-snapshot", "", "completed PILOT weekly snapshot JSON")
output := flag.String("out", "", "output ZIP path")
flag.Parse()
result, err := releaseevidence.Build(releaseevidence.Options{
Root: *root, CandidateVersion: *candidateVersion, BuildDigest: *buildDigest, SourceCommit: *sourceCommit,
AcceptanceSnapshotPath: *acceptanceSnapshot, EvidenceIndexPath: *evidenceIndex,
PilotDailySnapshotPaths: []string(pilotDailySnapshots), PilotWeeklySnapshotPath: *pilotWeeklySnapshot, OutputPath: *output,
})
if err != nil {
fmt.Fprintln(os.Stderr, "release evidence bundle failed:", err)
os.Exit(1)
}
encoded, _ := json.Marshal(result)
fmt.Println(string(encoded))
}
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"data-terminal-cloud/internal/rtcconformance"
)
func main() {
capturePath := flag.String("capture", "", "real-device-rtc-capture-v1 JSON")
candidateVersion := flag.String("candidate-version", "", "release candidate version")
buildDigest := flag.String("build-digest", "", "sha256 immutable build digest")
outputPath := flag.String("output", "", "absolute output report path")
flag.Parse()
if *capturePath == "" || strings.TrimSpace(*candidateVersion) == "" || strings.TrimSpace(*buildDigest) == "" || !filepath.IsAbs(*outputPath) {
fmt.Fprintln(os.Stderr, "usage: rtc-conformance -capture capture.json -candidate-version VERSION -build-digest sha256:... -output ABSOLUTE.json")
os.Exit(2)
}
content, err := os.ReadFile(*capturePath)
if err != nil {
fmt.Fprintln(os.Stderr, "read RTC capture:", err)
os.Exit(2)
}
capture, err := rtcconformance.ParseCapture(content)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
report := rtcconformance.Evaluate(capture, *candidateVersion, *buildDigest, time.Now().UTC())
report.InputSHA256["rtc_capture"] = rtcconformance.SHA256(content)
encoded, _ := json.MarshalIndent(report, "", " ")
encoded = append(encoded, '\n')
if err = os.WriteFile(*outputPath, encoded, 0o600); err != nil {
fmt.Fprintln(os.Stderr, "write RTC report:", err)
os.Exit(2)
}
fmt.Printf("real device RTC conformance %s: %s\n", report.Result, *outputPath)
if report.Result != "PASS" {
os.Exit(1)
}
}
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"data-terminal-cloud/internal/safariconformance"
)
func main() {
capturePath := flag.String("capture", "", "safari-compatibility-capture-v1 JSON")
candidateVersion := flag.String("candidate-version", "", "release candidate version")
buildDigest := flag.String("build-digest", "", "sha256 immutable build digest")
outputPath := flag.String("output", "", "absolute output report path")
flag.Parse()
if *capturePath == "" || strings.TrimSpace(*candidateVersion) == "" || strings.TrimSpace(*buildDigest) == "" || !filepath.IsAbs(*outputPath) {
fmt.Fprintln(os.Stderr, "usage: safari-conformance -capture capture.json -candidate-version VERSION -build-digest sha256:... -output ABSOLUTE.json")
os.Exit(2)
}
content, err := os.ReadFile(*capturePath)
if err != nil {
fmt.Fprintln(os.Stderr, "read Safari capture:", err)
os.Exit(2)
}
capture, err := safariconformance.ParseCapture(content)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
report := safariconformance.Evaluate(capture, *candidateVersion, *buildDigest, time.Now().UTC())
report.InputSHA256["safari_capture"] = safariconformance.SHA256(content)
encoded, _ := json.MarshalIndent(report, "", " ")
encoded = append(encoded, '\n')
if err = os.WriteFile(*outputPath, encoded, 0o600); err != nil {
fmt.Fprintln(os.Stderr, "write Safari report:", err)
os.Exit(2)
}
fmt.Printf("Safari compatibility conformance %s: %s\n", report.Result, *outputPath)
if report.Result != "PASS" {
os.Exit(1)
}
}
+255
View File
@@ -0,0 +1,255 @@
package main
import (
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const maximumSimulatedFirmwareSize = 268435456
type simulatedDeviceCommand struct {
ID string `json:"id"`
Type string `json:"type"`
Payload map[string]any `json:"payload"`
}
type simulatedFirmwareManifest struct {
ReleaseID string
Version string
Digest string
Signature []byte
SigningKeyID string
DownloadPath string
}
type firmwareExecutionOutcome string
const (
firmwareCompleted firmwareExecutionOutcome = "completed"
firmwareFailed firmwareExecutionOutcome = "failed"
firmwareInterrupted firmwareExecutionOutcome = "interrupted"
firmwareRebootRequired firmwareExecutionOutcome = "reboot_required"
)
func (s *simulator) executeFirmwareCommand(ctx context.Context, command simulatedDeviceCommand) (firmwareExecutionOutcome, error) {
manifest, err := parseSimulatedFirmwareManifest(command.Payload)
if err != nil {
if ackErr := s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"result_code": "MANIFEST_INVALID", "error_message": err.Error()}); ackErr != nil {
return firmwareFailed, ackErr
}
return firmwareFailed, nil
}
fault, err := simulatedFirmwareFault()
if err != nil {
return firmwareFailed, err
}
delay, err := simulatorOptionalDuration("SIM_FIRMWARE_STEP_DELAY", 30*time.Second)
if err != nil {
return firmwareFailed, err
}
abEnabled, err := simulatedABFirmwareEnabled()
if err != nil {
return firmwareFailed, err
}
if !abEnabled && isABFirmwareFault(fault) {
return firmwareFailed, errors.New("A/B firmware fault injection requires SIM_FIRMWARE_AB_UPDATE=true")
}
if abEnabled {
if handled, outcome, resumeErr := s.resumeABFirmwareCommand(ctx, command, manifest, fault, delay); handled || resumeErr != nil {
return outcome, resumeErr
}
}
if err := s.ackDeviceCommand(ctx, command.ID, "ACKNOWLEDGED", map[string]any{"phase": "downloading", "progress_percent": 5, "result_code": "DOWNLOAD_STARTED"}); err != nil {
return firmwareFailed, err
}
if fault == "download_failed" {
return firmwareFailed, s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"phase": "downloading", "progress_percent": 5, "result_code": "DOWNLOAD_FAILED"})
}
content, err := s.downloadFirmware(ctx, manifest.DownloadPath)
if err != nil {
if ackErr := s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"phase": "downloading", "progress_percent": 5, "result_code": "DOWNLOAD_FAILED", "error_message": err.Error()}); ackErr != nil {
return firmwareFailed, ackErr
}
return firmwareFailed, nil
}
digestValue := sha256.Sum256(content)
actualDigest := hex.EncodeToString(digestValue[:])
if fault == "digest_mismatch" || actualDigest != manifest.Digest {
return firmwareFailed, s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"phase": "downloading", "progress_percent": 100, "result_code": "DIGEST_MISMATCH", "expected_sha256": manifest.Digest, "actual_sha256": actualDigest, "bytes_downloaded": len(content)})
}
publicKey, err := simulatedFirmwareTrustedKey(manifest.SigningKeyID)
if err != nil {
return firmwareFailed, s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"phase": "downloading", "progress_percent": 100, "result_code": "SIGNING_KEY_UNTRUSTED", "signing_key_id": manifest.SigningKeyID, "error_message": err.Error()})
}
signedManifest := fmt.Sprintf("firmware-manifest-v1\nmodel=%s\nhardware_revision=%s\nversion=%s\nsha256=%s\nsize=%d\n", env("SIM_DEVICE_MODEL", "simulated-terminal"), env("SIM_DEVICE_HARDWARE_REVISION", "sim-rev-a"), manifest.Version, manifest.Digest, len(content))
if fault == "signature_mismatch" || !ed25519.Verify(publicKey, []byte(signedManifest), manifest.Signature) {
return firmwareFailed, s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"phase": "downloading", "progress_percent": 100, "result_code": "SIGNATURE_MISMATCH", "signing_key_id": manifest.SigningKeyID, "verified_sha256": actualDigest, "bytes_downloaded": len(content)})
}
if err := s.ackDeviceCommand(ctx, command.ID, "ACKNOWLEDGED", map[string]any{"phase": "downloading", "progress_percent": 100, "result_code": "ARTIFACT_VERIFIED", "verified_sha256": actualDigest, "verified_signing_key_id": manifest.SigningKeyID, "bytes_downloaded": len(content)}); err != nil {
return firmwareFailed, err
}
if fault == "interrupt_after_download" {
return firmwareInterrupted, nil
}
if err := waitForContext(ctx, delay); err != nil {
return firmwareInterrupted, err
}
if err := s.ackDeviceCommand(ctx, command.ID, "ACKNOWLEDGED", map[string]any{"phase": "installing", "progress_percent": 80, "result_code": "INSTALL_STARTED"}); err != nil {
return firmwareFailed, err
}
if fault == "install_failed" {
return firmwareFailed, s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"phase": "installing", "progress_percent": 80, "result_code": "INSTALL_FAILED"})
}
if fault == "interrupt_after_install" {
return firmwareInterrupted, nil
}
if err := waitForContext(ctx, delay); err != nil {
return firmwareInterrupted, err
}
if abEnabled {
return s.stageABFirmwareCommand(ctx, command, manifest, fault)
}
if err := s.ackDeviceCommand(ctx, command.ID, "SUCCEEDED", map[string]any{"phase": "confirmed", "progress_percent": 100, "result_code": "SIMULATED_OK", "installed_version": manifest.Version, "verified_sha256": actualDigest, "verified_signing_key_id": manifest.SigningKeyID}); err != nil {
return firmwareFailed, err
}
return firmwareCompleted, nil
}
func parseSimulatedFirmwareManifest(payload map[string]any) (simulatedFirmwareManifest, error) {
digest := stringValue(payload, "sha256")
signature, signatureErr := base64.StdEncoding.DecodeString(stringValue(payload, "signature"))
manifest := simulatedFirmwareManifest{
ReleaseID: stringValue(payload, "release_id"),
Version: stringValue(payload, "version"),
Digest: digest,
Signature: signature,
SigningKeyID: stringValue(payload, "signing_key_id"),
DownloadPath: stringValue(payload, "download_path"),
}
if manifest.ReleaseID == "" || manifest.Version == "" || manifest.SigningKeyID == "" || len(manifest.Digest) != sha256.Size*2 || signatureErr != nil || len(manifest.Signature) != ed25519.SignatureSize {
return simulatedFirmwareManifest{}, errors.New("firmware command requires release_id, version, signing_key_id, a 64-character SHA-256 and a 64-byte Ed25519 signature")
}
if _, err := hex.DecodeString(manifest.Digest); err != nil {
return simulatedFirmwareManifest{}, errors.New("firmware command SHA-256 is not lowercase hexadecimal")
}
if manifest.Digest != strings.ToLower(manifest.Digest) {
return simulatedFirmwareManifest{}, errors.New("firmware command SHA-256 is not lowercase hexadecimal")
}
parsedPath, err := url.Parse(manifest.DownloadPath)
if err != nil || parsedPath.IsAbs() || parsedPath.Host != "" || parsedPath.RawQuery != "" || parsedPath.Fragment != "" || strings.Contains(parsedPath.Path, "..") || !strings.HasPrefix(parsedPath.Path, "/device/v2/firmware-releases/") || !strings.HasSuffix(parsedPath.Path, "/download") {
return simulatedFirmwareManifest{}, errors.New("firmware command download_path is outside the fixed device firmware boundary")
}
return manifest, nil
}
func stringValue(value map[string]any, key string) string {
item, _ := value[key].(string)
return strings.TrimSpace(item)
}
func simulatedFirmwareFault() (string, error) {
value := strings.ToLower(strings.TrimSpace(os.Getenv("SIM_FIRMWARE_FAULT")))
if value == "" || value == "none" {
return "none", nil
}
switch value {
case "download_failed", "digest_mismatch", "signature_mismatch", "install_failed", "interrupt_after_download", "interrupt_after_install", "power_loss_after_stage", "power_loss_during_boot", "boot_health_failed":
return value, nil
default:
return "", fmt.Errorf("SIM_FIRMWARE_FAULT must be none, download_failed, digest_mismatch, signature_mismatch, install_failed, interrupt_after_download, interrupt_after_install, power_loss_after_stage, power_loss_during_boot or boot_health_failed")
}
}
func isABFirmwareFault(value string) bool {
return value == "power_loss_after_stage" || value == "power_loss_during_boot" || value == "boot_health_failed"
}
func simulatedFirmwareTrustedKey(keyID string) (ed25519.PublicKey, error) {
raw := strings.TrimSpace(os.Getenv("SIM_FIRMWARE_TRUSTED_KEYS"))
if raw == "" {
return nil, errors.New("SIM_FIRMWARE_TRUSTED_KEYS is empty")
}
var encoded map[string]string
if err := json.Unmarshal([]byte(raw), &encoded); err != nil {
return nil, errors.New("SIM_FIRMWARE_TRUSTED_KEYS must be a JSON object of key IDs to Base64 Ed25519 public keys")
}
value, exists := encoded[keyID]
if !exists {
return nil, fmt.Errorf("signing key %s is not provisioned", keyID)
}
publicKey, err := base64.StdEncoding.DecodeString(value)
if err != nil || len(publicKey) != ed25519.PublicKeySize {
return nil, fmt.Errorf("signing key %s is not a 32-byte Base64 Ed25519 public key", keyID)
}
return ed25519.PublicKey(publicKey), nil
}
func simulatorOptionalDuration(key string, maximum time.Duration) (time.Duration, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return 0, nil
}
parsed, err := time.ParseDuration(value)
if err != nil || parsed < 0 || parsed > maximum {
return 0, fmt.Errorf("%s must be a non-negative duration no greater than %s", key, maximum)
}
return parsed, nil
}
func (s *simulator) ackDeviceCommand(ctx context.Context, commandID, status string, result map[string]any) error {
body, err := json.Marshal(map[string]any{"status": status, "result_summary": result})
if err != nil {
return err
}
_, err = s.request(ctx, http.MethodPost, "/device/v2/commands/"+commandID+"/ack", body, "")
return err
}
func (s *simulator) downloadFirmware(ctx context.Context, path string) ([]byte, error) {
result, err := s.request(ctx, http.MethodGet, path, nil, "")
if err != nil {
return nil, err
}
var address struct {
DownloadURL string `json:"download_url"`
}
if err := decodeData(result.Body, &address); err != nil || address.DownloadURL == "" {
return nil, errors.New("firmware download response did not contain a private URL")
}
parsed, err := url.Parse(address.DownloadURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return nil, errors.New("firmware private download URL is invalid")
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, address.DownloadURL, nil)
if err != nil {
return nil, err
}
response, err := s.client.Do(request)
if err != nil {
return nil, fmt.Errorf("download firmware object: %w", err)
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("firmware object returned HTTP %d", response.StatusCode)
}
content, err := io.ReadAll(io.LimitReader(response.Body, maximumSimulatedFirmwareSize+1))
if err != nil {
return nil, fmt.Errorf("read firmware object: %w", err)
}
if len(content) == 0 || len(content) > maximumSimulatedFirmwareSize {
return nil, fmt.Errorf("firmware object size %d is outside 1 to %d bytes", len(content), maximumSimulatedFirmwareSize)
}
return content, nil
}
+298
View File
@@ -0,0 +1,298 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
const (
firmwareBootStaged = "STAGED"
firmwareBootRebootPending = "REBOOT_PENDING"
firmwareBootConfirmed = "CONFIRMED"
firmwareBootRolledBack = "ROLLED_BACK"
)
type simulatedFirmwareBootState struct {
Phase string `json:"phase"`
CommandID string `json:"command_id"`
ReleaseID string `json:"release_id"`
TargetVersion string `json:"target_version"`
PreviousVersion string `json:"previous_version"`
Digest string `json:"sha256"`
ActiveSlot string `json:"active_slot"`
PreviousSlot string `json:"previous_slot"`
TargetSlot string `json:"target_slot"`
FailedSlot string `json:"failed_slot,omitempty"`
BootAttemptID string `json:"boot_attempt_id,omitempty"`
TerminalResultCode string `json:"terminal_result_code,omitempty"`
HealthDeadline time.Time `json:"health_deadline,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
func simulatedABFirmwareEnabled() (bool, error) {
value := strings.ToLower(strings.TrimSpace(os.Getenv("SIM_FIRMWARE_AB_UPDATE")))
if value == "" || value == "false" {
return false, nil
}
if value != "true" {
return false, errors.New("SIM_FIRMWARE_AB_UPDATE must be true or false")
}
statePath := strings.TrimSpace(os.Getenv("SIM_FIRMWARE_STATE_FILE"))
if statePath == "" || !filepath.IsAbs(statePath) {
return false, errors.New("SIM_FIRMWARE_STATE_FILE must be an absolute path when A/B firmware simulation is enabled")
}
evidencePath := strings.TrimSpace(os.Getenv("SIM_FIRMWARE_EVIDENCE_FILE"))
if evidencePath != "" && !filepath.IsAbs(evidencePath) {
return false, errors.New("SIM_FIRMWARE_EVIDENCE_FILE must be an absolute path")
}
return true, nil
}
func simulatedFirmwareHealthWindow() (time.Duration, error) {
value := strings.TrimSpace(os.Getenv("SIM_FIRMWARE_BOOT_HEALTH_WINDOW"))
if value == "" {
return 2 * time.Minute, nil
}
duration, err := time.ParseDuration(value)
if err != nil || duration < time.Second || duration > 30*time.Minute {
return 0, errors.New("SIM_FIRMWARE_BOOT_HEALTH_WINDOW must be between 1s and 30m")
}
return duration, nil
}
func simulatedFirmwareInitialSlot() (string, error) {
value := strings.ToUpper(env("SIM_FIRMWARE_ACTIVE_SLOT", "A"))
if value != "A" && value != "B" {
return "", errors.New("SIM_FIRMWARE_ACTIVE_SLOT must be A or B")
}
return value, nil
}
func inactiveFirmwareSlot(active string) string {
if active == "A" {
return "B"
}
return "A"
}
func loadSimulatedFirmwareBootState() (*simulatedFirmwareBootState, error) {
path := strings.TrimSpace(os.Getenv("SIM_FIRMWARE_STATE_FILE"))
content, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read simulated firmware state: %w", err)
}
var state simulatedFirmwareBootState
decoder := json.NewDecoder(strings.NewReader(string(content)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&state); err != nil {
return nil, fmt.Errorf("decode simulated firmware state: %w", err)
}
if state.ActiveSlot != "A" && state.ActiveSlot != "B" {
return nil, errors.New("simulated firmware state has an invalid active slot")
}
switch state.Phase {
case firmwareBootStaged, firmwareBootRebootPending, firmwareBootConfirmed, firmwareBootRolledBack:
default:
return nil, errors.New("simulated firmware state has an invalid phase")
}
return &state, nil
}
func saveSimulatedFirmwareBootState(state simulatedFirmwareBootState) error {
path := strings.TrimSpace(os.Getenv("SIM_FIRMWARE_STATE_FILE"))
if !filepath.IsAbs(path) {
return errors.New("SIM_FIRMWARE_STATE_FILE must be an absolute path")
}
state.UpdatedAt = time.Now().UTC()
content, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return fmt.Errorf("open simulated firmware state: %w", err)
}
if _, err = file.Write(content); err == nil {
err = file.Sync()
}
closeErr := file.Close()
if err != nil {
return fmt.Errorf("write simulated firmware state: %w", err)
}
if closeErr != nil {
return fmt.Errorf("close simulated firmware state: %w", closeErr)
}
return nil
}
func (s *simulator) resumeABFirmwareCommand(ctx context.Context, command simulatedDeviceCommand, manifest simulatedFirmwareManifest, fault string, delay time.Duration) (bool, firmwareExecutionOutcome, error) {
state, err := loadSimulatedFirmwareBootState()
if err != nil || state == nil {
return false, firmwareFailed, err
}
if state.Phase == firmwareBootConfirmed || state.Phase == firmwareBootRolledBack {
if state.CommandID != command.ID {
return false, firmwareCompleted, nil
}
if state.Phase == firmwareBootConfirmed {
err := s.ackDeviceCommand(ctx, command.ID, "SUCCEEDED", map[string]any{
"phase": "confirmed", "progress_percent": 100, "result_code": "SIMULATED_AB_BOOT_CONFIRMED", "installed_version": manifest.Version,
"verified_sha256": manifest.Digest, "verified_signing_key_id": manifest.SigningKeyID, "active_slot": state.ActiveSlot, "previous_slot": state.PreviousSlot, "boot_attempt_id": state.BootAttemptID,
})
return true, firmwareCompleted, err
}
resultCode := state.TerminalResultCode
if resultCode == "" {
resultCode = "BOOT_HEALTH_FAILED_ROLLED_BACK"
}
err := s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{
"phase": "rolled_back", "progress_percent": 95, "result_code": resultCode, "rollback_applied": true,
"active_slot": state.ActiveSlot, "failed_slot": state.FailedSlot, "rollback_version": state.TargetVersion, "boot_attempt_id": state.BootAttemptID,
})
return true, firmwareFailed, err
}
if state.CommandID != command.ID || state.ReleaseID != manifest.ReleaseID || state.TargetVersion != manifest.Version || state.Digest != manifest.Digest {
return true, firmwareFailed, s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"phase": "rolled_back", "progress_percent": 0, "result_code": "LOCAL_UPDATE_STATE_CONFLICT", "rollback_applied": false})
}
if state.Phase == firmwareBootStaged {
outcome, err := s.armSimulatedFirmwareBoot(ctx, command.ID, *state)
return true, outcome, err
}
bootState := *state
bootState.ActiveSlot = state.TargetSlot
if err := recordSimulatedFirmwareEvidence(command, manifest, "boot_verifying", bootState, "BOOT_HEALTH_CHECK_STARTED"); err != nil {
return true, firmwareFailed, err
}
if err := s.ackDeviceCommand(ctx, command.ID, "ACKNOWLEDGED", map[string]any{
"phase": "boot_verifying", "progress_percent": 95, "result_code": "BOOT_HEALTH_CHECK_STARTED",
"active_slot": state.TargetSlot, "previous_slot": state.PreviousSlot, "boot_attempt_id": state.BootAttemptID, "boot_health_deadline": state.HealthDeadline,
}); err != nil {
return true, firmwareFailed, err
}
if fault == "power_loss_during_boot" {
if err := recordSimulatedFirmwareEvidence(command, manifest, "power_loss_during_boot", *state, "SIMULATED_POWER_LOSS"); err != nil {
return true, firmwareFailed, err
}
return true, firmwareInterrupted, nil
}
rollbackCode := ""
if fault == "boot_health_failed" {
rollbackCode = "BOOT_HEALTH_FAILED_ROLLED_BACK"
} else if !state.HealthDeadline.IsZero() && time.Now().After(state.HealthDeadline) {
rollbackCode = "BOOT_HEALTH_TIMEOUT_ROLLED_BACK"
}
if rollbackCode != "" {
failedSlot := state.TargetSlot
state.Phase = firmwareBootRolledBack
state.ActiveSlot = state.PreviousSlot
state.FailedSlot = failedSlot
state.TerminalResultCode = rollbackCode
state.TargetSlot = ""
state.TargetVersion = state.PreviousVersion
if err := saveSimulatedFirmwareBootState(*state); err != nil {
return true, firmwareFailed, err
}
if err := recordSimulatedFirmwareEvidence(command, manifest, "rolled_back", *state, rollbackCode); err != nil {
return true, firmwareFailed, err
}
if err := s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{
"phase": "rolled_back", "progress_percent": 95, "result_code": rollbackCode, "rollback_applied": true,
"active_slot": state.ActiveSlot, "failed_slot": failedSlot, "rollback_version": state.PreviousVersion, "boot_attempt_id": state.BootAttemptID,
}); err != nil {
return true, firmwareFailed, err
}
return true, firmwareFailed, nil
}
if err := waitForContext(ctx, delay); err != nil {
return true, firmwareInterrupted, err
}
confirmedSlot := state.TargetSlot
state.Phase = firmwareBootConfirmed
state.ActiveSlot = confirmedSlot
state.TerminalResultCode = "SIMULATED_AB_BOOT_CONFIRMED"
state.TargetSlot = ""
state.PreviousVersion = state.TargetVersion
if err := saveSimulatedFirmwareBootState(*state); err != nil {
return true, firmwareFailed, err
}
if err := recordSimulatedFirmwareEvidence(command, manifest, "confirmed", *state, "SIMULATED_AB_BOOT_CONFIRMED"); err != nil {
return true, firmwareFailed, err
}
if err := s.ackDeviceCommand(ctx, command.ID, "SUCCEEDED", map[string]any{
"phase": "confirmed", "progress_percent": 100, "result_code": "SIMULATED_AB_BOOT_CONFIRMED", "installed_version": manifest.Version,
"verified_sha256": manifest.Digest, "verified_signing_key_id": manifest.SigningKeyID, "active_slot": confirmedSlot, "previous_slot": state.PreviousSlot, "boot_attempt_id": state.BootAttemptID,
}); err != nil {
return true, firmwareFailed, err
}
return true, firmwareCompleted, nil
}
func (s *simulator) stageABFirmwareCommand(ctx context.Context, command simulatedDeviceCommand, manifest simulatedFirmwareManifest, fault string) (firmwareExecutionOutcome, error) {
current, err := loadSimulatedFirmwareBootState()
if err != nil {
return firmwareFailed, err
}
activeSlot, err := simulatedFirmwareInitialSlot()
if err != nil {
return firmwareFailed, err
}
previousVersion := env("SIM_FIRMWARE_CURRENT_VERSION", "0.2.0-sim")
if current != nil && (current.Phase == firmwareBootConfirmed || current.Phase == firmwareBootRolledBack) {
activeSlot = current.ActiveSlot
previousVersion = current.TargetVersion
}
state := simulatedFirmwareBootState{
Phase: firmwareBootStaged, CommandID: command.ID, ReleaseID: manifest.ReleaseID, TargetVersion: manifest.Version,
PreviousVersion: previousVersion, Digest: manifest.Digest, ActiveSlot: activeSlot, PreviousSlot: activeSlot, TargetSlot: inactiveFirmwareSlot(activeSlot),
}
if err := saveSimulatedFirmwareBootState(state); err != nil {
return firmwareFailed, err
}
if err := recordSimulatedFirmwareEvidence(command, manifest, "staging", state, "INACTIVE_SLOT_STAGED"); err != nil {
return firmwareFailed, err
}
if err := s.ackDeviceCommand(ctx, command.ID, "ACKNOWLEDGED", map[string]any{
"phase": "staging", "progress_percent": 85, "result_code": "INACTIVE_SLOT_STAGED", "active_slot": state.ActiveSlot, "target_slot": state.TargetSlot,
}); err != nil {
return firmwareFailed, err
}
if fault == "power_loss_after_stage" {
if err := recordSimulatedFirmwareEvidence(command, manifest, "power_loss_after_stage", state, "SIMULATED_POWER_LOSS"); err != nil {
return firmwareFailed, err
}
return firmwareInterrupted, nil
}
return s.armSimulatedFirmwareBoot(ctx, command.ID, state)
}
func (s *simulator) armSimulatedFirmwareBoot(ctx context.Context, commandID string, state simulatedFirmwareBootState) (firmwareExecutionOutcome, error) {
window, err := simulatedFirmwareHealthWindow()
if err != nil {
return firmwareFailed, err
}
state.Phase = firmwareBootRebootPending
state.BootAttemptID = nonce()
state.HealthDeadline = time.Now().UTC().Add(window)
if err := saveSimulatedFirmwareBootState(state); err != nil {
return firmwareFailed, err
}
if err := recordSimulatedFirmwareEvidence(simulatedDeviceCommand{ID: commandID}, simulatedFirmwareManifest{ReleaseID: state.ReleaseID, Version: state.TargetVersion, Digest: state.Digest}, "reboot_pending", state, "REBOOT_ARMED"); err != nil {
return firmwareFailed, err
}
if err := s.ackDeviceCommand(ctx, commandID, "ACKNOWLEDGED", map[string]any{
"phase": "reboot_pending", "progress_percent": 90, "result_code": "REBOOT_ARMED", "active_slot": state.ActiveSlot,
"target_slot": state.TargetSlot, "boot_attempt_id": state.BootAttemptID, "boot_health_deadline": state.HealthDeadline,
}); err != nil {
return firmwareFailed, err
}
return firmwareRebootRequired, nil
}
+105
View File
@@ -0,0 +1,105 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"time"
"data-terminal-cloud/internal/firmwareconformance"
)
func recordSimulatedFirmwareEvidence(command simulatedDeviceCommand, manifest simulatedFirmwareManifest, phase string, state simulatedFirmwareBootState, resultCode string) error {
path := strings.TrimSpace(os.Getenv("SIM_FIRMWARE_EVIDENCE_FILE"))
if path == "" {
return nil
}
now := time.Now().UTC()
var evidence firmwareconformance.DeviceEvidence
content, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
partitionDigest := strings.ToLower(env("SIM_PARTITION_TABLE_SHA256", firmwareconformance.SHA256([]byte(env("SIM_DEVICE_MODEL", "simulated-terminal")+":"+env("SIM_DEVICE_HARDWARE_REVISION", "sim-rev-a")+":A/B"))))
secureBoot, parseErr := simulatedEvidenceBoolean("SIM_SECURE_BOOT_ENABLED", false)
if parseErr != nil {
return parseErr
}
evidence = firmwareconformance.DeviceEvidence{
SchemaVersion: firmwareconformance.DeviceEvidenceSchema, EvidenceSource: "SIMULATOR",
CandidateVersion: env("SIM_CANDIDATE_VERSION", "local-development"), BuildDigest: strings.ToLower(strings.TrimSpace(os.Getenv("SIM_BUILD_DIGEST"))),
DeviceID: env("SIM_DEVICE_ID", "10000000-0000-0000-0000-000000000511"), DeviceModel: env("SIM_DEVICE_MODEL", "simulated-terminal"), HardwareRevision: env("SIM_DEVICE_HARDWARE_REVISION", "sim-rev-a"),
ReleaseID: manifest.ReleaseID, CommandID: command.ID, Scenario: strings.ToUpper(strings.TrimSpace(os.Getenv("SIM_FIRMWARE_EVIDENCE_SCENARIO"))),
BootloaderVersion: env("SIM_BOOTLOADER_VERSION", "sim-bootloader-1"), PartitionTableSHA256: partitionDigest, SecureBootEnabled: secureBoot,
ArtifactSHA256: manifest.Digest, SigningKeyID: manifest.SigningKeyID,
InitialSlot: state.PreviousSlot, TargetSlot: state.TargetSlot, FinalSlot: state.PreviousSlot,
InitialVersion: state.PreviousVersion, TargetVersion: state.TargetVersion, FinalVersion: state.PreviousVersion,
BootAttemptID: state.BootAttemptID, CollectedAt: now, Events: []firmwareconformance.DeviceEvent{},
}
if evidence.BuildDigest == "" || evidence.Scenario == "" {
return errors.New("SIM_BUILD_DIGEST and SIM_FIRMWARE_EVIDENCE_SCENARIO are required when evidence capture is enabled")
}
} else if err != nil {
return fmt.Errorf("read simulated firmware evidence: %w", err)
} else {
evidence, err = firmwareconformance.ParseDeviceEvidence(content)
if err != nil {
return err
}
if evidence.CommandID != command.ID || evidence.ReleaseID != manifest.ReleaseID {
return errors.New("simulated firmware evidence belongs to a different command or release")
}
}
if manifest.SigningKeyID != "" && evidence.SigningKeyID == "" {
evidence.SigningKeyID = manifest.SigningKeyID
}
if state.BootAttemptID != "" {
evidence.BootAttemptID = state.BootAttemptID
}
event := firmwareconformance.DeviceEvent{
Phase: phase, OccurredAt: now, ActiveSlot: state.ActiveSlot, TargetSlot: state.TargetSlot, ResultCode: resultCode, BootAttemptID: state.BootAttemptID,
}
evidence.Events = append(evidence.Events, event)
evidence.CollectedAt = now
switch phase {
case "confirmed":
evidence.FinalSlot = state.ActiveSlot
evidence.FinalVersion = manifest.Version
case "rolled_back":
evidence.FinalSlot = state.ActiveSlot
evidence.FinalVersion = state.TargetVersion
}
encoded, err := json.MarshalIndent(evidence, "", " ")
if err != nil {
return err
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return fmt.Errorf("open simulated firmware evidence: %w", err)
}
if _, err = file.Write(encoded); err == nil {
err = file.Sync()
}
closeErr := file.Close()
if err != nil {
return fmt.Errorf("write simulated firmware evidence: %w", err)
}
if closeErr != nil {
return fmt.Errorf("close simulated firmware evidence: %w", closeErr)
}
return nil
}
func simulatedEvidenceBoolean(key string, fallback bool) (bool, error) {
value := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
if value == "" {
return fallback, nil
}
if value == "true" {
return true, nil
}
if value == "false" {
return false, nil
}
return false, fmt.Errorf("%s must be true or false", key)
}
+611
View File
@@ -0,0 +1,611 @@
package main
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"data-terminal-cloud/internal/deviceauth"
)
type simulator struct {
baseURL, deviceID string
secret []byte
client *http.Client
}
type apiResult struct {
Status int
Header http.Header
Body []byte
}
type envelope struct {
Data json.RawMessage `json:"data"`
}
type createdConversation struct {
ID string `json:"conversation_id"`
RTC struct {
RoomID string `json:"room_id"`
UserID string `json:"user_id"`
ExpiresAt time.Time `json:"expires_at"`
} `json:"rtc"`
}
type deviceConversationState struct {
Status string `json:"status"`
Action string `json:"action"`
FailureCode string `json:"failure_code"`
}
type uploadBatch struct {
ID string `json:"id"`
Status string `json:"status"`
Segments []struct {
ID string `json:"id"`
Status string `json:"status"`
UploadURL string `json:"upload_url"`
ContentType string `json:"content_type"`
LastErrorCode string `json:"last_error_code"`
LocalDeleteAfter *time.Time `json:"local_delete_after"`
} `json:"segments"`
}
func main() {
if err := run(context.Background()); err != nil {
fmt.Fprintln(os.Stderr, "模拟设备失败:", err)
os.Exit(1)
}
}
func run(ctx context.Context) error {
secret := []byte(env("SIM_DEVICE_SECRET", "local-simulated-device-secret-32-bytes-minimum"))
if len(secret) < 32 {
return errors.New("SIM_DEVICE_SECRET must contain at least 32 characters")
}
sim := &simulator{
baseURL: strings.TrimRight(env("SIM_API_URL", "http://localhost:58080"), "/"),
deviceID: env("SIM_DEVICE_ID", "10000000-0000-0000-0000-000000000511"),
secret: secret, client: &http.Client{Timeout: 15 * time.Second},
}
scenarios := parseScenarios(env("SIM_SCENARIO", "bootstrap"))
if scenarios["offline"] {
if err := sim.offlineRetry(ctx); err != nil {
return err
}
}
if scenarios["bootstrap"] || scenarios["conversation"] || scenarios["all"] {
if err := sim.bootstrap(ctx); err != nil {
return err
}
}
conversationID := strings.TrimSpace(os.Getenv("SIM_CONVERSATION_ID"))
if scenarios["conversation"] || scenarios["all"] {
id, err := sim.conversation(ctx)
if err != nil {
return err
}
conversationID = id
}
if scenarios["transcript"] || scenarios["all"] {
if conversationID == "" {
return errors.New("transcript scenario requires conversation or SIM_CONVERSATION_ID")
}
if err := sim.transcript(ctx, conversationID); err != nil {
return err
}
}
if scenarios["telemetry"] || scenarios["all"] {
if err := sim.telemetry(ctx, conversationID); err != nil {
return err
}
}
if scenarios["commands"] || scenarios["all"] {
if err := sim.commands(ctx); err != nil {
return err
}
}
if scenarios["media"] {
if conversationID == "" {
return errors.New("media scenario requires SIM_CONVERSATION_ID")
}
if err := sim.media(ctx, conversationID); err != nil {
return err
}
}
return nil
}
func (s *simulator) bootstrap(ctx context.Context) error {
query := url.Values{
"firmware_version": {env("SIM_FIRMWARE_VERSION", "0.2.0-sim")}, "board_rev": {"sim-rev-a"}, "rtc_sdk_version": {"mock-rtc-1"},
"codec": {"opus"}, "sample_rate": {"16000"}, "network": {"simulated"},
"device_time": {time.Now().UTC().Format(time.RFC3339)},
}
first, err := s.request(ctx, http.MethodGet, "/device/v2/bootstrap?"+query.Encode(), nil, "")
if err != nil {
return err
}
var bootstrap struct {
Device struct {
FirmwareCompatible bool `json:"firmware_compatible"`
MinimumFirmwareVersion *string `json:"minimum_firmware_version"`
} `json:"device"`
}
if err := decodeData(first.Body, &bootstrap); err != nil {
return err
}
if !bootstrap.Device.FirmwareCompatible {
minimum := "当前策略"
if bootstrap.Device.MinimumFirmwareVersion != nil {
minimum = *bootstrap.Device.MinimumFirmwareVersion
}
return fmt.Errorf("firmware update required before conversation; minimum=%s", minimum)
}
second, err := s.request(ctx, http.MethodGet, "/device/v2/config", nil, "")
if err != nil {
return err
}
fmt.Printf("bootstrap HTTP %d; config HTTP %d\n", first.Status, second.Status)
return nil
}
func (s *simulator) offlineRetry(ctx context.Context) error {
offline := *s
offline.baseURL = "http://127.0.0.1:1"
offline.client = &http.Client{Timeout: 250 * time.Millisecond}
if _, err := offline.request(ctx, http.MethodGet, "/device/v2/config", nil, ""); err == nil {
return errors.New("offline simulation unexpectedly reached a server")
}
fmt.Println("offline request retained locally; reconnecting with a new nonce")
_, err := s.request(ctx, http.MethodGet, "/device/v2/config", nil, "")
return err
}
func (s *simulator) conversation(ctx context.Context) (string, error) {
createBody := jsonBody(map[string]any{
"device_time": time.Now().UTC(), "monotonic_ms": 1000,
"capabilities": map[string]any{
"rtc": true, "audio_tracks": []string{"near", "far"}, "upload_resume": true,
"rtc_credential_renewal": true,
},
"network": map[string]any{"type": "simulated", "rssi": -48}, "requested_goal": "open_conversation",
})
createKey := "sim-create-" + nonce()
createdResponse, err := s.request(ctx, http.MethodPost, "/device/v2/conversations", createBody, createKey)
if err != nil {
return "", err
}
var created createdConversation
if err := decodeData(createdResponse.Body, &created); err != nil {
return "", err
}
replay, err := s.request(ctx, http.MethodPost, "/device/v2/conversations", createBody, createKey)
if err != nil || replay.Header.Get("Idempotency-Replayed") != "true" {
return "", fmt.Errorf("create replay failed: %w", err)
}
activate := jsonBody(map[string]any{
"joined_at": time.Now().UTC(), "room_id": created.RTC.RoomID, "rtc_user_id": created.RTC.UserID,
"rtc_join_result": "success", "config_revision": 1, "connection_reference": "sim-device",
})
activationPath := "/device/v2/conversations/" + created.ID + "/activate"
if _, err := s.request(ctx, http.MethodPost, activationPath, activate, "sim-activate-"+nonce()); err != nil {
return "", err
}
if err := s.waitForConversationActive(ctx, created.ID, 15*time.Second); err != nil {
return "", err
}
heartbeat := jsonBody(map[string]any{
"sequence_no": 1, "device_time": time.Now().UTC(), "monotonic_ms": 2000, "conversation_status": "ACTIVE",
"rtc": map[string]any{"joined": true, "uplink": true, "downlink": true}, "network": map[string]any{"type": "simulated", "rssi": -50},
"firmware_version": "0.2.0-sim", "reported_config_revision": 1,
"rtc_credential_expires_at": created.RTC.ExpiresAt,
"recording_queue": map[string]any{"pending_segments": 2, "failed_segments": 0}, "command_results": []any{},
})
if _, err := s.request(ctx, http.MethodPost, "/device/v2/conversations/"+created.ID+"/heartbeat", heartbeat, ""); err != nil {
return "", err
}
stop := jsonBody(map[string]any{
"reason": "simulated_hangup", "device_time": time.Now().UTC(), "last_heartbeat_sequence": 1,
"rtc_left": true, "recording": map[string]any{"near_segments": 1, "far_segments": 1},
})
stopKey := "sim-stop-" + nonce()
if _, err := s.request(ctx, http.MethodPost, "/device/v2/conversations/"+created.ID+"/stop", stop, stopKey); err != nil {
return "", err
}
replay, err = s.request(ctx, http.MethodPost, "/device/v2/conversations/"+created.ID+"/stop", stop, stopKey)
if err != nil || replay.Header.Get("Idempotency-Replayed") != "true" {
return "", fmt.Errorf("stop replay failed: %w", err)
}
time.Sleep(2 * time.Second)
fmt.Println("conversation and repeated requests completed:", created.ID)
return created.ID, nil
}
func (s *simulator) waitForConversationActive(ctx context.Context, conversationID string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for {
statusResponse, err := s.request(ctx, http.MethodGet, "/device/v2/conversations/"+conversationID, nil, "")
if err != nil {
return err
}
var state deviceConversationState
if err := decodeData(statusResponse.Body, &state); err != nil {
return err
}
switch state.Action {
case "continue":
if state.Status != "ACTIVE" {
return fmt.Errorf("conversation returned continue in unexpected state %s", state.Status)
}
return nil
case "leave_room":
code := state.FailureCode
if code == "" {
code = strings.ToLower(state.Status)
}
return fmt.Errorf("conversation cannot start; leave RTC room: %s", code)
case "wait":
default:
return fmt.Errorf("conversation returned unsupported device action %q", state.Action)
}
if time.Now().After(deadline) {
return errors.New("provider did not activate the simulated conversation within 15 seconds")
}
if err := waitForContext(ctx, 500*time.Millisecond); err != nil {
return err
}
}
}
func (s *simulator) transcript(ctx context.Context, conversationID string) error {
now := time.Now().UTC()
body := jsonBody(map[string]any{"turns": []any{map[string]any{
"turn_key": "sim-final-" + nonce(), "sequence_no": 100, "speaker": "subject",
"start_at": now.Add(-2 * time.Second), "end_at": now,
"final_text": "这是模拟设备离线缓存后补交的最终字幕。", "interrupted": false,
}}})
key := "sim-transcript-" + nonce()
if _, err := s.request(ctx, http.MethodPost, "/device/v2/conversations/"+conversationID+"/turns:batch", body, key); err != nil {
return err
}
replay, err := s.request(ctx, http.MethodPost, "/device/v2/conversations/"+conversationID+"/turns:batch", body, key)
if err != nil || replay.Header.Get("Idempotency-Replayed") != "true" {
return fmt.Errorf("transcript replay failed: %w", err)
}
fmt.Println("transcript supplement and replay completed")
return nil
}
func (s *simulator) telemetry(ctx context.Context, conversationID string) error {
sequence := time.Now().UnixNano()
event := map[string]any{
"event_key": "sim-status-" + strconv.FormatInt(sequence, 10), "sequence_no": sequence,
"occurred_at": time.Now().UTC(), "event_type": "status",
"status": map[string]any{
"firmware_version": "0.2.0-sim", "reported_config_revision": 1,
"network": map[string]any{"type": "simulated", "rssi": -52},
"rtc": map[string]any{"joined": false, "uplink": false, "downlink": false},
"recording_queue": map[string]any{"pending_segments": 0, "failed_segments": 0}, "conversation_status": "COMPLETED",
},
}
if conversationID != "" {
event["conversation_id"] = conversationID
}
body := jsonBody(map[string]any{"events": []any{event}})
key := "sim-telemetry-" + nonce()
if _, err := s.request(ctx, http.MethodPost, "/device/v2/telemetry:batch", body, key); err != nil {
return err
}
replay, err := s.request(ctx, http.MethodPost, "/device/v2/telemetry:batch", body, key)
if err != nil || replay.Header.Get("Idempotency-Replayed") != "true" {
return fmt.Errorf("telemetry replay failed: %w", err)
}
fmt.Println("telemetry batch and replay completed")
return nil
}
func (s *simulator) commands(ctx context.Context) error {
response, err := s.request(ctx, http.MethodGet, "/device/v2/commands?limit=20", nil, "")
if err != nil {
return err
}
var data struct {
Items []simulatedDeviceCommand `json:"items"`
}
if err := decodeData(response.Body, &data); err != nil {
return err
}
completed, failed := 0, 0
for _, command := range data.Items {
if command.Type == "firmware_update" {
outcome, err := s.executeFirmwareCommand(ctx, command)
if err != nil {
return err
}
switch outcome {
case firmwareCompleted:
completed++
case firmwareFailed:
failed++
case firmwareInterrupted:
fmt.Printf("firmware command %s intentionally interrupted; server state retained for recovery\n", command.ID)
return nil
case firmwareRebootRequired:
fmt.Printf("firmware command %s armed the inactive slot; restart the simulator to perform boot health confirmation\n", command.ID)
return nil
}
continue
}
if err := s.ackDeviceCommand(ctx, command.ID, "SUCCEEDED", map[string]any{"result_code": "SIMULATED_OK"}); err != nil {
return err
}
completed++
}
fmt.Printf("processed %d pending commands: %d completed, %d simulated failures\n", len(data.Items), completed, failed)
return nil
}
func (s *simulator) media(ctx context.Context, conversationID string) error {
durationMS, err := strconv.Atoi(env("SIM_MEDIA_DURATION_MS", "100"))
if err != nil || durationMS < 1 || durationMS > 600000 {
return errors.New("SIM_MEDIA_DURATION_MS must be between 1 and 600000")
}
mediaPath := strings.TrimSpace(os.Getenv("SIM_MEDIA_FILE"))
content := syntheticWAV(durationMS)
if mediaPath != "" {
content, err = os.ReadFile(mediaPath)
if err != nil {
return fmt.Errorf("read simulated media file: %w", err)
}
}
digest := sha256.Sum256(content)
manifest := jsonBody(map[string]any{"segments": []any{map[string]any{
"track": env("SIM_MEDIA_TRACK", "near"), "sequence_no": int(time.Now().Unix() % 1000000000),
"codec": "wav_pcm_s16le", "content_type": "audio/wav", "duration_ms": durationMS,
"expected_size": len(content), "expected_digest": hex.EncodeToString(digest[:]),
}}})
prepared, err := s.request(ctx, http.MethodPost, "/device/v2/conversations/"+conversationID+"/upload-batches", manifest, "sim-media-"+nonce())
if err != nil {
return err
}
var batch uploadBatch
if err := decodeData(prepared.Body, &batch); err != nil {
return err
}
if len(batch.Segments) != 1 || batch.Segments[0].UploadURL == "" {
return errors.New("media preparation did not return one upload URL")
}
uploadRequest, err := http.NewRequestWithContext(ctx, http.MethodPut, batch.Segments[0].UploadURL, bytes.NewReader(content))
if err != nil {
return err
}
uploadRequest.Header.Set("Content-Type", "audio/wav")
uploadResponse, err := s.client.Do(uploadRequest)
if err != nil {
return fmt.Errorf("upload simulated media: %w", err)
}
uploadResponse.Body.Close()
if uploadResponse.StatusCode < 200 || uploadResponse.StatusCode >= 300 {
return fmt.Errorf("upload simulated media returned HTTP %d", uploadResponse.StatusCode)
}
if _, err := s.request(ctx, http.MethodPost, "/device/v2/upload-batches/"+batch.ID+"/complete", []byte(`{}`), ""); err != nil {
return err
}
verifyTimeout, err := simulatorDuration("SIM_MEDIA_VERIFY_TIMEOUT", 20*time.Second, 5*time.Minute)
if err != nil {
return err
}
verified, err := s.waitForVerifiedMedia(ctx, batch.ID, verifyTimeout)
if err != nil {
return err
}
if verified.LocalDeleteAfter == nil {
return errors.New("verified media segment did not return local_delete_after")
}
deleteWaitMax, err := simulatorDuration("SIM_MEDIA_DELETE_WAIT_MAX", 30*time.Second, 10*time.Minute)
if err != nil {
return err
}
wait := time.Until(*verified.LocalDeleteAfter) + 100*time.Millisecond
if wait > deleteWaitMax {
fmt.Printf("media verified; local encrypted copy retained until %s (outside simulator wait window)\n", verified.LocalDeleteAfter.UTC().Format(time.RFC3339))
return nil
}
if wait > 0 {
if err := waitForContext(ctx, wait); err != nil {
return err
}
}
// The simulator only releases its in-memory copy. SIM_MEDIA_FILE is an operator-owned
// fixture and is intentionally never removed from disk.
content = nil
ack := jsonBody(map[string]any{"status": "DELETED"})
if _, err := s.request(ctx, http.MethodPost, "/device/v2/media-segments/"+verified.ID+"/local-delete-ack", ack, "sim-local-delete-"+nonce()); err != nil {
return err
}
fmt.Println("media verified and local encrypted copy deletion acknowledged:", batch.ID)
return nil
}
type verifiedMediaSegment struct {
ID string
LocalDeleteAfter *time.Time
}
func (s *simulator) waitForVerifiedMedia(ctx context.Context, batchID string, timeout time.Duration) (verifiedMediaSegment, error) {
deadline := time.Now().Add(timeout)
for {
response, err := s.request(ctx, http.MethodGet, "/device/v2/upload-batches/"+batchID, nil, "")
if err != nil {
return verifiedMediaSegment{}, err
}
var batch uploadBatch
if err := decodeData(response.Body, &batch); err != nil {
return verifiedMediaSegment{}, err
}
if batch.Status == "FAILED" {
code := "SEGMENT_REJECTED"
if len(batch.Segments) > 0 && batch.Segments[0].LastErrorCode != "" {
code = batch.Segments[0].LastErrorCode
}
return verifiedMediaSegment{}, fmt.Errorf("media verification failed: %s", code)
}
if batch.Status == "COMPLETED" {
for _, segment := range batch.Segments {
if segment.Status == "VERIFIED" && segment.ID != "" {
return verifiedMediaSegment{ID: segment.ID, LocalDeleteAfter: segment.LocalDeleteAfter}, nil
}
}
return verifiedMediaSegment{}, errors.New("completed media batch has no verified segment")
}
if time.Now().After(deadline) {
return verifiedMediaSegment{}, fmt.Errorf("media verification did not complete within %s", timeout)
}
if err := waitForContext(ctx, 300*time.Millisecond); err != nil {
return verifiedMediaSegment{}, err
}
}
}
func simulatorDuration(key string, fallback, maximum time.Duration) (time.Duration, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
parsed, err := time.ParseDuration(value)
if err != nil || parsed <= 0 || parsed > maximum {
return 0, fmt.Errorf("%s must be a positive duration no greater than %s", key, maximum)
}
return parsed, nil
}
func waitForContext(ctx context.Context, duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func (s *simulator) request(ctx context.Context, method, path string, body []byte, key string) (apiResult, error) {
request, err := http.NewRequestWithContext(ctx, method, s.baseURL+path, bytes.NewReader(body))
if err != nil {
return apiResult{}, err
}
timestamp := time.Now().UTC().Format(time.RFC3339)
requestNonce, err := newNonce()
if err != nil {
return apiResult{}, err
}
hash := sha256.Sum256(body)
digest := hex.EncodeToString(hash[:])
request.Header.Set(deviceauth.HeaderDeviceID, s.deviceID)
request.Header.Set(deviceauth.HeaderTimestamp, timestamp)
request.Header.Set(deviceauth.HeaderNonce, requestNonce)
request.Header.Set(deviceauth.HeaderContentSHA256, digest)
request.Header.Set(deviceauth.HeaderSignature, deviceauth.SignatureHeader(s.secret, method, request.URL.RequestURI(), timestamp, requestNonce, digest))
if len(body) > 0 {
request.Header.Set("Content-Type", "application/json")
}
if key != "" {
request.Header.Set("Idempotency-Key", key)
}
response, err := s.client.Do(request)
if err != nil {
return apiResult{}, err
}
defer response.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return apiResult{}, err
}
result := apiResult{Status: response.StatusCode, Header: response.Header.Clone(), Body: responseBody}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return result, fmt.Errorf("%s returned HTTP %d: %s", request.URL.Path, response.StatusCode, responseBody)
}
return result, nil
}
func decodeData(body []byte, target any) error {
var result envelope
if err := json.Unmarshal(body, &result); err != nil {
return err
}
return json.Unmarshal(result.Data, target)
}
func parseScenarios(value string) map[string]bool {
result := map[string]bool{}
for _, item := range strings.Split(strings.ToLower(value), ",") {
if item = strings.TrimSpace(item); item != "" {
result[item] = true
}
}
return result
}
func syntheticWAV(durationMS int) []byte {
samples := 16000 * durationMS / 1000
dataSize := samples * 2
buffer := bytes.NewBuffer(make([]byte, 0, 44+dataSize))
buffer.WriteString("RIFF")
_ = binary.Write(buffer, binary.LittleEndian, uint32(36+dataSize))
buffer.WriteString("WAVEfmt ")
_ = binary.Write(buffer, binary.LittleEndian, uint32(16))
_ = binary.Write(buffer, binary.LittleEndian, uint16(1))
_ = binary.Write(buffer, binary.LittleEndian, uint16(1))
_ = binary.Write(buffer, binary.LittleEndian, uint32(16000))
_ = binary.Write(buffer, binary.LittleEndian, uint32(32000))
_ = binary.Write(buffer, binary.LittleEndian, uint16(2))
_ = binary.Write(buffer, binary.LittleEndian, uint16(16))
buffer.WriteString("data")
_ = binary.Write(buffer, binary.LittleEndian, uint32(dataSize))
buffer.Write(make([]byte, dataSize))
return buffer.Bytes()
}
func jsonBody(value any) []byte {
body, err := json.Marshal(value)
if err != nil {
panic(err)
}
return body
}
func nonce() string {
value, err := newNonce()
if err != nil {
panic(err)
}
return value
}
func newNonce() (string, error) {
value := make([]byte, 16)
if _, err := rand.Read(value); err != nil {
return "", err
}
return hex.EncodeToString(value), nil
}
func env(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
+429
View File
@@ -0,0 +1,429 @@
package main
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"data-terminal-cloud/internal/deviceauth"
)
func TestSimulatorRequestSignsBodyAndReturnsEnvelope(t *testing.T) {
secret := []byte("simulator-request-secret-at-least-32-bytes")
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
body, _ := io.ReadAll(request.Body)
digest := sha256.Sum256(body)
if request.Header.Get(deviceauth.HeaderDeviceID) != "device-1" || request.Header.Get(deviceauth.HeaderContentSHA256) != hex.EncodeToString(digest[:]) || request.Header.Get(deviceauth.HeaderSignature) == "" {
t.Errorf("invalid signed headers: %v", request.Header)
}
if request.Header.Get("Idempotency-Key") != "idem-1" {
t.Errorf("idempotency key=%q", request.Header.Get("Idempotency-Key"))
}
writer.Header().Set("ETag", `"3"`)
writer.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(writer, `{"data":{"status":"ok"}}`)
}))
defer server.Close()
sim := simulator{baseURL: server.URL, deviceID: "device-1", secret: secret, client: server.Client()}
result, err := sim.request(context.Background(), http.MethodPost, "/device/v2/test", []byte(`{"value":1}`), "idem-1")
if err != nil || result.Status != http.StatusOK || result.Header.Get("ETag") != `"3"` {
t.Fatalf("result=%+v err=%v", result, err)
}
var payload struct {
Status string `json:"status"`
}
if err := decodeData(result.Body, &payload); err != nil || payload.Status != "ok" {
t.Fatalf("payload=%+v err=%v", payload, err)
}
}
func TestSimulatorHelpersAndValidation(t *testing.T) {
scenarios := parseScenarios(" Bootstrap,TELEMETRY, bootstrap ")
if !scenarios["bootstrap"] || !scenarios["telemetry"] || len(scenarios) != 2 {
t.Fatalf("scenarios=%v", scenarios)
}
wav := syntheticWAV(100)
if string(wav[:4]) != "RIFF" || string(wav[8:12]) != "WAVE" || binary.LittleEndian.Uint32(wav[40:44]) != uint32(len(wav)-44) {
t.Fatalf("invalid WAV header: size=%d", len(wav))
}
if len(jsonBody(map[string]int{"value": 1})) == 0 {
t.Fatal("json body is empty")
}
if value, err := newNonce(); err != nil || len(value) != 32 {
t.Fatalf("nonce=%q err=%v", value, err)
}
t.Setenv("SIM_DEVICE_SECRET", "short")
if err := run(context.Background()); err == nil || !strings.Contains(err.Error(), "at least 32") {
t.Fatalf("short secret error=%v", err)
}
}
func TestCreatedConversationIgnoresEngineMode(t *testing.T) {
decode := func(mode string) createdConversation {
var created createdConversation
body := []byte(`{"data":{"conversation_id":"conversation-1","rtc":{"room_id":"room-1","user_id":"device-1","expires_at":"2026-08-23T12:05:00Z"},"session":{"engine_mode":"` + mode + `"}}}`)
if err := decodeData(body, &created); err != nil {
t.Fatalf("decode mode %q: %v", mode, err)
}
return created
}
for _, mode := range []string{"s2s_pure", "s2s_hybrid", "cascade", "future_mode"} {
created := decode(mode)
if created.ID != "conversation-1" || created.RTC.RoomID != "room-1" || created.RTC.UserID != "device-1" {
t.Fatalf("mode %q changed terminal media inputs: %#v", mode, created)
}
}
}
func TestSimulatorWaitForConversationState(t *testing.T) {
for _, test := range []struct {
name, response, wantError string
}{
{name: "active", response: `{"data":{"status":"ACTIVE","action":"continue"}}`},
{name: "failed", response: `{"data":{"status":"FAILED","action":"leave_room","failure_code":"provider_start_rejected"}}`, wantError: "provider_start_rejected"},
} {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet || request.URL.Path != "/device/v2/conversations/conversation-1" {
t.Errorf("unexpected request %s %s", request.Method, request.URL.Path)
}
writer.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(writer, test.response)
}))
defer server.Close()
sim := simulator{baseURL: server.URL, deviceID: "device-1", secret: []byte("simulator-request-secret-at-least-32-bytes"), client: server.Client()}
err := sim.waitForConversationActive(context.Background(), "conversation-1", time.Second)
if test.wantError == "" && err != nil {
t.Fatalf("wait failed: %v", err)
}
if test.wantError != "" && (err == nil || !strings.Contains(err.Error(), test.wantError)) {
t.Fatalf("error=%v want %q", err, test.wantError)
}
})
}
}
func TestSimulatorHTTPAndMediaFailures(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.WriteHeader(http.StatusConflict)
_, _ = io.WriteString(writer, `{"error":{"code":"CONFLICT"}}`)
}))
defer server.Close()
sim := simulator{baseURL: server.URL, deviceID: "device-1", secret: []byte("simulator-request-secret-at-least-32-bytes"), client: server.Client()}
if _, err := sim.request(context.Background(), http.MethodGet, "/conflict", nil, ""); err == nil || !strings.Contains(err.Error(), "HTTP 409") {
t.Fatalf("conflict error=%v", err)
}
t.Setenv("SIM_MEDIA_DURATION_MS", "0")
if err := sim.media(context.Background(), "conversation-1"); err == nil || !strings.Contains(err.Error(), "between 1 and 600000") {
t.Fatalf("media validation error=%v", err)
}
var target map[string]any
if err := decodeData([]byte(`not-json`), &target); err == nil {
t.Fatal("invalid envelope accepted")
}
encoded, _ := json.Marshal(map[string]any{"data": "not-an-object"})
if err := decodeData(encoded, &target); err == nil {
t.Fatal("invalid data payload accepted")
}
}
func TestSimulatorMediaCompletesVerificationAndDeleteReceipt(t *testing.T) {
deleteAcknowledged := false
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Type", "application/json")
switch {
case request.Method == http.MethodPost && request.URL.Path == "/device/v2/conversations/conversation-1/upload-batches":
_, _ = io.WriteString(writer, `{"data":{"id":"batch-1","status":"UPLOADING","segments":[{"id":"segment-1","upload_url":"`+server.URL+`/upload","content_type":"audio/wav"}]}}`)
case request.Method == http.MethodPut && request.URL.Path == "/upload":
writer.WriteHeader(http.StatusOK)
case request.Method == http.MethodPost && request.URL.Path == "/device/v2/upload-batches/batch-1/complete":
_, _ = io.WriteString(writer, `{"data":{"id":"batch-1","status":"VERIFYING","segments":[]}}`)
case request.Method == http.MethodGet && request.URL.Path == "/device/v2/upload-batches/batch-1":
deleteAfter := time.Now().Add(-time.Second).UTC().Format(time.RFC3339Nano)
_, _ = io.WriteString(writer, `{"data":{"id":"batch-1","status":"COMPLETED","segments":[{"id":"segment-1","status":"VERIFIED","local_delete_after":"`+deleteAfter+`"}]}}`)
case request.Method == http.MethodPost && request.URL.Path == "/device/v2/media-segments/segment-1/local-delete-ack":
if request.Header.Get("Idempotency-Key") == "" {
t.Error("local delete receipt has no idempotency key")
}
deleteAcknowledged = true
_, _ = io.WriteString(writer, `{"data":{"id":"segment-1","status":"VERIFIED","local_delete_status":"DELETED"}}`)
default:
http.NotFound(writer, request)
}
}))
defer server.Close()
t.Setenv("SIM_MEDIA_VERIFY_TIMEOUT", "2s")
t.Setenv("SIM_MEDIA_DELETE_WAIT_MAX", "2s")
sim := simulator{baseURL: server.URL, deviceID: "device-1", secret: []byte("simulator-request-secret-at-least-32-bytes"), client: server.Client()}
if err := sim.media(context.Background(), "conversation-1"); err != nil {
t.Fatalf("media flow error=%v", err)
}
if !deleteAcknowledged {
t.Fatal("local delete receipt was not sent")
}
}
func TestSimulatorFirmwareCommandSuccess(t *testing.T) {
content := []byte("signed simulated firmware image")
payload := signedSimulatorFirmwarePayload(t, content, "2.3.1")
acks := []map[string]any{}
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Type", "application/json")
switch {
case request.Method == http.MethodGet && request.URL.Path == "/device/v2/firmware-releases/release-1/download":
_, _ = io.WriteString(writer, `{"data":{"download_url":"`+server.URL+`/firmware.bin"}}`)
case request.Method == http.MethodGet && request.URL.Path == "/firmware.bin":
writer.Header().Set("Content-Type", "application/octet-stream")
_, _ = writer.Write(content)
case request.Method == http.MethodPost && request.URL.Path == "/device/v2/commands/command-1/ack":
var body map[string]any
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Errorf("decode ack: %v", err)
}
acks = append(acks, body)
_, _ = io.WriteString(writer, `{"data":{"id":"command-1"}}`)
default:
http.NotFound(writer, request)
}
}))
defer server.Close()
sim := simulator{baseURL: server.URL, deviceID: "device-1", secret: []byte("simulator-request-secret-at-least-32-bytes"), client: server.Client()}
command := simulatedDeviceCommand{ID: "command-1", Type: "firmware_update", Payload: payload}
outcome, err := sim.executeFirmwareCommand(context.Background(), command)
if err != nil || outcome != firmwareCompleted {
t.Fatalf("outcome=%q err=%v", outcome, err)
}
if len(acks) != 4 || acks[0]["status"] != "ACKNOWLEDGED" || acks[1]["status"] != "ACKNOWLEDGED" || acks[2]["status"] != "ACKNOWLEDGED" || acks[3]["status"] != "SUCCEEDED" {
t.Fatalf("acks=%+v", acks)
}
firstResult := acks[0]["result_summary"].(map[string]any)
downloadResult := acks[1]["result_summary"].(map[string]any)
installResult := acks[2]["result_summary"].(map[string]any)
finalResult := acks[3]["result_summary"].(map[string]any)
if firstResult["phase"] != "downloading" || downloadResult["result_code"] != "ARTIFACT_VERIFIED" || downloadResult["verified_signing_key_id"] != "sim-test-key" || installResult["phase"] != "installing" || finalResult["installed_version"] != "2.3.1" || finalResult["verified_sha256"] != payload["sha256"] {
t.Fatalf("firmware results=%+v", acks)
}
}
func TestSimulatorFirmwareCommandFaultsAndInterruptions(t *testing.T) {
content := []byte("simulated firmware fault fixture")
tests := []struct {
fault string
wantOutcome firmwareExecutionOutcome
wantStatuses []string
wantCode string
}{
{fault: "download_failed", wantOutcome: firmwareFailed, wantStatuses: []string{"ACKNOWLEDGED", "FAILED"}, wantCode: "DOWNLOAD_FAILED"},
{fault: "digest_mismatch", wantOutcome: firmwareFailed, wantStatuses: []string{"ACKNOWLEDGED", "FAILED"}, wantCode: "DIGEST_MISMATCH"},
{fault: "signature_mismatch", wantOutcome: firmwareFailed, wantStatuses: []string{"ACKNOWLEDGED", "FAILED"}, wantCode: "SIGNATURE_MISMATCH"},
{fault: "install_failed", wantOutcome: firmwareFailed, wantStatuses: []string{"ACKNOWLEDGED", "ACKNOWLEDGED", "ACKNOWLEDGED", "FAILED"}, wantCode: "INSTALL_FAILED"},
{fault: "interrupt_after_download", wantOutcome: firmwareInterrupted, wantStatuses: []string{"ACKNOWLEDGED", "ACKNOWLEDGED"}, wantCode: "ARTIFACT_VERIFIED"},
{fault: "interrupt_after_install", wantOutcome: firmwareInterrupted, wantStatuses: []string{"ACKNOWLEDGED", "ACKNOWLEDGED", "ACKNOWLEDGED"}, wantCode: "INSTALL_STARTED"},
}
for _, test := range tests {
t.Run(test.fault, func(t *testing.T) {
t.Setenv("SIM_FIRMWARE_FAULT", test.fault)
payload := signedSimulatorFirmwarePayload(t, content, "2.3.1")
acks := []map[string]any{}
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Type", "application/json")
switch {
case request.Method == http.MethodGet && strings.HasSuffix(request.URL.Path, "/download"):
_, _ = io.WriteString(writer, `{"data":{"download_url":"`+server.URL+`/firmware.bin"}}`)
case request.Method == http.MethodGet && request.URL.Path == "/firmware.bin":
_, _ = writer.Write(content)
case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/ack"):
var body map[string]any
_ = json.NewDecoder(request.Body).Decode(&body)
acks = append(acks, body)
_, _ = io.WriteString(writer, `{"data":{"id":"command-1"}}`)
default:
http.NotFound(writer, request)
}
}))
defer server.Close()
sim := simulator{baseURL: server.URL, deviceID: "device-1", secret: []byte("simulator-request-secret-at-least-32-bytes"), client: server.Client()}
command := simulatedDeviceCommand{ID: "command-1", Type: "firmware_update", Payload: payload}
outcome, err := sim.executeFirmwareCommand(context.Background(), command)
if err != nil || outcome != test.wantOutcome {
t.Fatalf("outcome=%q err=%v", outcome, err)
}
statuses := make([]string, 0, len(acks))
for _, ack := range acks {
statuses = append(statuses, ack["status"].(string))
}
if strings.Join(statuses, ",") != strings.Join(test.wantStatuses, ",") {
t.Fatalf("statuses=%v want=%v", statuses, test.wantStatuses)
}
lastResult := acks[len(acks)-1]["result_summary"].(map[string]any)
if lastResult["result_code"] != test.wantCode {
t.Fatalf("last result=%+v", lastResult)
}
})
}
}
func TestSimulatorFirmwareManifestAndConfigurationValidation(t *testing.T) {
digest := strings.Repeat("a", 64)
valid := map[string]any{"release_id": "release-1", "version": "2.3.1", "sha256": digest, "signature": base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize)), "signing_key_id": "sim-test-key", "download_path": "/device/v2/firmware-releases/release-1/download"}
if _, err := parseSimulatedFirmwareManifest(valid); err != nil {
t.Fatalf("valid manifest error=%v", err)
}
for name, payload := range map[string]map[string]any{
"external path": {"release_id": "release-1", "version": "2.3.1", "sha256": digest, "signature": valid["signature"], "signing_key_id": "sim-test-key", "download_path": "https://example.invalid/firmware"},
"traversal": {"release_id": "release-1", "version": "2.3.1", "sha256": digest, "signature": valid["signature"], "signing_key_id": "sim-test-key", "download_path": "/device/v2/firmware-releases/../download"},
"uppercase": {"release_id": "release-1", "version": "2.3.1", "sha256": strings.Repeat("A", 64), "signature": valid["signature"], "signing_key_id": "sim-test-key", "download_path": "/device/v2/firmware-releases/release-1/download"},
} {
t.Run(name, func(t *testing.T) {
if _, err := parseSimulatedFirmwareManifest(payload); err == nil {
t.Fatalf("invalid manifest accepted: %+v", payload)
}
})
}
t.Setenv("SIM_FIRMWARE_FAULT", "unknown")
if _, err := simulatedFirmwareFault(); err == nil {
t.Fatal("unknown firmware fault was accepted")
}
t.Setenv("SIM_FIRMWARE_STEP_DELAY", "31s")
if _, err := simulatorOptionalDuration("SIM_FIRMWARE_STEP_DELAY", 30*time.Second); err == nil {
t.Fatal("excessive firmware step delay was accepted")
}
t.Setenv("SIM_FIRMWARE_TRUSTED_KEYS", `{}`)
if _, err := simulatedFirmwareTrustedKey("sim-test-key"); err == nil {
t.Fatal("unprovisioned firmware signing key was accepted")
}
t.Setenv("SIM_FIRMWARE_AB_UPDATE", "yes")
if _, err := simulatedABFirmwareEnabled(); err == nil {
t.Fatal("invalid A/B firmware flag was accepted")
}
t.Setenv("SIM_FIRMWARE_AB_UPDATE", "true")
t.Setenv("SIM_FIRMWARE_STATE_FILE", "relative-state.json")
if _, err := simulatedABFirmwareEnabled(); err == nil {
t.Fatal("relative A/B firmware state path was accepted")
}
}
func TestSimulatorABFirmwareBootConfirmationAndRollback(t *testing.T) {
for _, test := range []struct {
name, stageFault, bootFault, wantOutcome, wantPhase, wantSlot, wantCode string
retryAfterBoot bool
wantAckCount int
}{
{name: "healthy boot", stageFault: "none", bootFault: "none", wantOutcome: string(firmwareCompleted), wantPhase: firmwareBootConfirmed, wantSlot: "B", wantCode: "SIMULATED_AB_BOOT_CONFIRMED", wantAckCount: 7},
{name: "failed boot rolls back", stageFault: "none", bootFault: "boot_health_failed", wantOutcome: string(firmwareFailed), wantPhase: firmwareBootRolledBack, wantSlot: "A", wantCode: "BOOT_HEALTH_FAILED_ROLLED_BACK", wantAckCount: 7},
{name: "power loss after staging resumes", stageFault: "power_loss_after_stage", bootFault: "none", wantOutcome: string(firmwareCompleted), wantPhase: firmwareBootConfirmed, wantSlot: "B", wantCode: "SIMULATED_AB_BOOT_CONFIRMED", wantAckCount: 7},
{name: "power loss during boot resumes", stageFault: "none", bootFault: "power_loss_during_boot", retryAfterBoot: true, wantOutcome: string(firmwareCompleted), wantPhase: firmwareBootConfirmed, wantSlot: "B", wantCode: "SIMULATED_AB_BOOT_CONFIRMED", wantAckCount: 8},
} {
t.Run(test.name, func(t *testing.T) {
content := []byte("simulated A/B firmware image")
payload := signedSimulatorFirmwarePayload(t, content, "3.1.0")
stateFile := filepath.Join(t.TempDir(), "firmware-state.json")
t.Setenv("SIM_FIRMWARE_AB_UPDATE", "true")
t.Setenv("SIM_FIRMWARE_STATE_FILE", stateFile)
t.Setenv("SIM_FIRMWARE_BOOT_HEALTH_WINDOW", "30s")
t.Setenv("SIM_FIRMWARE_CURRENT_VERSION", "0.2.0-sim")
t.Setenv("SIM_FIRMWARE_FAULT", test.stageFault)
acks := []map[string]any{}
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Type", "application/json")
switch {
case request.Method == http.MethodGet && strings.HasSuffix(request.URL.Path, "/download"):
_, _ = io.WriteString(writer, `{"data":{"download_url":"`+server.URL+`/firmware.bin"}}`)
case request.Method == http.MethodGet && request.URL.Path == "/firmware.bin":
_, _ = writer.Write(content)
case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/ack"):
var body map[string]any
_ = json.NewDecoder(request.Body).Decode(&body)
acks = append(acks, body)
_, _ = io.WriteString(writer, `{"data":{"id":"command-ab"}}`)
default:
http.NotFound(writer, request)
}
}))
defer server.Close()
sim := simulator{baseURL: server.URL, deviceID: "device-1", secret: []byte("simulator-request-secret-at-least-32-bytes"), client: server.Client()}
command := simulatedDeviceCommand{ID: "command-ab", Type: "firmware_update", Payload: payload}
outcome, err := sim.executeFirmwareCommand(context.Background(), command)
if err != nil {
t.Fatalf("staging outcome=%q err=%v", outcome, err)
}
if test.stageFault == "power_loss_after_stage" {
if outcome != firmwareInterrupted {
t.Fatalf("power loss staging outcome=%q", outcome)
}
staged, stateErr := loadSimulatedFirmwareBootState()
if stateErr != nil || staged.Phase != firmwareBootStaged {
t.Fatalf("power loss staged state=%+v err=%v", staged, stateErr)
}
t.Setenv("SIM_FIRMWARE_FAULT", "none")
outcome, err = sim.executeFirmwareCommand(context.Background(), command)
}
if err != nil || outcome != firmwareRebootRequired {
t.Fatalf("reboot arming outcome=%q err=%v", outcome, err)
}
staged, err := loadSimulatedFirmwareBootState()
if err != nil || staged.Phase != firmwareBootRebootPending || staged.ActiveSlot != "A" || staged.TargetSlot != "B" || staged.BootAttemptID == "" {
t.Fatalf("staged state=%+v err=%v", staged, err)
}
t.Setenv("SIM_FIRMWARE_FAULT", test.bootFault)
outcome, err = sim.executeFirmwareCommand(context.Background(), command)
if test.retryAfterBoot {
if err != nil || outcome != firmwareInterrupted {
t.Fatalf("boot power loss outcome=%q err=%v", outcome, err)
}
t.Setenv("SIM_FIRMWARE_FAULT", "none")
outcome, err = sim.executeFirmwareCommand(context.Background(), command)
}
if err != nil || string(outcome) != test.wantOutcome {
t.Fatalf("boot outcome=%q err=%v", outcome, err)
}
finalState, err := loadSimulatedFirmwareBootState()
if err != nil || finalState.Phase != test.wantPhase || finalState.ActiveSlot != test.wantSlot {
t.Fatalf("final state=%+v err=%v", finalState, err)
}
if len(acks) != test.wantAckCount {
t.Fatalf("ack count=%d acks=%+v", len(acks), acks)
}
lastResult := acks[len(acks)-1]["result_summary"].(map[string]any)
if lastResult["result_code"] != test.wantCode {
t.Fatalf("last result=%+v", lastResult)
}
})
}
}
func signedSimulatorFirmwarePayload(t *testing.T, content []byte, version string) map[string]any {
t.Helper()
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(content)
digestText := hex.EncodeToString(digest[:])
manifest := fmt.Sprintf("firmware-manifest-v1\nmodel=simulated-terminal\nhardware_revision=sim-rev-a\nversion=%s\nsha256=%s\nsize=%d\n", version, digestText, len(content))
trustedKeys, _ := json.Marshal(map[string]string{"sim-test-key": base64.StdEncoding.EncodeToString(publicKey)})
t.Setenv("SIM_FIRMWARE_TRUSTED_KEYS", string(trustedKeys))
return map[string]any{
"release_id": "release-1", "version": version, "sha256": digestText,
"signature": base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, []byte(manifest))),
"signing_key_id": "sim-test-key", "download_path": "/device/v2/firmware-releases/release-1/download",
}
}
+24
View File
@@ -0,0 +1,24 @@
package main
import (
"context"
"log"
"os/signal"
"syscall"
"data-terminal-cloud/internal/app"
)
type applicationRunner func(context.Context, string) error
func run(ctx context.Context, runner applicationRunner) error {
return runner(ctx, "worker")
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := run(ctx, app.Run); err != nil {
log.Fatal(err)
}
}
+21
View File
@@ -0,0 +1,21 @@
package main
import (
"context"
"errors"
"testing"
)
func TestRunWiresWorkerServiceAndPropagatesError(t *testing.T) {
want := errors.New("synthetic worker failure")
ctx := context.WithValue(context.Background(), struct{}{}, "request-context")
err := run(ctx, func(gotContext context.Context, serviceName string) error {
if gotContext != ctx || serviceName != "worker" {
t.Fatalf("context=%v service=%q", gotContext, serviceName)
}
return want
})
if !errors.Is(err, want) {
t.Fatalf("run error=%v", err)
}
}
+316
View File
@@ -0,0 +1,316 @@
name: data-terminal-cloud
x-backend-environment: &backend-environment
APP_ENV: local
LOG_LEVEL: debug
DATABASE_URL: postgres://voice:voice_dev_only@postgres:5432/voice_platform?sslmode=disable
REDIS_ADDR: redis:6379
REDIS_PASSWORD: ""
REDIS_DB: "0"
DEPENDENCY_TIMEOUT: 2s
EXTERNAL_PROBE_INTERVAL: 30s
SHUTDOWN_TIMEOUT: 10s
AUTH_ISSUER_URL: http://localhost:58180/realms/voice-local
AUTH_JWKS_URL: http://keycloak:8080/realms/voice-local/protocol/openid-connect/certs
AUTH_AUDIENCE: voice-platform-web
OTEL_EXPORTER_OTLP_ENDPOINT: otel-collector:4317
RATE_LIMIT_RPS: "50"
RATE_LIMIT_BURST: "100"
RATE_LIMIT_USER_RPS: "20"
RATE_LIMIT_USER_BURST: "40"
RATE_LIMIT_TENANT_RPS: "200"
RATE_LIMIT_TENANT_BURST: "400"
RATE_LIMIT_DEVICE_RPS: "10"
RATE_LIMIT_DEVICE_BURST: "20"
PROVIDER_START_RPS: "20"
PROVIDER_START_BURST: "40"
PROVIDER_MAX_ACTIVE_TASKS: "100"
DEVICE_CLOCK_SKEW: 5m
DEVICE_SECRET_DIRECTORY: ${DEVICE_SECRET_DIRECTORY:-}
RTC_APP_ID: local-public-rtc-app
RTC_APP_KEY: ${RTC_APP_KEY:-}
RTC_TOKEN_ISSUER: ${RTC_TOKEN_ISSUER:-local}
RTC_TOKEN_TTL: 5m
CURSOR_SIGNING_SECRET: local-signed-pagination-cursor-secret-32-bytes-minimum
SHARE_TOKEN_SIGNING_SECRET: local-share-token-signing-secret-32-bytes-minimum
CONVERSATION_ALLOCATION_TTL: 30s
HEARTBEAT_INTERVAL_SECONDS: "15"
PROVIDER_CALLBACK_CLOCK_SKEW: 5m
PROVIDER_REQUEST_TIMEOUT: 5s
VOICE_PROVIDER: ${VOICE_PROVIDER:-mock}
VOLC_RTC_BASE_URL: ${VOLC_RTC_BASE_URL:-https://rtc.volcengineapi.com}
VOLC_ACCESS_KEY_ID: ${VOLC_ACCESS_KEY_ID:-}
VOLC_SECRET_ACCESS_KEY: ${VOLC_SECRET_ACCESS_KEY:-}
VOLC_SESSION_TOKEN: ${VOLC_SESSION_TOKEN:-}
VOLC_RTC_REGION: ${VOLC_RTC_REGION:-cn-north-1}
VOLC_RTC_API_VERSION: ${VOLC_RTC_API_VERSION:-2024-12-01}
VOLC_VOICE_CHAT_TEMPLATE_FILE: ${VOLC_VOICE_CHAT_TEMPLATE_FILE:-}
PROVIDER_FAILURE_THRESHOLD: "3"
PROVIDER_CIRCUIT_COOLDOWN: 1m
RECOVERY_SCAN_INTERVAL: 5s
RECOVERY_STARTING_AFTER: 20s
RECOVERY_STOPPING_AFTER: 20s
OBJECT_STORE_ENDPOINT: minio:9000
OBJECT_STORE_PUBLIC_ENDPOINT: localhost:59000
OBJECT_STORE_ACCESS_KEY: local_minio
OBJECT_STORE_SECRET_KEY: local_minio_password
OBJECT_STORE_EXPORT_BUCKET: private-exports
OBJECT_STORE_REGION: us-east-1
OBJECT_STORE_SECURE: "false"
EXPORT_DOWNLOAD_TTL: 10m
EXPORT_OBJECT_TTL: 24h
MEDIA_LOCAL_DELETE_GRACE: ${MEDIA_LOCAL_DELETE_GRACE:-1s}
EMBEDDING_PROVIDER: ${EMBEDDING_PROVIDER:-local}
ARK_BASE_URL: ${ARK_BASE_URL:-https://ark.cn-beijing.volces.com/api/v3}
ARK_API_KEY: ${ARK_API_KEY:-}
ARK_EMBEDDING_MODEL: ${ARK_EMBEDDING_MODEL:-}
ARK_REQUEST_TIMEOUT: ${ARK_REQUEST_TIMEOUT:-30s}
x-backend-dependencies: &backend-dependencies
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
migrate:
condition: service_completed_successfully
seed:
condition: service_completed_successfully
services:
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_DB: voice_platform
POSTGRES_USER: voice
POSTGRES_PASSWORD: voice_dev_only
ports:
- "${POSTGRES_PORT:-55432}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U voice -d voice_platform"]
interval: 5s
timeout: 3s
retries: 20
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
ports:
- "${REDIS_PORT:-56379}:6379"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 20
minio:
image: quay.io/minio/minio:latest
command: ["server", "/data", "--console-address", ":9001"]
environment:
MINIO_ROOT_USER: local_minio
MINIO_ROOT_PASSWORD: local_minio_password
ports:
- "${MINIO_API_PORT:-59000}:9000"
- "${MINIO_CONSOLE_PORT:-59001}:9001"
volumes:
- minio-data:/data
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:9000/minio/health/ready >/dev/null"]
interval: 5s
timeout: 3s
retries: 20
keycloak:
image: quay.io/keycloak/keycloak:26.2
command: ["start-dev", "--import-realm"]
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: local_admin_only
KC_HOSTNAME: http://localhost:58180
ports:
- "${KEYCLOAK_PORT:-58180}:8080"
volumes:
- ./deploy/keycloak:/opt/keycloak/data/import:ro
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ["--config=/etc/otelcol-contrib/config.yaml"]
volumes:
- ./deploy/otel/collector.yaml:/etc/otelcol-contrib/config.yaml:ro
ports:
- "${OTEL_GRPC_PORT:-54317}:4317"
prometheus:
image: prom/prometheus:latest
command: ["--config.file=/etc/prometheus/prometheus.yml"]
volumes:
- ./deploy/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./deploy/prometheus/rules:/etc/prometheus/rules:ro
ports:
- "${PROMETHEUS_PORT:-59190}:9090"
grafana:
image: grafana/grafana:12.2.0
environment:
GF_AUTH_ANONYMOUS_ENABLED: "true"
GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer
GF_USERS_DEFAULT_THEME: light
volumes:
- ./deploy/grafana/provisioning:/etc/grafana/provisioning:ro
- ./deploy/grafana/dashboards:/var/lib/grafana/dashboards:ro
ports:
- "${GRAFANA_PORT:-59300}:3000"
depends_on:
- prometheus
migrate:
image: migrate/migrate:v4.18.3
volumes:
- ./migrations:/migrations:ro
command:
- -path=/migrations
- -database=postgres://voice:voice_dev_only@postgres:5432/voice_platform?sslmode=disable
- up
depends_on:
postgres:
condition: service_healthy
restart: "no"
seed:
image: pgvector/pgvector:pg16
environment:
PGPASSWORD: voice_dev_only
volumes:
- ./test/fixtures:/fixtures:ro
command: ["psql", "-h", "postgres", "-U", "voice", "-d", "voice_platform", "-v", "ON_ERROR_STOP=1", "-f", "/fixtures/local_seed.sql"]
depends_on:
migrate:
condition: service_completed_successfully
restart: "no"
api:
build:
context: .
dockerfile: deploy/backend.Dockerfile
entrypoint: ["/app/api"]
environment:
<<: *backend-environment
HTTP_ADDR: 0.0.0.0:8080
DEVICE_LOCAL_SECRET: local-simulated-device-secret-32-bytes-minimum
RTC_TOKEN_SIGNING_SECRET: local-simulated-rtc-token-secret-32-bytes-minimum
ports:
- "${API_PORT:-58080}:8080"
depends_on: *backend-dependencies
callbacks:
build:
context: .
dockerfile: deploy/backend.Dockerfile
entrypoint: ["/app/callbacks"]
environment:
<<: *backend-environment
HTTP_ADDR: 0.0.0.0:8081
PROVIDER_CALLBACK_SECRET: local-provider-callback-secret-32-bytes-minimum
RATE_LIMIT_RPS: "1000"
RATE_LIMIT_BURST: "1500"
ports:
- "${CALLBACKS_PORT:-58081}:8081"
depends_on: *backend-dependencies
worker:
build:
context: .
dockerfile: deploy/backend.Dockerfile
entrypoint: ["/app/worker"]
environment:
<<: *backend-environment
HTTP_ADDR: 0.0.0.0:8082
MOCK_PROVIDER_BASE_URL: http://mock-provider:8083
MOCK_PROVIDER_API_KEY: local-mock-provider-key
ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:-http://mock-alert-receiver:8084/webhooks/alerts}
ALERT_WEBHOOK_SECRET: ${ALERT_WEBHOOK_SECRET:-local-alert-webhook-secret-32-bytes-minimum}
ALERT_WEBHOOK_TIMEOUT: ${ALERT_WEBHOOK_TIMEOUT:-5s}
CONTENT_GENERATOR: ${CONTENT_GENERATOR:-local}
CONTENT_SAFETY_PROVIDER: ${CONTENT_SAFETY_PROVIDER:-local}
CONTENT_SAFETY_ENDPOINT: ${CONTENT_SAFETY_ENDPOINT:-}
CONTENT_SAFETY_API_KEY: ${CONTENT_SAFETY_API_KEY:-}
CONTENT_SAFETY_POLICY_REF: ${CONTENT_SAFETY_POLICY_REF:-}
CONTENT_SAFETY_TIMEOUT: ${CONTENT_SAFETY_TIMEOUT:-5s}
ARK_BASE_URL: ${ARK_BASE_URL:-https://ark.cn-beijing.volces.com/api/v3}
ARK_API_KEY: ${ARK_API_KEY:-}
ARK_CONTENT_MODEL: ${ARK_CONTENT_MODEL:-}
ARK_REQUEST_TIMEOUT: ${ARK_REQUEST_TIMEOUT:-30s}
ports:
- "${WORKER_PORT:-58082}:8082"
depends_on:
<<: *backend-dependencies
mock-provider:
condition: service_started
mock-alert-receiver:
condition: service_started
mock-provider:
build:
context: .
dockerfile: deploy/backend.Dockerfile
entrypoint: ["/app/mock-provider"]
environment:
HTTP_ADDR: 0.0.0.0:8083
MOCK_PROVIDER_API_KEY: local-mock-provider-key
MOCK_PROVIDER_CALLBACK_URL: http://callbacks:8081/callbacks/v1/vertc/voice-chat
PROVIDER_CALLBACK_SECRET: local-provider-callback-secret-32-bytes-minimum
MOCK_PROVIDER_START_DELAY: 100ms
MOCK_PROVIDER_DUPLICATE_CALLBACKS: "2"
ports:
- "${MOCK_PROVIDER_PORT:-59200}:8083"
depends_on:
callbacks:
condition: service_started
mock-alert-receiver:
build:
context: .
dockerfile: deploy/backend.Dockerfile
entrypoint: ["/app/mock-alert-receiver"]
environment:
HTTP_ADDR: 0.0.0.0:8084
ALERT_WEBHOOK_SECRET: ${ALERT_WEBHOOK_SECRET:-local-alert-webhook-secret-32-bytes-minimum}
ports:
- "${MOCK_ALERT_RECEIVER_PORT:-59210}:8084"
sim-device:
profiles: [simulation]
build:
context: .
dockerfile: deploy/backend.Dockerfile
entrypoint: ["/app/sim-device"]
environment:
SIM_API_URL: http://api:8080
SIM_DEVICE_ID: 10000000-0000-0000-0000-000000000511
SIM_DEVICE_SECRET: local-simulated-device-secret-32-bytes-minimum
depends_on:
api:
condition: service_started
web:
build:
context: .
dockerfile: deploy/web.Dockerfile
environment:
WEB_OIDC_AUTHORITY: ${WEB_OIDC_AUTHORITY:-http://localhost:58180/realms/voice-local}
WEB_OIDC_CLIENT_ID: ${WEB_OIDC_CLIENT_ID:-voice-platform-web}
ports:
- "${WEB_PORT:-3100}:8080"
depends_on:
api:
condition: service_started
volumes:
postgres-data:
redis-data:
minio-data:
+40
View File
@@ -0,0 +1,40 @@
FROM golang:1.26.6-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY internal ./internal
ARG VERSION=dev
ARG COMMIT=unknown
ARG BUILD_TIME=unknown
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w \
-X data-terminal-cloud/internal/buildinfo.Version=${VERSION} \
-X data-terminal-cloud/internal/buildinfo.Commit=${COMMIT} \
-X data-terminal-cloud/internal/buildinfo.BuildTime=${BUILD_TIME}" -o /out/api ./cmd/api && \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w \
-X data-terminal-cloud/internal/buildinfo.Version=${VERSION} \
-X data-terminal-cloud/internal/buildinfo.Commit=${COMMIT} \
-X data-terminal-cloud/internal/buildinfo.BuildTime=${BUILD_TIME}" -o /out/callbacks ./cmd/callbacks && \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w \
-X data-terminal-cloud/internal/buildinfo.Version=${VERSION} \
-X data-terminal-cloud/internal/buildinfo.Commit=${COMMIT} \
-X data-terminal-cloud/internal/buildinfo.BuildTime=${BUILD_TIME}" -o /out/worker ./cmd/worker && \
CGO_ENABLED=0 go build -trimpath -o /out/sim-device ./cmd/sim-device && \
CGO_ENABLED=0 go build -trimpath -o /out/firmware-conformance ./cmd/firmware-conformance && \
CGO_ENABLED=0 go build -trimpath -o /out/alert-receiver-conformance ./cmd/alert-receiver-conformance && \
CGO_ENABLED=0 go build -trimpath -o /out/kms-rotation-conformance ./cmd/kms-rotation-conformance && \
CGO_ENABLED=0 go build -trimpath -o /out/recovery-conformance ./cmd/recovery-conformance && \
CGO_ENABLED=0 go build -trimpath -o /out/infrastructure-conformance ./cmd/infrastructure-conformance && \
CGO_ENABLED=0 go build -trimpath -o /out/safari-conformance ./cmd/safari-conformance && \
CGO_ENABLED=0 go build -trimpath -o /out/rtc-conformance ./cmd/rtc-conformance && \
CGO_ENABLED=0 go build -trimpath -o /out/agent-models-conformance ./cmd/agent-models-conformance && \
CGO_ENABLED=0 go build -trimpath -o /out/ci-supply-chain-conformance ./cmd/ci-supply-chain-conformance && \
CGO_ENABLED=0 go build -trimpath -o /out/mock-provider ./cmd/mock-provider && \
CGO_ENABLED=0 go build -trimpath -o /out/mock-alert-receiver ./cmd/mock-alert-receiver
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/ /app/
EXPOSE 8080 8081 8082 8083 8084
USER nonroot:nonroot
ENTRYPOINT ["/app/api"]
+13
View File
@@ -0,0 +1,13 @@
{
"uid": "voice-business", "title": "业务仪表盘", "schemaVersion": 41, "version": 1,
"refresh": "10s", "time": {"from": "now-6h", "to": "now"},
"tags": ["voice-platform", "business"],
"panels": [
{"id": 1, "type": "stat", "title": "进行中通话", "gridPos": {"x":0,"y":0,"w":6,"h":5}, "datasource":{"uid":"prometheus","type":"prometheus"}, "targets":[{"refId":"A","expr":"sum(voice_platform_conversations{status=~\"PREPARING|ROOM_ISSUED|STARTING_AGENT|ACTIVE|STOPPING\"})"}]},
{"id": 2, "type": "stat", "title": "隐私请求部分失败", "gridPos": {"x":6,"y":0,"w":6,"h":5}, "datasource":{"uid":"prometheus","type":"prometheus"}, "targets":[{"refId":"A","expr":"voice_platform_privacy_requests{status=\"PARTIAL_FAILED\"}"}]},
{"id": 3, "type": "stat", "title": "固件失败设备", "gridPos": {"x":12,"y":0,"w":6,"h":5}, "datasource":{"uid":"prometheus","type":"prometheus"}, "targets":[{"refId":"A","expr":"voice_platform_firmware_assignments{status=\"FAILED\"}"}]},
{"id": 4, "type": "stat", "title": "活动业务告警", "gridPos": {"x":18,"y":0,"w":6,"h":5}, "datasource":{"uid":"prometheus","type":"prometheus"}, "targets":[{"refId":"A","expr":"voice_platform_alert_incidents{category=\"BUSINESS\"}"}]},
{"id": 5, "type": "timeseries", "title": "业务接口请求速率", "gridPos": {"x":0,"y":5,"w":12,"h":8}, "datasource":{"uid":"prometheus","type":"prometheus"}, "targets":[{"refId":"A","expr":"sum by (route) (rate(voice_platform_http_requests_total{service=\"api\",route=~\"/api/v1/.*\"}[5m]))","legendFormat":"{{route}}"}]},
{"id": 6, "type": "timeseries", "title": "通话状态分布", "gridPos": {"x":12,"y":5,"w":12,"h":8}, "datasource":{"uid":"prometheus","type":"prometheus"}, "targets":[{"refId":"A","expr":"voice_platform_conversations","legendFormat":"{{status}}"}]}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"uid": "voice-security", "title": "安全仪表盘", "schemaVersion": 41, "version": 1,
"refresh": "30s", "time": {"from": "now-24h", "to": "now"},
"tags": ["voice-platform", "security"],
"panels": [
{"id":1,"type":"stat","title":"7天内到期证书","gridPos":{"x":0,"y":0,"w":6,"h":5},"datasource":{"uid":"prometheus","type":"prometheus"},"targets":[{"refId":"A","expr":"voice_platform_security_certificates_expiring{window=\"7d\"}"}]},
{"id":2,"type":"stat","title":"30天内到期证书","gridPos":{"x":6,"y":0,"w":6,"h":5},"datasource":{"uid":"prometheus","type":"prometheus"},"targets":[{"refId":"A","expr":"voice_platform_security_certificates_expiring{window=\"30d\"}"}]},
{"id":3,"type":"stat","title":"活动安全告警","gridPos":{"x":12,"y":0,"w":6,"h":5},"datasource":{"uid":"prometheus","type":"prometheus"},"targets":[{"refId":"A","expr":"voice_platform_alert_incidents{category=\"SECURITY\"}"}]},
{"id":4,"type":"stat","title":"当前限流拒绝速率","gridPos":{"x":18,"y":0,"w":6,"h":5},"datasource":{"uid":"prometheus","type":"prometheus"},"targets":[{"refId":"A","expr":"sum(rate(voice_platform_http_requests_total{status=\"429\"}[5m]))"}]},
{"id":5,"type":"timeseries","title":"身份与权限拒绝","gridPos":{"x":0,"y":5,"w":12,"h":8},"datasource":{"uid":"prometheus","type":"prometheus"},"targets":[{"refId":"A","expr":"sum by (status,route) (rate(voice_platform_http_requests_total{status=~\"401|403\"}[5m]))","legendFormat":"{{status}} {{route}}"}]},
{"id":6,"type":"timeseries","title":"设备与供应商入口异常响应","gridPos":{"x":12,"y":5,"w":12,"h":8},"datasource":{"uid":"prometheus","type":"prometheus"},"targets":[{"refId":"A","expr":"sum by (status,route) (rate(voice_platform_http_requests_total{route=~\"/device/.*|/callbacks/.*\",status!~\"2..\"}[5m]))","legendFormat":"{{status}} {{route}}"}]}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"uid": "voice-technical", "title": "技术仪表盘", "schemaVersion": 41, "version": 1,
"refresh": "10s", "time": {"from": "now-1h", "to": "now"},
"tags": ["voice-platform", "technical"],
"panels": [
{"id":1,"type":"stat","title":"依赖可用性","gridPos":{"x":0,"y":0,"w":6,"h":5},"datasource":{"uid":"prometheus","type":"prometheus"},"targets":[{"refId":"A","expr":"min(voice_platform_dependency_up)"}]},
{"id":2,"type":"stat","title":"可靠任务死信","gridPos":{"x":6,"y":0,"w":6,"h":5},"datasource":{"uid":"prometheus","type":"prometheus"},"targets":[{"refId":"A","expr":"voice_platform_outbox_events{status=\"dead\"}"}]},
{"id":3,"type":"stat","title":"供应商通知积压","gridPos":{"x":12,"y":0,"w":6,"h":5},"datasource":{"uid":"prometheus","type":"prometheus"},"targets":[{"refId":"A","expr":"voice_platform_provider_notification_backlog"}]},
{"id":4,"type":"stat","title":"活动技术告警","gridPos":{"x":18,"y":0,"w":6,"h":5},"datasource":{"uid":"prometheus","type":"prometheus"},"targets":[{"refId":"A","expr":"voice_platform_alert_incidents{category=\"TECHNICAL\"}"}]},
{"id":5,"type":"timeseries","title":"HTTP P95 延迟","gridPos":{"x":0,"y":5,"w":12,"h":8},"datasource":{"uid":"prometheus","type":"prometheus"},"targets":[{"refId":"A","expr":"histogram_quantile(0.95, sum by (le,service) (rate(voice_platform_http_request_duration_seconds_bucket[5m])))","legendFormat":"{{service}}"}]},
{"id":6,"type":"timeseries","title":"HTTP 5xx 速率","gridPos":{"x":12,"y":5,"w":12,"h":8},"datasource":{"uid":"prometheus","type":"prometheus"},"targets":[{"refId":"A","expr":"sum by (service) (rate(voice_platform_http_requests_total{status=~\"5..\"}[5m]))","legendFormat":"{{service}}"}]},
{"id":7,"type":"timeseries","title":"待处理与执行中可靠任务","gridPos":{"x":0,"y":13,"w":24,"h":8},"datasource":{"uid":"prometheus","type":"prometheus"},"targets":[{"refId":"A","expr":"voice_platform_outbox_events{status=~\"pending|processing\"}","legendFormat":"{{status}}"}]}
]
}
@@ -0,0 +1,10 @@
apiVersion: 1
providers:
- name: data-terminal-cloud
orgId: 1
folder: 数据采集终端云平台
type: file
disableDeletion: true
updateIntervalSeconds: 30
options:
path: /var/lib/grafana/dashboards
@@ -0,0 +1,9 @@
apiVersion: 1
datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
+68
View File
@@ -0,0 +1,68 @@
{
"realm": "voice-local",
"enabled": true,
"displayName": "长者语音平台本地身份服务",
"sslRequired": "none",
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": false,
"clients": [
{
"clientId": "voice-platform-web",
"name": "本地Web客户端",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true,
"redirectUris": ["http://localhost:3100/*", "http://localhost:5173/*"],
"webOrigins": ["http://localhost:3100", "http://localhost:5173"],
"protocol": "openid-connect",
"protocolMappers": [
{
"name": "voice-platform-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "voice-platform-web",
"access.token.claim": "true",
"id.token.claim": "false"
}
}
]
}
],
"users": [
{
"id": "00000000-0000-0000-0000-000000000101",
"username": "owner",
"firstName": "本地",
"lastName": "所有者",
"email": "owner@example.invalid",
"emailVerified": true,
"enabled": true,
"credentials": [{"type": "password", "value": "LocalOnly123!", "temporary": false}]
},
{
"id": "00000000-0000-0000-0000-000000000102",
"username": "editor",
"firstName": "本地",
"lastName": "编辑者",
"email": "editor@example.invalid",
"emailVerified": true,
"enabled": true,
"credentials": [{"type": "password", "value": "LocalOnly123!", "temporary": false}]
},
{
"id": "00000000-0000-0000-0000-000000000103",
"username": "outsider",
"firstName": "隔离租户",
"lastName": "查看者",
"email": "outsider@example.invalid",
"emailVerified": true,
"enabled": true,
"credentials": [{"type": "password", "value": "LocalOnly123!", "temporary": false}]
}
]
}
+135
View File
@@ -0,0 +1,135 @@
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: api
namespace: voice-platform
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: voice-platform-api
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: callbacks
namespace: voice-platform
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: voice-platform-callbacks
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: worker
namespace: voice-platform
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: voice-platform-worker
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: web
namespace: voice-platform
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: voice-platform-web
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api
namespace: voice-platform
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api
minReplicas: 2
maxReplicas: 10
behavior:
scaleDown:
stabilizationWindowSeconds: 300
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: callbacks
namespace: voice-platform
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: callbacks
minReplicas: 2
maxReplicas: 8
behavior:
scaleDown:
stabilizationWindowSeconds: 300
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: worker
namespace: voice-platform
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: worker
minReplicas: 2
maxReplicas: 8
behavior:
scaleDown:
stabilizationWindowSeconds: 300
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: web
namespace: voice-platform
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: web
minReplicas: 2
maxReplicas: 6
behavior:
scaleDown:
stabilizationWindowSeconds: 300
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
+73
View File
@@ -0,0 +1,73 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: voice-platform-config
namespace: voice-platform
data:
APP_ENV: stage
LOG_LEVEL: info
REDIS_ADDR: redis.example.invalid:6379
REDIS_DB: "0"
AUTH_ISSUER_URL: https://identity.example.invalid/realms/voice
AUTH_JWKS_URL: https://identity.example.invalid/realms/voice/protocol/openid-connect/certs
AUTH_AUDIENCE: voice-platform-web
OTEL_EXPORTER_OTLP_ENDPOINT: otel-collector.monitoring.svc.cluster.local:4317
DEPENDENCY_TIMEOUT: 3s
EXTERNAL_PROBE_INTERVAL: 30s
SHUTDOWN_TIMEOUT: 20s
RATE_LIMIT_RPS: "50"
RATE_LIMIT_BURST: "100"
RATE_LIMIT_USER_RPS: "20"
RATE_LIMIT_USER_BURST: "40"
RATE_LIMIT_TENANT_RPS: "200"
RATE_LIMIT_TENANT_BURST: "400"
RATE_LIMIT_DEVICE_RPS: "10"
RATE_LIMIT_DEVICE_BURST: "20"
PROVIDER_START_RPS: "20"
PROVIDER_START_BURST: "40"
PROVIDER_MAX_ACTIVE_TASKS: "100"
DEVICE_CLOCK_SKEW: 5m
RTC_APP_ID: "000000000000000000000000"
RTC_TOKEN_ISSUER: volcengine
RTC_TOKEN_TTL: 5m
CONVERSATION_ALLOCATION_TTL: 30s
HEARTBEAT_INTERVAL_SECONDS: "15"
PROVIDER_CALLBACK_CLOCK_SKEW: 5m
PROVIDER_REQUEST_TIMEOUT: 5s
PROVIDER_FAILURE_THRESHOLD: "3"
PROVIDER_CIRCUIT_COOLDOWN: 1m
RECOVERY_SCAN_INTERVAL: 5s
RECOVERY_STARTING_AFTER: 20s
RECOVERY_STOPPING_AFTER: 20s
OBJECT_STORE_ENDPOINT: object-storage.example.invalid:443
OBJECT_STORE_PUBLIC_ENDPOINT: downloads.example.invalid:443
OBJECT_STORE_EXPORT_BUCKET: private-exports
OBJECT_STORE_REGION: cn-beijing
OBJECT_STORE_SECURE: "true"
EXPORT_DOWNLOAD_TTL: 10m
EXPORT_OBJECT_TTL: 24h
VOICE_PROVIDER: volcengine
VOLC_RTC_BASE_URL: https://rtc.volcengineapi.com
VOLC_RTC_REGION: cn-north-1
VOLC_RTC_API_VERSION: "2024-12-01"
CONTENT_GENERATOR: ark
CONTENT_SAFETY_PROVIDER: managed_http
CONTENT_SAFETY_ENDPOINT: https://content-safety.example.invalid/v1/classify
CONTENT_SAFETY_POLICY_REF: replace-with-approved-content-safety-policy
CONTENT_SAFETY_TIMEOUT: 5s
EMBEDDING_PROVIDER: ark
ARK_BASE_URL: https://ark.cn-beijing.volces.com/api/v3
ARK_CONTENT_MODEL: replace-with-ark-content-endpoint-id
ARK_EMBEDDING_MODEL: replace-with-ark-embedding-endpoint-id
ARK_REQUEST_TIMEOUT: 30s
WORKER_POLL_INTERVAL: 2s
ALERT_WEBHOOK_TIMEOUT: 5s
---
apiVersion: v1
kind: ConfigMap
metadata:
name: voice-platform-web-config
namespace: voice-platform
data:
WEB_OIDC_AUTHORITY: https://identity.example.invalid/realms/voice
WEB_OIDC_CLIENT_ID: voice-platform-web
+452
View File
@@ -0,0 +1,452 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: voice-platform
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app.kubernetes.io/name: voice-platform-api
template:
metadata:
annotations:
prometheus.io/scrape: "true"
prometheus.io/path: /metrics
prometheus.io/port: "8080"
labels:
app.kubernetes.io/name: voice-platform-api
app.kubernetes.io/component: api
spec:
serviceAccountName: voice-platform-api
automountServiceAccountToken: false
terminationGracePeriodSeconds: 30
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: voice-platform-api
containers:
- name: api
image: registry.example.invalid/voice-platform/backend:0.1.0
imagePullPolicy: IfNotPresent
command: ["/app/api"]
envFrom:
- configMapRef:
name: voice-platform-config
env:
- name: HTTP_ADDR
value: 0.0.0.0:8080
- name: DATABASE_URL_FILE
value: /var/run/secrets/voice-platform/common/database-url
- name: REDIS_PASSWORD_FILE
value: /var/run/secrets/voice-platform/common/redis-password
- name: DEVICE_SECRET_DIRECTORY
value: /var/run/secrets/voice-platform/devices
- name: RTC_APP_KEY_FILE
value: /var/run/secrets/voice-platform/api/rtc-app-key
- name: CURSOR_SIGNING_SECRET_FILE
value: /var/run/secrets/voice-platform/api/cursor-signing-secret
- name: SHARE_TOKEN_SIGNING_SECRET_FILE
value: /var/run/secrets/voice-platform/api/share-token-signing-secret
- name: OBJECT_STORE_ACCESS_KEY_FILE
value: /var/run/secrets/voice-platform/model-storage/object-store-access-key
- name: OBJECT_STORE_SECRET_KEY_FILE
value: /var/run/secrets/voice-platform/model-storage/object-store-secret-key
- name: ARK_API_KEY_FILE
value: /var/run/secrets/voice-platform/model-storage/ark-api-key
ports:
- name: http
containerPort: 8080
startupProbe:
httpGet:
path: /health/live
port: http
periodSeconds: 2
failureThreshold: 30
readinessProbe:
httpGet:
path: /health/ready
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
httpGet:
path: /health/live
port: http
periodSeconds: 15
timeoutSeconds: 2
failureThreshold: 3
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: "1"
memory: 768Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: common-secrets
mountPath: /var/run/secrets/voice-platform/common
readOnly: true
- name: api-secrets
mountPath: /var/run/secrets/voice-platform/api
readOnly: true
- name: model-storage-secrets
mountPath: /var/run/secrets/voice-platform/model-storage
readOnly: true
- name: device-secrets
mountPath: /var/run/secrets/voice-platform/devices
readOnly: true
volumes:
- name: common-secrets
secret:
secretName: voice-platform-common-secrets
- name: api-secrets
secret:
secretName: voice-platform-api-secrets
- name: model-storage-secrets
secret:
secretName: voice-platform-model-storage-secrets
- name: device-secrets
secret:
secretName: voice-platform-device-secrets
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: callbacks
namespace: voice-platform
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app.kubernetes.io/name: voice-platform-callbacks
template:
metadata:
annotations:
prometheus.io/scrape: "true"
prometheus.io/path: /metrics
prometheus.io/port: "8081"
labels:
app.kubernetes.io/name: voice-platform-callbacks
app.kubernetes.io/component: callbacks
spec:
serviceAccountName: voice-platform-callbacks
automountServiceAccountToken: false
terminationGracePeriodSeconds: 30
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: voice-platform-callbacks
containers:
- name: callbacks
image: registry.example.invalid/voice-platform/backend:0.1.0
imagePullPolicy: IfNotPresent
command: ["/app/callbacks"]
envFrom:
- configMapRef:
name: voice-platform-config
env:
- name: HTTP_ADDR
value: 0.0.0.0:8081
- name: DATABASE_URL_FILE
value: /var/run/secrets/voice-platform/common/database-url
- name: REDIS_PASSWORD_FILE
value: /var/run/secrets/voice-platform/common/redis-password
- name: PROVIDER_CALLBACK_SECRET_FILE
value: /var/run/secrets/voice-platform/callbacks/provider-callback-secret
ports:
- name: http
containerPort: 8081
startupProbe:
httpGet:
path: /health/live
port: http
periodSeconds: 2
failureThreshold: 30
readinessProbe:
httpGet:
path: /health/ready
port: http
periodSeconds: 5
timeoutSeconds: 2
livenessProbe:
httpGet:
path: /health/live
port: http
periodSeconds: 15
timeoutSeconds: 2
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 384Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: common-secrets
mountPath: /var/run/secrets/voice-platform/common
readOnly: true
- name: callbacks-secrets
mountPath: /var/run/secrets/voice-platform/callbacks
readOnly: true
volumes:
- name: common-secrets
secret:
secretName: voice-platform-common-secrets
- name: callbacks-secrets
secret:
secretName: voice-platform-callbacks-secrets
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: worker
namespace: voice-platform
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app.kubernetes.io/name: voice-platform-worker
template:
metadata:
annotations:
prometheus.io/scrape: "true"
prometheus.io/path: /metrics
prometheus.io/port: "8082"
labels:
app.kubernetes.io/name: voice-platform-worker
app.kubernetes.io/component: worker
spec:
serviceAccountName: voice-platform-worker
automountServiceAccountToken: false
terminationGracePeriodSeconds: 30
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: voice-platform-worker
containers:
- name: worker
image: registry.example.invalid/voice-platform/backend:0.1.0
imagePullPolicy: IfNotPresent
command: ["/app/worker"]
envFrom:
- configMapRef:
name: voice-platform-config
env:
- name: HTTP_ADDR
value: 0.0.0.0:8082
- name: DATABASE_URL_FILE
value: /var/run/secrets/voice-platform/common/database-url
- name: REDIS_PASSWORD_FILE
value: /var/run/secrets/voice-platform/common/redis-password
- name: VOLC_ACCESS_KEY_ID_FILE
value: /var/run/secrets/voice-platform/worker/volc-access-key-id
- name: VOLC_SECRET_ACCESS_KEY_FILE
value: /var/run/secrets/voice-platform/worker/volc-secret-access-key
- name: VOLC_VOICE_CHAT_TEMPLATE_FILE
value: /var/run/secrets/voice-platform/worker/voice-chat-template.json
- name: OBJECT_STORE_ACCESS_KEY_FILE
value: /var/run/secrets/voice-platform/model-storage/object-store-access-key
- name: OBJECT_STORE_SECRET_KEY_FILE
value: /var/run/secrets/voice-platform/model-storage/object-store-secret-key
- name: ARK_API_KEY_FILE
value: /var/run/secrets/voice-platform/model-storage/ark-api-key
- name: CONTENT_SAFETY_API_KEY_FILE
value: /var/run/secrets/voice-platform/model-storage/content-safety-api-key
ports:
- name: http
containerPort: 8082
startupProbe:
httpGet:
path: /health/live
port: http
periodSeconds: 2
failureThreshold: 30
readinessProbe:
httpGet:
path: /health/ready
port: http
periodSeconds: 5
timeoutSeconds: 2
livenessProbe:
httpGet:
path: /health/live
port: http
periodSeconds: 15
timeoutSeconds: 2
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "1"
memory: 768Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: common-secrets
mountPath: /var/run/secrets/voice-platform/common
readOnly: true
- name: worker-secrets
mountPath: /var/run/secrets/voice-platform/worker
readOnly: true
- name: model-storage-secrets
mountPath: /var/run/secrets/voice-platform/model-storage
readOnly: true
volumes:
- name: common-secrets
secret:
secretName: voice-platform-common-secrets
- name: worker-secrets
secret:
secretName: voice-platform-worker-secrets
- name: model-storage-secrets
secret:
secretName: voice-platform-model-storage-secrets
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: voice-platform
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app.kubernetes.io/name: voice-platform-web
template:
metadata:
labels:
app.kubernetes.io/name: voice-platform-web
app.kubernetes.io/component: web
spec:
serviceAccountName: voice-platform-web
automountServiceAccountToken: false
terminationGracePeriodSeconds: 30
securityContext:
runAsNonRoot: true
runAsUser: 101
runAsGroup: 101
fsGroup: 101
seccompProfile:
type: RuntimeDefault
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: voice-platform-web
containers:
- name: web
image: registry.example.invalid/voice-platform/web:0.1.0
imagePullPolicy: IfNotPresent
command: ["/bin/sh", "-c"]
args: ["/docker-entrypoint.d/40-runtime-config.sh && exec nginx -g 'daemon off;'"]
envFrom:
- configMapRef:
name: voice-platform-web-config
ports:
- name: http
containerPort: 8080
startupProbe:
httpGet:
path: /runtime-config.js
port: http
periodSeconds: 2
failureThreshold: 15
readinessProbe:
httpGet:
path: /runtime-config.js
port: http
periodSeconds: 5
timeoutSeconds: 2
livenessProbe:
httpGet:
path: /runtime-config.js
port: http
periodSeconds: 15
timeoutSeconds: 2
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 250m
memory: 192Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir:
sizeLimit: 32Mi
+55
View File
@@ -0,0 +1,55 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: voice-platform
namespace: voice-platform
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: 10m
nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
nginx.ingress.kubernetes.io/proxy-send-timeout: "60"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- voice-stage.example.invalid
secretName: voice-platform-tls
rules:
- host: voice-stage.example.invalid
http:
paths:
- path: /callbacks
pathType: Prefix
backend:
service:
name: callbacks
port:
number: 8081
- path: /device
pathType: Prefix
backend:
service:
name: api
port:
number: 8080
- path: /api
pathType: Prefix
backend:
service:
name: api
port:
number: 8080
- path: /health
pathType: Prefix
backend:
service:
name: api
port:
number: 8080
- path: /
pathType: Prefix
backend:
service:
name: web
port:
number: 8080
+16
View File
@@ -0,0 +1,16 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- configmap.yaml
- serviceaccounts.yaml
- deployments.yaml
- services.yaml
- ingress.yaml
- availability.yaml
- network-policies.yaml
labels:
- pairs:
app.kubernetes.io/part-of: voice-platform
includeSelectors: true
includeTemplates: true
+9
View File
@@ -0,0 +1,9 @@
apiVersion: v1
kind: Namespace
metadata:
name: voice-platform
labels:
app.kubernetes.io/part-of: voice-platform
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restricted
@@ -0,0 +1,59 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: voice-platform
spec:
podSelector: {}
policyTypes:
- Ingress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-controller
namespace: voice-platform
spec:
podSelector:
matchExpressions:
- key: app.kubernetes.io/component
operator: In
values: [api, callbacks, web]
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
ports:
- protocol: TCP
port: 8080
- protocol: TCP
port: 8081
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-monitoring
namespace: voice-platform
spec:
podSelector:
matchExpressions:
- key: app.kubernetes.io/component
operator: In
values: [api, callbacks, worker]
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
ports:
- protocol: TCP
port: 8080
- protocol: TCP
port: 8081
- protocol: TCP
port: 8082
@@ -0,0 +1,27 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: voice-platform-api
namespace: voice-platform
automountServiceAccountToken: false
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: voice-platform-callbacks
namespace: voice-platform
automountServiceAccountToken: false
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: voice-platform-worker
namespace: voice-platform
automountServiceAccountToken: false
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: voice-platform-web
namespace: voice-platform
automountServiceAccountToken: false
+59
View File
@@ -0,0 +1,59 @@
apiVersion: v1
kind: Service
metadata:
name: api
namespace: voice-platform
labels:
app.kubernetes.io/name: voice-platform-api
spec:
selector:
app.kubernetes.io/name: voice-platform-api
ports:
- name: http
port: 8080
targetPort: http
---
apiVersion: v1
kind: Service
metadata:
name: callbacks
namespace: voice-platform
labels:
app.kubernetes.io/name: voice-platform-callbacks
spec:
selector:
app.kubernetes.io/name: voice-platform-callbacks
ports:
- name: http
port: 8081
targetPort: http
---
apiVersion: v1
kind: Service
metadata:
name: worker
namespace: voice-platform
labels:
app.kubernetes.io/name: voice-platform-worker
spec:
selector:
app.kubernetes.io/name: voice-platform-worker
ports:
- name: http
port: 8082
targetPort: http
---
apiVersion: v1
kind: Service
metadata:
name: web
namespace: voice-platform
labels:
app.kubernetes.io/name: voice-platform-web
spec:
selector:
app.kubernetes.io/name: voice-platform-web
ports:
- name: http
port: 8080
targetPort: http
@@ -0,0 +1,62 @@
# Copy this file into an environment-owned directory and replace every provider
# and parameters block according to the installed Secrets Store CSI provider.
# Every provider object must use the exact mounted filename documented below.
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: voice-platform-common
namespace: voice-platform
spec:
provider: replace-with-installed-provider
parameters:
objects: replace-with-provider-specific-database-url-and-redis-password
---
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: voice-platform-api
namespace: voice-platform
spec:
provider: replace-with-installed-provider
parameters:
objects: replace-with-provider-specific-rtc-app-key-cursor-and-share-secrets
---
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: voice-platform-callbacks
namespace: voice-platform
spec:
provider: replace-with-installed-provider
parameters:
objects: replace-with-provider-specific-callback-secret
---
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: voice-platform-worker
namespace: voice-platform
spec:
provider: replace-with-installed-provider
parameters:
objects: replace-with-provider-specific-volc-credentials-and-voice-template
---
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: voice-platform-model-storage
namespace: voice-platform
spec:
provider: replace-with-installed-provider
parameters:
objects: replace-with-provider-specific-object-store-ark-and-content-safety-credentials
---
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: voice-platform-devices
namespace: voice-platform
spec:
provider: replace-with-installed-provider
parameters:
objects: replace-with-provider-specific-per-device-secrets
@@ -0,0 +1,35 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../migration
patches:
- target:
group: batch
version: v1
kind: Job
name: voice-platform-migrate
patch: |-
- op: remove
path: /spec/template/spec/containers/0/env
- op: add
path: /spec/template/spec/containers/0/command
value: ["/bin/sh", "-ec"]
- op: replace
path: /spec/template/spec/containers/0/args
value:
- 'database_url=""; IFS= read -r database_url < /var/run/secrets/voice-platform/common/database-url || [ -n "$database_url" ]; exec migrate -path=/migrations -database "$database_url" up'
- op: add
path: /spec/template/spec/containers/0/volumeMounts
value:
- name: common-secrets
mountPath: /var/run/secrets/voice-platform/common
readOnly: true
- op: add
path: /spec/template/spec/volumes
value:
- name: common-secrets
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: voice-platform-common
+52
View File
@@ -0,0 +1,52 @@
apiVersion: batch/v1
kind: Job
metadata:
name: voice-platform-migrate
namespace: voice-platform
labels:
app.kubernetes.io/name: voice-platform-migrate
app.kubernetes.io/part-of: voice-platform
spec:
backoffLimit: 2
activeDeadlineSeconds: 600
ttlSecondsAfterFinished: 600
template:
metadata:
labels:
app.kubernetes.io/name: voice-platform-migrate
app.kubernetes.io/part-of: voice-platform
spec:
restartPolicy: Never
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: migrate
image: registry.example.invalid/voice-platform/migrate:0.1.0
imagePullPolicy: IfNotPresent
args:
- -path=/migrations
- -database=$(DATABASE_URL)
- up
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: voice-platform-common-secrets
key: database-url
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 250m
memory: 192Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- job.yaml
images:
- name: registry.example.invalid/voice-platform/migrate
newName: registry.example.invalid/voice-platform/migrate
newTag: production
@@ -0,0 +1,86 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: voice-platform
spec:
template:
spec:
volumes:
- $patch: replace
- name: common-secrets
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: voice-platform-common
- name: api-secrets
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: voice-platform-api
- name: model-storage-secrets
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: voice-platform-model-storage
- name: device-secrets
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: voice-platform-devices
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: callbacks
namespace: voice-platform
spec:
template:
spec:
volumes:
- $patch: replace
- name: common-secrets
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: voice-platform-common
- name: callbacks-secrets
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: voice-platform-callbacks
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: worker
namespace: voice-platform
spec:
template:
spec:
volumes:
- $patch: replace
- name: common-secrets
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: voice-platform-common
- name: worker-secrets
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: voice-platform-worker
- name: model-storage-secrets
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: voice-platform-model-storage
@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../production
patches:
- path: csi-volumes.yaml
@@ -0,0 +1,7 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: voice-platform-config
namespace: voice-platform
data:
APP_ENV: prod
@@ -0,0 +1,49 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: voice-platform
namespace: voice-platform
spec:
tls:
- hosts:
- voice.example.invalid
secretName: voice-platform-tls
rules:
- host: voice.example.invalid
http:
paths:
- path: /callbacks
pathType: Prefix
backend:
service:
name: callbacks
port:
number: 8081
- path: /device
pathType: Prefix
backend:
service:
name: api
port:
number: 8080
- path: /api
pathType: Prefix
backend:
service:
name: api
port:
number: 8080
- path: /health
pathType: Prefix
backend:
service:
name: api
port:
number: 8080
- path: /
pathType: Prefix
backend:
service:
name: web
port:
number: 8080
@@ -0,0 +1,14 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
images:
- name: registry.example.invalid/voice-platform/backend
newName: registry.example.invalid/voice-platform/backend
newTag: production
- name: registry.example.invalid/voice-platform/web
newName: registry.example.invalid/voice-platform/web
newTag: production
patches:
- path: config-patch.yaml
- path: ingress-patch.yaml
@@ -0,0 +1,7 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: voice-platform-config
namespace: voice-platform
data:
APP_ENV: stage
@@ -0,0 +1,13 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
images:
- name: registry.example.invalid/voice-platform/backend
newName: registry.example.invalid/voice-platform/backend
newTag: staging
- name: registry.example.invalid/voice-platform/web
newName: registry.example.invalid/voice-platform/web
newTag: staging
patches:
- path: config-patch.yaml
+3
View File
@@ -0,0 +1,3 @@
FROM migrate/migrate:v4.18.3
COPY migrations /migrations
+19
View File
@@ -0,0 +1,19 @@
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
processors:
batch: {}
exporters:
debug:
verbosity: basic
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [debug]
+12
View File
@@ -0,0 +1,12 @@
global:
scrape_interval: 10s
evaluation_interval: 10s
rule_files:
- /etc/prometheus/rules/*.yml
scrape_configs:
- job_name: voice-platform
metrics_path: /metrics
static_configs:
- targets: [api:8080, callbacks:8081, worker:8082]
@@ -0,0 +1,96 @@
groups:
- name: data-terminal-cloud-availability
rules:
- alert: PlatformServiceDown
expr: up{job="voice-platform"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "Platform service is unavailable"
description: "Prometheus has failed to scrape {{ $labels.instance }} for more than two minutes."
- alert: PlatformHighHTTP5xxRate
expr: |
(
sum by (service) (rate(voice_platform_http_requests_total{status=~"5.."}[5m]))
/
clamp_min(sum by (service) (rate(voice_platform_http_requests_total[5m])), 0.01)
) > 0.05
and
sum by (service) (rate(voice_platform_http_requests_total[5m])) > 0.1
for: 5m
labels:
severity: critical
annotations:
summary: "HTTP 5xx rate exceeds 5%"
description: "{{ $labels.service }} has sustained a 5xx rate above 5%."
- alert: PlatformHighP95Latency
expr: |
histogram_quantile(
0.95,
sum by (service, route, le) (rate(voice_platform_http_request_duration_seconds_bucket[5m]))
) > 1.5
for: 10m
labels:
severity: warning
annotations:
summary: "HTTP p95 latency exceeds 1.5 seconds"
description: "{{ $labels.service }} route {{ $labels.route }} has sustained high latency."
- alert: PlatformExternalDependencyUnavailable
expr: voice_platform_dependency_up{dependency=~"oidc_jwks|ark_tls|volc_rtc_tls|content_safety_tls"} == 0
for: 5m
labels:
severity: critical
annotations:
summary: "External platform dependency is unavailable"
description: "{{ $labels.service }} cannot reach or validate {{ $labels.dependency }} for more than five minutes."
- name: data-terminal-cloud-workflows
rules:
- alert: OutboxDeadLetterPresent
expr: voice_platform_outbox_events{status="dead"} > 0
for: 2m
labels:
severity: critical
annotations:
summary: "Outbox dead-letter events require intervention"
description: "At least one reliable background task exhausted its retry budget."
- alert: OutboxPendingBacklog
expr: voice_platform_outbox_events{status="pending"} > 100
for: 10m
labels:
severity: warning
annotations:
summary: "Outbox pending backlog is growing"
description: "More than 100 events have remained pending for ten minutes."
- alert: PrivacyDeletionPartialFailure
expr: voice_platform_privacy_requests{status="PARTIAL_FAILED"} > 0
for: 2m
labels:
severity: critical
annotations:
summary: "A privacy request has partially failed"
description: "A subject-level privacy workflow requires retry or manual reconciliation."
- alert: FirmwareAssignmentFailure
expr: voice_platform_firmware_assignments{status="FAILED"} > 0
for: 2m
labels:
severity: warning
annotations:
summary: "A firmware assignment failed"
description: "Pause the rollout, inspect the device result, and select a verified rollback release if necessary."
- alert: FirmwareRolloutStalled
expr: voice_platform_firmware_stalled_rollouts > 0
for: 5m
labels:
severity: warning
annotations:
summary: "A firmware rollout is stalled"
description: "A running rollout has not changed for at least 15 minutes."
@@ -0,0 +1,38 @@
{
"schema_version": "voice-chat-template-bundle-v1",
"templates": {
"s2s_pure": {
"AgentConfig": {
"TargetUserId": [],
"UserId": "",
"WelcomeMessage": "您好,我会陪您聊聊天。",
"EnableConversationStateCallback": false
},
"Config": {
"TemplateBodyMustBeReplaced": "replace-with-official-s2s-pure-Config-object"
}
},
"s2s_hybrid": {
"AgentConfig": {
"TargetUserId": [],
"UserId": "",
"WelcomeMessage": "您好,我会陪您聊聊天。",
"EnableConversationStateCallback": false
},
"Config": {
"TemplateBodyMustBeReplaced": "replace-with-official-s2s-hybrid-Config-object"
}
},
"cascade": {
"AgentConfig": {
"TargetUserId": [],
"UserId": "",
"WelcomeMessage": "您好,我会陪您聊聊天。",
"EnableConversationStateCallback": false
},
"Config": {
"TemplateBodyMustBeReplaced": "replace-with-official-cascade-Config-object"
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
set -eu
json_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
oidc_authority=$(json_escape "${WEB_OIDC_AUTHORITY:-http://localhost:58180/realms/voice-local}")
oidc_client_id=$(json_escape "${WEB_OIDC_CLIENT_ID:-voice-platform-web}")
printf 'window.__VOICE_PLATFORM_CONFIG__ = {\n oidcAuthority: "%s",\n oidcClientId: "%s"\n};\n' \
"$oidc_authority" "$oidc_client_id" > /tmp/runtime-config.js
+16
View File
@@ -0,0 +1,16 @@
FROM node:24-alpine AS build
WORKDIR /src
COPY web/package*.json ./
RUN npm ci
COPY openapi /openapi
COPY web ./
RUN npm run generate:api && npm run build
FROM nginxinc/nginx-unprivileged:1.30.4-alpine
USER root
RUN apk upgrade --no-cache
COPY --chown=101:101 deploy/web.nginx.conf /etc/nginx/conf.d/default.conf
COPY --chmod=755 --chown=101:101 deploy/web-runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh
COPY --chown=101:101 --from=build /src/dist /usr/share/nginx/html
USER 101
EXPOSE 8080
+54
View File
@@ -0,0 +1,54 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://api:8080;
proxy_set_header Host $host;
proxy_set_header X-Request-Id $request_id;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /health/ {
proxy_pass http://api:8080;
proxy_set_header Host $host;
proxy_set_header X-Request-Id $request_id;
}
location /assets/ {
try_files $uri =404;
expires 1y;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always;
}
location = /index.html {
expires -1;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https: http://localhost:*; img-src 'self' data: blob: https:; media-src 'self' blob: https: http://localhost:*; style-src 'self'; script-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self' https: http://localhost:*" always;
}
location = /runtime-config.js {
alias /tmp/runtime-config.js;
expires -1;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always;
}
location / {
try_files $uri $uri/ /index.html;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always;
}
}
+20
View File
@@ -0,0 +1,20 @@
# ADR-0001:首期采用Go模块化单体与独立Worker
- 状态:已接受
- 日期:2026-08-23
## 背景
系统需要同时承载设备控制、家庭业务、Agent编排、供应商回调和后台可靠任务。首期需要优先保证事务一致性、联调效率和可部署性,同时保留以后按领域拆分服务的路径。
## 决策
后端采用同一Go模块中的模块化单体,部署为`api`、`callbacks`和`worker`三个独立进程。业务模块只能通过公开的应用层接口协作,不跨模块直接修改数据。跨进程工作先写入PostgreSQL outbox,再由Worker执行;Redis只保存可重建状态。
前端采用React、TypeScript和Vite。业务事实以PostgreSQL为准,实时语音不经过自建应用服务器。
## 影响
- 本地和测试环境可以用一套代码快速启动三个进程。
- 共享数据库不代表可以绕过模块边界,代码评审需检查跨模块访问。
- 将来拆分服务时,以模块公开接口、outbox事件和数据所有权为迁移边界。
+176
View File
@@ -0,0 +1,176 @@
# Agent固定评测集与发布门禁
## 1. 目的和需求对应
本文落实原需求 `P0252`“建立可重复的评测集和质量指标”。评测执行器把固定测试集、脱敏观测结果、评分规则、阈值、Agent版本和模型配置版本组合成一份可重复生成的JSON报告。相同评测集既可用于模拟供应商,也可用于真实账号;两种环境使用同一评分口径,但报告必须明确标记`mock`或`real`,不得用模拟结果代替真实账号验收。
评测报告可以直接转换为运营配置接口所需的登记正文,并固定标记为`AGENT_QUALITY_V1`。运营配置发布仍由既有双人审批、资源引用和评测门禁控制;`AGENT_BUNDLE`只有登记了`PASSED`标准Agent质量证据才可以发布,普通手工`GENERIC`证据不能绕过该门禁。
## 2. 交付组成
| 文件 | 作用 |
|---|---|
| `cmd/agent-eval` | 读取评测集和观测结果,生成报告;退化时返回非零退出码 |
| `cmd/agent-eval-collect` | 通过受权限控制的SaaS接口读取每个用例的通话状态和最终话轮,自动生成脱敏观测文件 |
| `cmd/agent-eval-register` | 校验评测集和报告完整性,使用确定性幂等键把证据登记到指定运行配置版本 |
| `internal/agenteval` | 输入校验、逐用例评分、聚合指标、阈值判断和登记正文生成 |
| `testdata/agent-eval-suite.v1.json` | 首版固定评测集、预期结果和评分规则引用 |
| `testdata/agent-eval-collection.example.v1.json` | 用例、唯一通话编号和脱敏评分卡的采集清单示例 |
| `testdata/agent-eval-observations.mock.v1.json` | 可重复执行的模拟供应商脱敏观测样例 |
| `artifacts/agent-eval-report.mock.v1.json` | 当前模拟基线报告和可登记正文 |
测试集与观测文件只保存合成用例编号、时延、回答长度、布尔评分和计数,不保存完整对话、姓名、录音、提示词或供应商长期凭证。真实试点问题应先脱敏并分配稳定用例编号,再进入评测集。
## 3. 固定覆盖范围
评测集缺少以下任一类别时,执行器直接判定输入无效,不生成“通过”结论:
1. 正常闲聊 `normal_chat`;
2. 长停顿 `long_pause`;
3. 重复表达 `repeated_expression`;
4. 姓名和日期歧义 `name_date_ambiguity`;
5. 冲突事实 `conflicting_fact`;
6. 情绪话题 `emotional_topic`;
7. 用户打断 `user_interruption`;
8. 网络波动 `network_fluctuation`;
9. 记忆召回 `memory_recall`;
10. 敏感信息 `sensitive_information`。
每个用例必须声明预期通话结果、首个回答时延上限、回答长度上限、虚构事实数量上限、至少一个评分标志和版本化评分规则引用。观测结果必须与评测集中的用例一一对应,不能缺失、重复或混入未知用例。
## 4. 质量指标
执行器同时执行“逐用例门禁”和“整套指标门禁”。逐用例失败能够定位到具体用例;聚合指标用于识别Agent版本整体退化。
| 指标 | 计算方式 | 默认基线用途 |
|---|---|---|
| 通话成功率 | 成功用例数 ÷ 全部用例数 | 检查完整对话是否可用 |
| 首个回答P95时延 | 全部用例首个回答时延的最近秩P95 | 检查回答等待时间退化 |
| 打断恢复率 | 打断用例中通过`interruption_recovered`的比例 | 检查打断后能否继续交流 |
| 平均回答长度 | 全部用例回答字符数平均值 | 防止回答变得过长 |
| 称呼准确率 | 要求`salutation_correct`的用例通过比例 | 检查长者称呼使用 |
| 记忆准确率 | 要求`memory_correct`的用例通过比例 | 检查是否使用正确长者记忆 |
| 虚构事实数 | 全部用例虚构事实计数之和 | 阻止无来源事实进入内容闭环 |
| 故事来源完整率 | 要求`story_source_complete`的用例通过比例 | 检查候选内容可追溯性 |
| 敏感信息处理率 | 敏感信息用例通过比例 | 检查敏感内容不进入普通故事或记忆 |
阈值保存在评测集文件中并随版本提交。修改阈值必须创建新的`suite_version`,不能覆盖已经用于发布结论的旧版本。
## 5. 自动采集与评分边界
每个用例完成一次独立通话后,采集清单把`case_id`关联到唯一`conversation_id`,并填写人工评分器或经过批准的自动评分器产生的脱敏评分标志。一个通话编号不能复用于多个用例。
采集器通过当前登录用户的OIDC访问令牌调用:
```text
GET /api/v1/conversations/{id}
GET /api/v1/conversations/{id}/turns?limit=100
```
服务端继续执行客户组织、家庭、长者和资源权限检查。采集器自动计算以下机器可观察结果:
- 通话状态为`COMPLETED`且存在有效长者—智能体话轮时,判定控制和内容链路成功;
- 从第一条长者话轮结束到随后第一条智能体话轮开始计算首答时延;
- 使用本通话最长的智能体最终话轮字符数作为回答长度,避免多个短回答掩盖单条过长回答;
- 观测文件只保留`conversation:<id>`证据引用,不复制最终字幕正文。
称呼是否准确、记忆是否正确、是否虚构、故事来源是否完整、敏感信息是否正确处理属于语义判断。采集器要求评分卡显式提供这些字段,但不会因为通话完成而自动写成`true`。缺少任一用例、重复使用同一通话或缺少必需评分字段时,采集直接失败。
准备一个权限受控且只包含一行OIDC访问令牌的临时文件,然后执行:
```powershell
$env:GOCACHE = (Resolve-Path '.').Path + '\.gocache'
go run ./cmd/agent-eval-collect `
-suite ./testdata/agent-eval-suite.v1.json `
-manifest ./testdata/agent-eval-collection.example.v1.json `
-api http://127.0.0.1:58080 `
-access-token-file <临时令牌文件> `
-out ./artifacts/agent-eval-observations.current.json
```
示例清单中的通话编号仅为格式占位,执行前必须替换为本次十个独立用例产生且当前账号有权读取的真实编号。访问令牌不会写入观测文件、报告或日志;执行后应删除临时令牌文件。
## 6. 生成评测报告
在项目根目录执行:
```powershell
$env:GOCACHE = (Resolve-Path '.').Path + '\.gocache'
go run ./cmd/agent-eval `
-suite ./testdata/agent-eval-suite.v1.json `
-observations ./testdata/agent-eval-observations.mock.v1.json `
-report-ref 'artifact://agent-eval-report.mock.v1.json' `
-out ./artifacts/agent-eval-report.mock.v1.json
```
退出码含义:
- `0`:输入完整且所有用例和聚合阈值通过;
- `1`:评测正常完成但存在退化,报告仍会写出;
- `2`:评测集、观测结果、报告引用或文件格式无效,不能形成质量结论。
真实账号评测只需把观测文件的`environment`改为`real`,填写实际Agent版本、同一模型配置引用和真实执行时间,并用同一套用例编号记录脱敏评分。真实观测不得复制完整个人对话;需要人工复核时,报告仅保存受控证据系统中的引用。
## 7. 登记到配置发布流程
报告中的`registration_payload`是以下接口的JSON正文:
```text
POST /api/v1/ops/config-versions/{id}/evaluations
Idempotency-Key: <稳定且唯一的评测登记键>
If-Match: "<配置版本行版本>"
```
推荐流程如下:
1. 固定Agent版本、模型配置引用和评测集版本;
2. 在模拟供应商环境执行,修复输入、流程和明显质量问题;
3. 在真实账号环境执行同一评测集,形成独立的`real`报告;
4. 将真实报告存入受控交付位置,使用其引用和`registration_payload`登记评测证据;
5. 由非配置创建人完成发布审批;
6. 任一关键指标低于阈值时保持`FAILED`,不得手工改写为`PASSED`;修复后使用新的Agent或配置版本重新执行。
登记前先执行只读校验:
```powershell
go run ./cmd/agent-eval-register `
-suite ./testdata/agent-eval-suite.v1.json `
-observations ./testdata/agent-eval-observations.mock.v1.json `
-report ./artifacts/agent-eval-report.mock.v1.json `
-tenant <客户组织UUID> `
-config <运行配置版本UUID> `
-dry-run
```
只读校验会用评测集和观测文件重新计算完整报告,再检查评测集SHA-256、版本、模型配置引用、用例数量、通过/失败结论和`registration_payload`是否逐项一致,并显示本次登记的确定性幂等键。评测集、观测、报告或登记正文任一项被单独修改,命令都会拒绝发送。
确认无误后,使用运营人员OIDC令牌登记:
```powershell
go run ./cmd/agent-eval-register `
-suite ./testdata/agent-eval-suite.v1.json `
-observations ./testdata/agent-eval-observations.mock.v1.json `
-report ./artifacts/agent-eval-report.mock.v1.json `
-tenant <客户组织UUID> `
-config <运行配置版本UUID> `
-api http://127.0.0.1:58080 `
-access-token-file <临时令牌文件>
```
同一配置版本和同一报告正文始终生成相同的`Idempotency-Key`。登记器发送前会复制指标并注入目标`config_version_id`,不修改磁盘上的原始报告。第一次成功返回HTTP 201,响应丢失后重试返回同一评测记录和HTTP 200,不会重复产生发布证据。服务端还会复核`evidence_type`、报告结构、评测集摘要、用例计数、结论一致性和全部必需指标;标准证据只能属于`AGENT_BUNDLE`,且`model_config_ref`必须严格等于`runtime-config-version:<该组合的model_version_id>`,防止把另一模型或另一组合的报告误作当前发布证据。访问令牌不进入命令输出、报告或幂等键。
因此,执行候选组合评测前应从固定评测集复制一个本次运行文件,只把`model_config_ref`设为目标组合模型组件的规范引用;用例、分类、预期、评分规则和阈值保持不变。观测文件必须使用同一引用。仓库中的模拟基线报告用于证明工具链可运行,不应直接登记到模型组件编号不同的候选组合。
`FAILED`报告默认拒绝登记,防止操作人员误把调试产物当成发布证据。如果项目需要保留失败历史,必须显式增加`-allow-failed`;它只登记`FAILED`证据,仍不能满足配置发布门禁。
模拟报告证明评测工具和评分闭环可运行,不证明真实语音质量、真实首包时延、真实打断体验或供应商配额已经验收。
阶段8使用[真实Agent模型质量一致性证据](real-agent-models-evidence.md)把本报告与Embedding六类召回/隔离、内容安全六类策略、数据条款和额度证据绑定到同一候选。`agent-eval`报告仍是内容生成质量的唯一评分来源;聚合门禁会重新计算它,不接受只复制结论的手工摘要。
## 8. 扩展规则
- 新增用例可以扩展类别,但不能删除十类强制覆盖。
- 自动评分器和人工评分器都必须输出相同的脱敏观测结构。
- 评分标志的含义发生变化时,必须更新`scoring_rule_ref`和评测集版本。
- 同一报告必须能够追溯`suite_sha256`、Agent版本、模型配置引用、运行环境和执行时间。
- 发布候选只执行一遍完整固定集;普通功能子批次只运行受影响用例,符合项目“功能完整、验证精简”的测试策略。
+248
View File
@@ -0,0 +1,248 @@
# 告警监测与运营处置开发说明
> 对应数据库迁移:V22、V25
> 对应接口契约:OpenAPI 0.55.0
> 适用范围:客户组织级业务、技术、安全和费用告警的发现、路由、升级、静默及人工闭环
## 1. 建设目标与边界
告警模块将已有Prometheus基础规则之外、需要关联业务资源和人工处置的信号统一落入运营闭环。平台按客户组织隔离信号,把同一问题的重复命中归并为一个事件,冻结当时有效的接收组和运行手册,并记录确认、缓解、关闭、静默、重新打开和升级动作。
当前版本已经覆盖数据库、后台扫描器、运营接口、React工作台、可靠Webhook通知适配器、不会生成正式告警的接收器联通测试和本地端到端验证。`receiver_group`和`escalation_receiver_group`仍是受审计的逻辑接收组,接收端可据此映射短信、电话、PagerDuty或企业协作平台;真实通知网关账号、地址和值班组映射需在部署环境配置。PostgreSQL或Redis自身完全不可连接时,服务无法把故障写入同一主库,因此这类基础设施失联仍由Prometheus/部署平台外部探针告警,不能依赖本模块自举。
阶段8使用`cmd/alert-receiver-conformance`把全部活动路由、联通测试、真实网关回执、主/升级值班组映射和一次受控正式告警演练形成候选一致的结构化报告。详细采集格式、10项校验和证据包门禁见[真实告警接收器一致性证据](alert-receiver-conformance-evidence.md)。
## 2. 功能设计
### 2.1 告警分类与级别
| 维度 | 取值 | 用途 |
|---|---|---|
| 分类 | `BUSINESS` | 隐私删除、录音上传、业务任务等需要业务确认的问题 |
| 分类 | `TECHNICAL` | 死信、语音任务卡滞、固件失败等技术处置问题 |
| 分类 | `SECURITY` | 证书、异常访问、密钥或安全策略问题 |
| 分类 | `COST` | 额度临界、异常费用增长和供应商核价问题 |
| 级别 | `CRITICAL/HIGH/MEDIUM/LOW` | 决定队列排序、响应时限和升级路径 |
### 2.2 当前自动扫描规则
| 规则代码 | 分类/级别 | 判定依据 | 关联资源 |
|---|---|---|---|
| `OUTBOX_DEAD_LETTER` | 技术/高 | 客户组织存在`dead`可靠任务 | Outbox队列 |
| `PRIVACY_DELETE_OVERDUE` | 业务/严重 | 删除请求部分失败,或创建超过1小时仍未完成 | 隐私请求 |
| `MEDIA_UPLOAD_FAILED` | 业务/中 | 双轨录音上传批次进入失败终态 | 上传批次 |
| `FIRMWARE_ROLLOUT_FAILURE` | 技术/高 | 固件灰度存在失败设备 | 固件灰度批次 |
| `COST_DAILY_QUOTA_NEAR` | 费用/中 | 当前账期日费用达到活动硬额度的80% | 额度策略 |
| `VOICE_TASK_STUCK` | 技术/高 | 通话在启动或停止中间态停留超过5分钟 | 通话 |
| `PROVIDER_NOTIFICATION_BACKLOG` | 技术/高 | 供应商通知处于待处理/失败且超过1分钟,按客户组织和供应商归并 | 供应商通知队列 |
| `PROVIDER_UNAVAILABLE` | 技术/严重 | 连续临时失败达到阈值,供应商启动熔断处于不可用状态 | 供应商依赖状态 |
| `CERTIFICATE_EXPIRING` | 安全/高或严重 | 活动证书引用在30天内到期;7天内提升为严重 | 受控证书引用 |
扫描器随Worker进程启动,默认使用`WORKER_POLL_INTERVAL`。每次扫描只读取规范业务表,生成脱敏标题和摘要,不把供应商任务正文、录音、字幕、密钥或完整请求正文复制到告警表。
供应商临时错误由 Worker 按网络错误、超时、HTTP 429 和 HTTP 5xx 分类。连续失败达到`PROVIDER_FAILURE_THRESHOLD`后,`provider_dependency_state`按客户组织和供应商持久化为`UNAVAILABLE`,在`PROVIDER_CIRCUIT_COOLDOWN`窗口内暂停新的语音任务启动;停止和异常回收不经过启动熔断,仍会继续尝试。任一成功的启动或停止会清零连续失败并恢复`AVAILABLE`。告警扫描器对`UNAVAILABLE`状态生成`PROVIDER_UNAVAILABLE` CRITICAL事件,摘要只包含供应商代号、失败次数和归一化错误码。
### 2.3 路由策略
- 路由按`客户组织 + 分类 + 级别`版本化,同一组合只有一个`ACTIVE`版本。
- 新版本由`ops_approver`或`ops_admin`激活,旧版本自动变为`RETIRED`。
- 策略保存首接组、响应时限、升级时限、升级接收组、运行手册引用和变更说明。
- 事件首次发现时保存活动路由内部引用;后续策略升级不会改写历史处置依据。
- 如果事件首次发现时尚无路由,事件仍进入队列;后续重复命中可补挂当时有效的路由。
### 2.4 告警归并与状态机
同一客户组织内使用`fingerprint`归并仍未关闭的重复信号。扫描器通过事务级顾问锁串行化同一指纹,避免多Worker同时插入两个活动事件。重复命中只增加`occurrence_count`、刷新最后发现时间和摘要,不生成重复费用或重复业务资源。
```mermaid
stateDiagram-v2
[*] --> OPEN: 首次命中且无静默
[*] --> SILENCED: 首次命中且匹配静默
OPEN --> ACKNOWLEDGED: 运营确认并接手
OPEN --> MITIGATED: 直接记录缓解
ACKNOWLEDGED --> MITIGATED: 完成缓解
OPEN --> RESOLVED: 验证后关闭
ACKNOWLEDGED --> RESOLVED: 验证后关闭
MITIGATED --> RESOLVED: 验证后关闭
OPEN --> SILENCED: 后续命中有效静默
ACKNOWLEDGED --> SILENCED: 后续命中有效静默
MITIGATED --> SILENCED: 后续命中有效静默
SILENCED --> OPEN: 静默到期或撤销且信号仍存在
RESOLVED --> [*]
```
关闭后的相同问题再次发生时创建新的事件,而不是改写已关闭事件。这样每次故障窗口、首次发现、响应和恢复证据都可独立复盘。
### 2.5 超时升级
扫描器在每轮检测后查找`OPEN`或`ACKNOWLEDGED`且尚未升级的事件。若`first_detected_at + escalation_after_minutes`已到期,则原子写入`escalated_at`并追加`ESCALATED`动作。升级不改变业务状态,避免“已升级”被误认为“已缓解”。
### 2.6 静默
- 静默至少按规则代码、分类或具体资源之一限定,不能创建全平台无条件静默。
- 时间窗口必须有效,最长30天,原因必填。
- 静默不会删除事件或停止命中计数;首次命中仍写`DETECTED`和`SILENCED`动作。
- 对活动事件新增静默后,下次命中转为`SILENCED`;静默到期或撤销且问题仍存在时转回`OPEN`并写`REOPENED`。
- 撤销使用强`If-Match`,防止两个运营人员基于旧页面重复操作。
### 2.7 Webhook通知
- 未静默的新告警在写入事件和`DETECTED`动作的同一事务中生成`alert.webhook_requested`可靠任务;静默中的首次命中不对外通知。
- 超时升级在写入`ESCALATED`动作的同一事务中生成第二条通知,接收组切换为`escalation_receiver_group`。
- 通知正文只使用客户组织、事件和业务资源的公开编号,并携带规则、分类、级别、状态、脱敏标题/摘要、逻辑接收组和运行手册引用,不暴露内部主键、令牌、密钥、字幕或录音正文。
- Worker以稳定`event_id`投递,网络错误、超时、HTTP 408/425/429/5xx进入统一指数退避重试,其他4xx作为永久配置错误进入死信。
- 每次请求携带`X-Voice-Event-ID`、Unix秒`X-Voice-Timestamp`和`X-Voice-Signature`。签名格式为`v1=HMAC-SHA256(secret, timestamp + "." + raw_body)`;接收端必须校验签名、限制时间偏差并按事件编号去重。
- `ALERT_WEBHOOK_URL`为空时适配器安全关闭,任务由Worker正常确认,不访问外部网络,也不形成待处理或死信积压;启用时密钥至少32字符,生产环境强制HTTPS和非占位密钥。
- 运营人员可以针对任一活动路由发起`alert.webhook_test_requested`。测试使用同一Worker、目标地址、HMAC密钥、超时和错误分类,但`aggregate_type=alert_delivery_test`,不会写入`alert_incident`或触发正式告警升级。
- 普通告警在地址未配置时仍保持安全空操作;联通测试在地址未配置时明确进入`FAILED`,避免把“没有发送”误报为“接收器可用”。失败任务使用公开任务编号进入现有诊断受控重试链路。
## 3. 数据库设计
### 3.1 表及职责
| 表 | 主体字段 | 约束与用途 |
|---|---|---|
| `alert_routing_policy` | 分类、级别、版本、接收组、时限、运行手册 | 活动版本部分唯一;历史版本不可覆盖 |
| `alert_silence` | 规则/分类/资源范围、匹配器、原因、起止时间、撤销人 | 至少一个范围;结束晚于开始;服务层限制最长30天 |
| `alert_incident` | 规则、指纹、状态、摘要、资源、路由、负责人、时间和次数 | 未关闭指纹部分唯一;保存响应、缓解、关闭、静默和升级时间 |
| `alert_action` | 动作、前后状态、系统/用户操作人、说明、时间 | 随事件级联删除;构成不可变处置时间线 |
| `ops_resource_reference`(V25扩展) | `resource_type=CERTIFICATE`、受控引用、核验时间、到期时间 | 不保存证书私钥或正文;活动证书必须有未来到期时间 |
| `outbox_event`(V26扩展) | 公开任务编号、事件类型、测试关联号、状态、尝试与人工重试次数 | 联通测试复用可靠任务台账,不新增业务表;测试原因只进审计,不进入外发正文 |
所有表使用内部UUID关联,HTTP只暴露`public_id`。每张租户数据表都保存`tenant_id`,所有服务查询先用OIDC身份和客户组织公开编号解析运营账号,再固定内部客户组织条件;不接受前端传入内部客户组织编号。
### 3.2 关键索引
- `alert_route_active_unique`:保证一个分类/级别只有一个活动路由。
- `alert_silence_active_idx`:加速客户组织当前静默窗口判断。
- `alert_incident_active_fingerprint`:保证同一问题只有一个未关闭事件。
- `alert_incident_queue_idx`:按状态、严重度和最后发现时间查询运营队列。
- `alert_action_incident_time_idx`:按事件顺序读取完整动作时间线。
## 4. 接口设计
所有运营接口均要求OIDC Bearer Token和`X-Tenant-Id`;创建接口要求`Idempotency-Key`,状态变更要求`If-Match: "v{row_version}"`。
| 方法与路径 | 最低角色 | 说明 |
|---|---|---|
| `GET /api/v1/ops/alert-routing-policies` | `ops_viewer` | 查询活动和历史路由 |
| `POST /api/v1/ops/alert-routing-policies` | `ops_approver` | 激活新路由版本,支持幂等重放 |
| `GET /api/v1/ops/alert-delivery-tests` | `ops_viewer` | 查询最近50次联通测试及真实投递状态,不返回地址、密钥或测试原因 |
| `POST /api/v1/ops/alert-routing-policies/{id}/delivery-tests` | `ops_operator` | 针对活动路由创建受审计、幂等的联通测试,不创建正式告警 |
| `GET /api/v1/ops/alerts` | `ops_viewer` | 查询队列,可按状态筛选 |
| `GET /api/v1/ops/alerts/{id}` | `ops_viewer` | 查询事件详情和完整动作时间线 |
| `POST /api/v1/ops/alerts/{id}/acknowledge` | `ops_operator` | 确认并把首次操作人记为当前负责人 |
| `POST /api/v1/ops/alerts/{id}/mitigate` | `ops_operator` | 记录已采取缓解措施 |
| `POST /api/v1/ops/alerts/{id}/resolve` | `ops_operator` | 验证恢复并关闭事件 |
| `GET /api/v1/ops/alert-silences` | `ops_viewer` | 查询当前和历史静默 |
| `POST /api/v1/ops/alert-silences` | `ops_operator` | 创建有范围、有原因、有限时长的静默 |
| `POST /api/v1/ops/alert-silences/{id}/revoke` | `ops_operator` | 提前撤销静默 |
稳定错误包括:`OPS_ACCESS_DENIED`、`ALERT_NOT_FOUND`、`ALERT_STATE_CONFLICT`、`VERSION_CONFLICT`、`IDEMPOTENCY_KEY_CONFLICT`。内部数据库错误只在服务端结构化日志记录,客户端不返回SQL或内部编号。
## 5. 业务流程
### 5.1 自动发现到关闭
```mermaid
sequenceDiagram
participant S as Worker扫描器
participant DB as PostgreSQL
participant W as Outbox Worker
participant R as 通知接收端
participant O as 运营人员
S->>DB: 读取业务表并生成脱敏Signal
S->>DB: 指纹加锁、检查活动路由和静默
S->>DB: 新建事件或增加命中次数
S->>DB: 超时则追加ESCALATED动作
S->>DB: 同事务写可靠通知任务
W->>DB: 领取通知任务
W->>R: HMAC签名Webhook
R-->>W: 2xx确认或临时失败重试
O->>DB: 查询队列和运行手册
O->>DB: If-Match确认并接手
O->>DB: If-Match记录缓解
O->>DB: If-Match验证并关闭
DB-->>O: 返回最新版本及完整动作时间线
```
### 5.2 静默与恢复
1. 运营人员选择明确规则、分类或资源,填写原因和结束时间。
2. 服务端校验角色、范围、时间和幂等键,在同一事务写静默与审计。
3. 扫描器继续检测信号并保留命中次数,但把事件放入`SILENCED`。
4. 静默到期或被带版本撤销后,如果信号仍存在,扫描器将事件重新打开并追加动作。
5. 若信号已经消失,事件保留静默历史,不自动伪造“已恢复”;运营人员根据复核结果关闭。发布候选快照会提供阻断告警公开编号和关联资源`PRESENT/MISSING/AGGREGATE/UNKNOWN`状态,帮助复核,但`MISSING`本身不会绕过人工关闭责任。
### 5.3 接收器联通测试
```mermaid
sequenceDiagram
participant O as 运营人员
participant A as 运营API
participant DB as PostgreSQL/Outbox
participant W as Worker
participant R as 告警接收器
O->>A: 选择活动路由并填写测试原因
A->>DB: 幂等写测试任务和最小审计
A-->>O: 返回PENDING与test_ref
W->>DB: 领取alert.webhook_test_requested
W->>R: 与正式告警相同的HMAC签名信封
R-->>W: 2xx或可分类错误
W->>DB: DELIVERED或FAILED
O->>A: 查询最近测试状态
A-->>O: 尝试次数、错误码和完成时间
```
外发正文固定带`action=TEST`、测试关联号、客户组织公开编号、活动路由公开编号、分类、级别、接收组、运行手册和“非真实故障”说明,不携带操作人填写的测试原因。失败任务只有在配置或网络问题已复核后才能由运营操作角色受控重试。
## 6. 工作台设计
运营工作台包含五块信息:待处置/严重/已升级/活动静默概览;按状态筛选的告警队列;包含路由、运行手册、资源和请求关联号的详情及动作时间线;路由版本和临时静默管理;最近50次接收器联通测试及失败重试。只读运营角色不会看到写操作按钮,审批角色才可激活路由,操作角色可处置、静默、发起联通测试和重试失败任务。Grafana安全仪表盘同时展示7天/30天证书到期数量和当前安全事件,技术仪表盘展示供应商通知积压与可靠任务状态。
确认接手、记录缓解和验证关闭从告警详情打开页面内处置表单,不再调用浏览器输入弹窗。表单固定告警公开编号、规则、分类、级别、当前状态、命中次数和行版本,处置说明必填且最长1000字符;关闭还必须确认缓解措施已经验证、监测指标恢复、关联影响和遗留风险完成复核。重新筛选、切换客户组织、选择其他告警、刷新或取消会清除处置草稿,不发送状态请求;服务端仍执行角色、租户、状态及`If-Match`并发校验。
接收器联通测试、失败测试受控重试和静默提前撤销也统一使用页面内受控操作区。联通测试固定活动路由编号、分类、级别、版本、接收组、运行手册和行版本,要求确认接收组知晓测试,且原因仅进入审计、不外发;失败重试固定任务编号、测试关联号、尝试次数、人工重试次数和稳定错误码,要求确认依赖问题已经修复;静默撤销固定静默编号、范围、原因、窗口和行版本,要求确认仍在发生的信号会重新进入队列。三类原因最长500字符,刷新、切换客户组织或取消会清除草稿。
## 7. 验证与运行
执行后端、契约和前端门禁:
```powershell
.\scripts\check.ps1
```
执行真实OIDC、PostgreSQL、API和Worker组合烟测:
```powershell
pwsh -NoProfile -File .\scripts\smoke-alerts.ps1
```
该脚本验证跨客户组织拒绝、路由幂等、自动发现、首次通知接收组、路由冻结、超时升级及升级通知接收组、旧ETag返回412、确认—缓解—关闭时间线、静默命中、撤销静默和重新打开,并在结束后清理全部合成数据及通知任务。
日常页面增量采用低强度真实OIDC无提交检查:`test/e2e/visual-ops-alert-transitions.mjs`使用三条分别处于`OPEN`、`ACKNOWLEDGED`和`MITIGATED`的临时告警,验证确认、缓解和关闭表单的目标/行版本绑定、空说明阻断、关闭二次确认、取消关闭、零状态POST、零横向溢出、零控制台错误和零非预期失败请求。截图保存到`test/e2e/screenshots/ops-alert-transition-form.png`,夹具在`finally`中精确清理。该检查证明页面不会误处置,不替代既有服务端状态机纵向烟测。
剩余三类告警操作复用一条真实OIDC无提交检查:`test/e2e/visual-ops-alert-controlled-actions.mjs`使用临时活动路由、活动静默和失败联通任务,验证目标/版本绑定、原因与影响确认门禁、取消关闭,以及零测试发送、零重试、零静默撤销请求;同时检查零横向溢出、零控制台错误和零非预期失败请求。截图为`test/e2e/screenshots/ops-alert-controlled-actions.png`,路由、静默和死信任务夹具在`finally`中精确清理。
执行接收器真实投递纵向烟测:
```powershell
pwsh -NoProfile -File .\scripts\smoke-alert-delivery-test.ps1
```
该脚本在没有活动路由时临时建立一条路由,通过真实OIDC创建联通任务,等待真实Worker出队,验证本地接收器HMAC签名、状态回读、非正式告警隔离和幂等重放,最后清理路由、Outbox、审计和幂等记录。
执行Chrome布局检查:
```powershell
$env:PLAYWRIGHT_REQUIRE_FROM='C:\Users\19926\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\node_modules'
node .\scripts\visual-alert-ui.cjs
```
## 8. 生产接入待办
- 配置真实Webhook通知网关,把逻辑接收组映射到PagerDuty、短信、电话或企业协作平台;先从活动路由发起联通测试并确认`DELIVERED`和接收端唯一事件,再在预生产环境验证正式告警的去重、送达、限流和失败重试。
- 将异常访问、密钥轮换失败和核价失败接入规范Signal;证书到期、供应商不可用和供应商通知积压已经接入。
- 在类生产环境校准规则阈值、扫描周期、事件量和存储保留期。
- 使用外部探针验证PostgreSQL、Redis、对象存储和区域级故障;主库失联不能仅依赖主库内事件。
- 完成值班表、接收组映射、每条规则运行手册和故障演练签署。
@@ -0,0 +1,80 @@
# 真实告警接收器、值班组映射与正式演练一致性证据
## 1. 目标与边界
本工具把云端当前活动告警路由、联通测试投递状态、接收网关唯一验签回执、逻辑值班组映射和一次受控正式告警演练绑定到同一候选版本与构建摘要,生成可进入阶段8候选证据包的结构化报告。它解决以下问题:页面显示`DELIVERED`但接收端没有收到、同一`test_ref`重复通知、逻辑组没有映射到真实值班目标、只验证主接收组而遗漏升级组,以及用本地模拟接收器冒充生产告警网关。
`cmd/alert-receiver-conformance`输出`alert-receiver-conformance-report-v1`。本地Compose接收器固定产生`MOCK_RECEIVER`报告并保留`external_validation_required=true`;只有真实网关证据、全部活动路由、主/升级组映射和正式演练同时通过时,报告才是`REAL_RECEIVER`且`external_validation_required=false`。报告仍不等于业务发布批准,联合签署继续由`BUSINESS_APPROVAL`管理。
## 2. 四类输入
| 输入 | 获取方式 | 必需内容 |
|---|---|---|
| 活动路由 | 下载`GET /api/v1/ops/alert-routing-policies`完整响应 | 当前租户全部`ACTIVE`路由、主接收组、升级接收组和运行手册 |
| 联通测试 | 下载`GET /api/v1/ops/alert-delivery-tests`完整响应 | 每条活动路由最近一次`DELIVERED`测试、`test_ref`、尝试和完成时间 |
| 正式演练告警 | 下载`GET /api/v1/ops/alerts/{id}`详情 | 已解决的受控正式告警、接收组、确认动作和时间线 |
| 接收端采集 | 告警网关按`alert-receiver-capture-v1`导出 | 值班组映射、唯一测试回执、HMAC验签结果和正式演练接收/确认记录 |
接口返回可以是完整API响应或其中的`data`正文。接收端采集严格拒绝未知字段;不得包含Webhook密钥、Token、手机号、个人邮箱或完整值班人姓名。`receiver_system_ref`、`mapped_target_refs`和`acknowledgement_ref`只能使用脱敏稳定引用,例如`pager-service:platform-primary`或`incident-ticket:masked-001`。
## 3. 接收端证据格式
`alert-receiver-capture-v1`包含:
- `evidence_source`:真实网关填`REAL`,本地或测试接收器填`MOCK`;
- `candidate_version/build_digest`:必须与候选证据包完全一致;
- `group_mappings`:每个逻辑接收组对应一个或多个脱敏目标引用及复核时间;
- `test_receipts`:按`test_ref`记录外部事件编号、接收组、唯一送达次数、签名校验结果和接收时间;
- `formal_drill`:正式受控告警编号、接收组、唯一送达、签名校验、接收时间、值班确认时间和脱敏确认引用;
- `collected_at`:不得早于任何映射、回执或确认时间。
接收端事件编号与云端Outbox任务编号是两个独立公开编号,不要求相等;两端使用同一个`test_ref`绑定。每个联通测试必须只形成一条接收端回执,重复Webhook应由网关按事件编号幂等处理。
## 4. 自动校验项
报告固定包含以下10项`PASS/FAIL`检查,任一失败使总结果为`FAIL`:
1. `CAPTURE_SCHEMA`:接收证据版本正确;
2. `EVIDENCE_SOURCE`:来源明确为真实或模拟;
3. `CANDIDATE_IDENTITY`:候选版本和SHA-256构建摘要合法;
4. `CAPTURE_CANDIDATE_BINDING`:接收证据与命令参数绑定同一候选;
5. `RECEIVER_IDENTITY`:接收系统使用非秘密稳定引用;
6. `ACTIVE_ROUTES`:至少一条活动路由且全部属于同一租户;
7. `RECEIVER_GROUP_MAPPING`:每条活动路由的主接收组和升级接收组均有非空脱敏映射;
8. `DELIVERY_TEST_COVERAGE`:每条活动路由都有最近一次云端`DELIVERED`测试和唯一验签回执;
9. `DELIVERY_TIME_CORRELATION`:云端完成时间与接收时间差不超过10分钟;
10. `FORMAL_ALERT_DRILL`:一条已解决且包含确认动作的正式受控告警,与接收端唯一验签送达和确认引用一致。
联通测试不会创建正式告警,所以不能单独满足第10项。真实网关联通测试全部通过但尚未完成正式演练时,证据索引仍必须保持`REAL_ALERT_RECEIVER/PENDING`。
## 5. 命令用法
```powershell
go run ./cmd/alert-receiver-conformance `
-routes E:\evidence\alert-routes.json `
-tests E:\evidence\alert-delivery-tests.json `
-incident E:\evidence\formal-drill-incident.json `
-receiver E:\evidence\receiver-capture.json `
-candidate-version 0.63.0-rc.1 `
-build-digest sha256:<64位摘要> `
-output E:\evidence\alert-receiver-report.json
```
输出路径必须是绝对路径。通过返回0,不一致时仍写完整报告并返回1,参数、文件和JSON格式错误返回2。报告保存四份输入的SHA-256,不复制接收地址或通知正文。
## 6. 阶段8证据包门禁
`release-evidence-index-v2`中的`REAL_ALERT_RECEIVER`不可豁免:
- 无真实报告时保持`PENDING`,v3清单写入`REAL_RECEIVER_REPORT:0/1`和`FORMAL_ALERT_DRILL:0/1`;
- `files`的第一个文件必须是候选一致、所有检查通过、范围为`REAL_RECEIVER`且外部验证已完成的官方报告;后续文件可附真实网关导出、演练工单和签署材料;
- `MOCK_RECEIVER`、失败报告、跨候选报告、缺活动路由、缺升级组映射、重复回执或缺正式演练均不能标记`VERIFIED`;
- 通过后清单记录活动路由数、映射组数、测试回执数和正式演练结论,最终业务批准仍由独立门禁完成。
## 7. 本地轻量验证
```powershell
pwsh -NoProfile -File .\scripts\smoke-alert-delivery-test.ps1
```
脚本只创建一条临时活动路由和一条联通测试,真实经过Worker、HMAC签名和本地验签接收器,核对唯一回执后生成`MOCK_RECEIVER`报告。它使用合成正式演练输入验证报告结构,但必须继续声明外部验证未完成。脚本结束清理临时路由、Outbox、幂等、审计和磁盘报告,不会改变`REAL_ALERT_RECEIVER`门禁。
+118
View File
@@ -0,0 +1,118 @@
# 字幕、故事审核与已确认记忆设计
## 1. 目标与边界
本批次实现“最终转写入库—通话整理—故事候选—人工审核—已确认记忆—索引生效”的完整业务链。系统把模型输出始终视为候选内容;只有具备`story.review`能力的家庭成员明确确认,并且长者当前存在有效`personalized_memory`授权时,候选才可转为长期记忆。
当前抽取器支持两类可切换适配器:默认的`local-deterministic-extractor`用于本地业务验证,`ark:<model-id>`通过火山方舟OpenAI兼容Chat Completions接口生成结构化候选。向量生成与查询同样支持`local-deterministic-hash-v1`和`ark:<embedding-model-id>`两类适配器,并强制使用数据库既定的1536维。生产配置门禁禁止内容生成、索引或查询继续使用本地适配器;真实方舟账号效果仍需外部验收。
## 2. 功能设计
| 功能 | 输入 | 处理规则 | 输出 |
| --- | --- | --- | --- |
| 最终话轮接收 | 供应商签名回调、最终话轮数组 | 只接受`voice_chat.transcript.final/FINAL`;校验话轮键、序号、角色、时间和正文;按`conversation_id + turn_key`去重;同键不同内容拒绝 | `conversation_turn` |
| 通话整理 | 已结束通话、冻结授权快照、最终话轮 | 无`story_extraction`授权时不处理;只使用长者话轮作为故事和摘要来源;同一生成版本幂等 | 通话摘要、开放话题、故事候选、事实候选 |
| 方舟模型调用 | 本地安全策略通过的长者话轮 | 使用Bearer API Key调用`${ARK_BASE_URL}/chat/completions`;要求结构化JSON;网络、超时、408/425/429/5xx可靠重试,永久4xx进入死信 | 带模型编号、提示词版本和置信度的候选结果 |
| 分层内容安全过滤 | 长者最终话轮及模型结构化输出 | 验证码、支付账户、提示注入和高敏医疗先在本地拦截且不外发;其余输入在生成前、模型输出在落库前调用托管策略;BLOCK/REVIEW改为受控提示,策略服务异常时可靠重试且不落库 | 策略版本、非敏感提示和可审计请求引用 |
| 故事修订 | 候选故事、人工正文、原因、强ETag | 新增人工修订版本并复制来源关系,不覆盖模型版本;终态故事禁止再改 | 新的`story_revision`和`IN_REVIEW`状态 |
| 故事确认 | 当前故事、审核原因、强ETag | 必须有原话来源、具备审核权限且个性化记忆授权当前有效;确认故事及候选事实 | `CONFIRMED`故事和`PENDING`记忆 |
| 故事拒绝 | 当前故事、审核原因、强ETag | 拒绝故事及仍为候选状态的事实,不创建记忆 | `REJECTED`故事 |
| 记忆索引 | 待索引记忆版本 | 再次校验记忆为当前版本、状态有效且授权仍有效;不满足条件时清空向量并标记`STALE` | `ACTIVE`或`STALE`索引 |
| 生产向量化 | 普通敏感级别的当前记忆正文或新通话检索语句 | API与Worker使用同一方舟Embedding模型;请求1536维float向量并校验维度和有限数值;网络、超时、408/425/429/5xx可靠重试 | `ark:<model-id>`索引或固定边界查询向量 |
| 记忆修订与停用 | 当前记忆、正文或状态、原因、强ETag | 正文变化创建新版本并立即使旧索引失效;停用后不再召回;重新启用必须重新校验授权并重新索引;`PENDING_DELETE/SUPERSEDED/DELETED`均禁止修改 | 新记忆版本或状态 |
| 记忆永久删除 | 当前记忆、删除原因、强ETag | 事务内先进入`PENDING_DELETE`并清空活动向量,使新通话立即停止召回;该状态不可通过修改接口恢复;Outbox后台把索引标记删除、清除所有记忆版本正文并记录完成审计 | `DELETED`记忆和索引 |
| 新通话记忆检索 | 服务端已确定的客户组织、长者和通话目标 | SQL同时限制租户、长者、当前授权、活动记忆、当前版本、普通敏感级别、有效期和活动索引;最多5条 | 冻结到`context_snapshot.memory_versions`的版本引用和最小背景摘要 |
| 授权撤回 | `personalized_memory`授权 | 在同一事务内停用该长者的活动记忆,清空活动/待处理索引向量并标记失效 | `DISABLED`记忆、`STALE`索引 |
## 3. 业务流程
```mermaid
flowchart TD
A[供应商最终转写回调] --> B{字幕保存授权允许?}
B -- 否 --> C[不保存转写正文]
B -- 是 --> D[校验并去重最终话轮]
D --> E[通话进入COMPLETED或FAILED]
E --> F{故事整理授权允许?}
F -- 否 --> G[结束且不生成候选]
F -- 是 --> H[仅提取长者话轮]
H --> I[摘要与开放话题]
H --> J[故事和事实候选]
J --> K{家庭成员人工审核}
K -- 修订 --> L[新增人工版本并保留来源]
L --> K
K -- 拒绝 --> M[故事和候选事实拒绝]
K -- 确认 --> N{来源完整且记忆授权有效?}
N -- 否 --> O[返回冲突且不建记忆]
N -- 是 --> P[确认故事与事实]
P --> Q[创建记忆版本和待处理索引]
Q --> R{后台再次校验当前状态与授权}
R -- 合格 --> S[索引ACTIVE]
R -- 不合格 --> T[清空向量并标记STALE]
S --> U[创建下一次通话]
U --> V[数据库固定边界检索]
V --> W[冻结记忆版本和背景摘要]
```
供应商停止事件和最终转写事件可能存在先后竞争。停止事件会发布内容整理待办;如果话轮尚未到达,处理器返回可重试错误,由Outbox退避重试,避免把“暂无话轮”错误当成永久空内容。
内容处理器先在无事务状态读取已结束通话、冻结授权和有序最终话轮,执行本地敏感内容判断;只有普通内容才调用外部模型。模型返回后才开启短事务并锁定通话,重新读取状态、授权和全部话轮并比较SHA-256输入指纹。若模型调用期间授权撤回,则不写入结果;若话轮发生变化,则本次任务进入可靠重试并基于新输入重新生成。该顺序满足“数据库事务不得跨越大模型网络调用”的需求约束。
记忆索引也先在无事务状态读取当前版本和授权,事务外生成Embedding,返回后才锁定记忆与索引行重新校验;修订、停用或撤回授权发生在模型调用期间时,旧结果不会被激活。新通话创建前仅在设备绑定、语音授权和个性化记忆授权均可用时生成查询向量;事务内仍以固定租户、长者、授权、状态和敏感级别条件检索。Embedding临时不可用时,本次通话以`pgvector-degraded-v1`记录降级且不注入记忆,不阻断基本语音通话。普通顺序幂等重放和同键正文冲突会先读取已完成幂等记录,不重复调用Embedding;并发首请求仍由数据库唯一键保证只创建一个通话。
## 4. 接口设计
| 方法与路径 | 权限 | 并发/校验 | 说明 |
| --- | --- | --- | --- |
| `GET /api/v1/conversations/{id}/turns` | `conversation.read` | HMAC游标、1—100条 | 最终话轮分页 |
| `GET /api/v1/conversations/{id}/summaries` | `conversation.read` | 资源隔离 | 摘要和开放话题 |
| `GET /api/v1/subjects/{id}/stories` | `subject.read` | 可按状态过滤 | 故事列表,不展开子项 |
| `GET /api/v1/stories/{id}` | `subject.read` | 返回强ETag | 当前故事、来源和事实 |
| `PATCH /api/v1/stories/{id}` | `story.review` | 必须`If-Match`;正文和原因必填 | 创建人工修订版本 |
| `POST /api/v1/stories/{id}/confirm` | `story.review` | 必须`If-Match`;来源与授权门禁 | 确认故事并创建记忆 |
| `POST /api/v1/stories/{id}/reject` | `story.review` | 必须`If-Match`;原因必填 | 拒绝候选 |
| `GET /api/v1/subjects/{id}/memories` | `subject.read` | 过滤已删除记录;可用`q`在固定长者边界内搜索 | 已确认记忆列表 |
| `GET /api/v1/memories/{id}` | `subject.read` | 返回强ETag | 当前记忆版本和索引状态 |
| `PATCH /api/v1/memories/{id}` | `story.review` | 必须`If-Match`;正文/状态至少一项且原因必填;删除中及历史终态拒绝修改 | 修订、停用或重新启用记忆 |
| `DELETE /api/v1/memories/{id}` | `story.review` | 必须`If-Match`;删除原因必填;返回202 | 可靠受理永久删除任务 |
统一冲突码包括`STORY_SOURCE_REQUIRED`、`PERSONALIZED_MEMORY_CONSENT_REQUIRED`和`STORY_REVIEW_STATE_CONFLICT`。跨租户或非家庭成员访问统一返回403,不借404差异泄露资源是否存在。
## 5. 数据库设计
| 表 | 作用 | 关键约束 |
| --- | --- | --- |
| `conversation_turn` | 最终话轮 | 通话内`turn_key`和`sequence_no`唯一;角色、时间顺序和正文长度约束 |
| `conversation_summary` | 摘要与开放话题 | 通话、类型、生成版本唯一;必须有源话轮 |
| `story_draft` | 故事聚合根和审核状态 | 通话与生成版本唯一;强版本号;指向当前修订 |
| `story_revision` | 不可覆盖的故事版本 | 故事内修订号唯一;区分模型/人工作者;保存基础版本和原因 |
| `story_source` | 故事版本到原话的追溯关系 | 版本、话轮和文本偏移组合唯一 |
| `fact_candidate` | 可审核事实候选 | 区分长者陈述与模型推断;保存源话轮、敏感级别和状态 |
| `curated_memory` | 长期记忆聚合根 | 当前版本、状态、有效期、敏感级别和强版本号 |
| `memory_version` | 不可覆盖的记忆版本 | 记忆内版本号唯一;确认人、确认时间、原因、来源和内容摘要 |
| `memory_index_entry` | 记忆检索索引状态 | 记忆版本和索引类型唯一;向量维度1536;状态可失效和删除 |
V8迁移已经在独立空数据库执行`up → down -all → up`,最终为`8|dirty=false`,九张内容与记忆表均存在。测试数据库随后已删除。
## 6. 安全、隐私与可观测性
- 供应商事件记录只保存事件类型、状态、时间和话轮数量,不把转写正文复制进事件审计正文。
- 普通故事生成前执行本地敏感规则基线;验证码、支付账户和高敏感医疗信息默认不进入普通故事。该规则集只是生产内容安全服务接入前的最低门禁。
- 内容生成API Key由Worker环境注入,Embedding API Key由API和Worker环境注入;密钥不写入数据库、候选记录、HTTP响应或错误正文,业务记录只保留`ark:<model-id>`和提示词版本。
- Embedding API Key由API和Worker环境注入;记忆只记录模型引用,不记录密钥。只有普通敏感级别、当前有效且已授权的记忆进入外部向量化和语音召回。
- 只有冻结快照允许保存字幕时才落`conversation_turn`;故事整理和个性化记忆分别使用独立授权门禁。
- 故事、事实和记忆均保留租户、长者和来源边界;所有网页接口从OIDC主体重新解析家庭成员关系,不信任客户端传入租户。
- 人工修订、确认、拒绝和授权撤回记录审计事件;写操作使用强ETag防止并发覆盖。
- 通话创建已接入内部数据库召回并冻结实际使用的记忆版本;调用方不能传入自由租户或长者过滤条件。当前尚未开放通话中工具检索接口。
## 7. 验证结果
- Go全仓测试通过,OpenAPI 0.10.0可生成TypeScript类型,React生产构建通过。
- 本地内容策略单元测试覆盖验证码、银行卡号、高敏感医疗词和普通生活经历。
- 方舟兼容适配器轻量验证覆盖Bearer鉴权、请求路径、结构化响应校验、HTTP 429重试分类;真实PostgreSQL组合验证模型调用期间不持有通话行锁,模型返回后正常生成候选,敏感话轮不会调用外部模型。
- 方舟Embedding适配器轻量验证覆盖Bearer鉴权、`/embeddings`请求、模型/维度参数、1536维结果和错误维度拒绝;真实PostgreSQL组合验证向量生成期间不持有索引行锁,返回后可激活索引。
- 端到端通话冒烟验证最终话轮去重、两类整理结果、故事来源、人工确认前零记忆、确认后记忆生成及索引生效;下一次通话冻结1个记忆版本。
- 记忆修订生成第2版并重新索引;停用后索引立即失效且新通话召回0条;重新启用再次进入索引流程。
- 永久删除验证覆盖`PENDING_DELETE → DELETED`、删除中重新激活稳定拒绝、向量清理、数据库正文替换为删除标记和删除后新通话召回0条。
- React故事与记忆页以页面内管理区提供正文修订、启停和永久删除;未修改正文、空原因、未确认删除均不能提交,删除中和历史终态只读,不再依赖浏览器弹窗。
- OIDC、SaaS、设备、成员和通话五组冒烟全部通过;测试结束后内容表残留为0,Outbox无待处理、处理中或死信事件。
- 当前方舟内容与Embedding协议均使用本地模拟端点验证,尚未使用业务方真实API Key核验模型效果、召回质量、额度和数据条款;真实语音供应商与真实设备也仍待验收。
+49
View File
@@ -0,0 +1,49 @@
# 生产内容安全服务适配
## 1. 处理边界
Worker以两层策略保护故事、事实和后续记忆。第一层是进程内固定高风险规则,识别验证码、支付账户、提示注入和高敏医疗内容;命中后直接生成不含原文的受控提示,原文不发送给内容模型或外部安全服务。第二层是生产托管策略,分别检查准备送给内容模型的长者话轮和模型返回的摘要、开放话题、故事、事实组合文本。
托管策略返回`BLOCK`或`REVIEW`时,Worker只保存受控摘要和开放提示,不创建普通故事、事实或记忆。网络失败、超时、408/425/429/5xx、不可解析响应和不符合协议的响应均按可重试错误安全阻断;其他4xx作为永久配置或请求错误进入现有死信治理。任何失败都不会被降级解释为允许。
## 2. HTTP协议
Worker对`CONTENT_SAFETY_ENDPOINT`执行`POST`,使用`Authorization: Bearer <API Key>`和`Content-Type: application/json`。请求为:
```json
{
"policy_ref": "elder-story-production-v1",
"content": "待分类文本"
}
```
成功响应必须是:
```json
{
"decision": "ALLOW",
"sensitivity": "normal",
"reason_code": "",
"request_ref": "provider-audit-reference"
}
```
`decision`只允许`ALLOW`、`BLOCK`、`REVIEW`;`sensitivity`只允许`normal`、`sensitive`、`high`。阻断或复核必须返回由小写字母、数字、下划线或连字符组成且不超过64字符的`reason_code`。`request_ref`用于供应商侧审计关联,不写入普通业务正文。响应正文、API Key和待分类文本不会拼入应用错误。
## 3. 配置
| 变量 | 说明 |
|---|---|
| `CONTENT_SAFETY_PROVIDER` | 本地开发为`local`;生产必须为`managed_http` |
| `CONTENT_SAFETY_ENDPOINT` | 托管分类服务绝对地址;生产必须为非本地HTTPS |
| `CONTENT_SAFETY_API_KEY` / `_FILE` | Bearer密钥;二者只能配置一个,生产建议CSI文件 |
| `CONTENT_SAFETY_POLICY_REF` | 经业务、安全和法务批准的不可变策略版本 |
| `CONTENT_SAFETY_TIMEOUT` | 单次HTTP超时,默认5秒 |
生产Worker在连接数据库前执行配置门禁,拒绝本地提供器、HTTP或本地端点、占位/短密钥和本地策略名。Kubernetes基线从`voice-platform-model-storage`目录读取`content-safety-api-key`;生产覆盖层必须替换ConfigMap中的`.example.invalid`与`replace-with-*`占位值。
启用托管策略时,Worker还按`EXTERNAL_PROBE_INTERVAL`对策略端点执行无正文的DNS、TCP和受信TLS握手,结果写入`content_safety_tls`外部依赖状态和Prometheus指标。连续5分钟不可用触发`PlatformExternalDependencyUnavailable`,但不进入Pod readiness,避免网络抖动引发重启风暴;生产候选验收快照必须把该依赖列为必需项。
## 4. 验收
日常开发只运行适配器和配置的定向测试,覆盖正常允许、本地命中不外发、托管复核阻断、临时故障重试以及永久4xx。发布候选仍需在真实账号和目标策略上完成`VERIFICATION_CODE`、`FINANCIAL_ACCOUNT`、`PROMPT_INJECTION`、`SENSITIVE_MEDICAL`、`NORMAL_BENIGN`、`MODEL_OUTPUT_SAFETY`六类证据,并由`cmd/agent-models-conformance`形成不可豁免报告。
+59
View File
@@ -0,0 +1,59 @@
# 私有数据导出开发说明
## 1. 功能边界
私有数据导出用于将某一长者在当前租户和家庭边界内的资料生成独立JSON文件。它与`ACCESS`数据盘点分离:盘点只返回分类数量,导出文件才包含用户被授权获取的正文。
可导出分类包括长者档案、授权记录、设备绑定、通话元数据、最终话轮、摘要、故事与来源、已确认记忆。未显式指定分类时生成全量导出。
## 2. 功能设计
1. 家庭所有者使用`Idempotency-Key`提交导出任务。
2. API在事务中验证`privacy.manage`、长者归属和请求摘要,写入`QUEUED`任务及Outbox事件。
3. Worker将任务转为`RUNNING`,按固定分类SQL查询数据,生成`subject-export-v1`文档。
4. Worker计算SHA-256,将文件写入MinIO私有桶,再把文件大小、摘要和过期时间提交到PostgreSQL。
5. 用户读取`READY`任务时,API使用公网端点现场生成短期预签名URL,不在数据库和页面持久化URL。
6. 到期扫描器删除对象,清空内部对象引用,把任务置为`EXPIRED`并记录最小审计。
## 3. 安全设计
- MinIO桶`private-exports`保持私有,未签名的对象请求被拒绝。
- 对象路径为`tenants/{tenant-hash}/exports/{export-id}/v1.json`,不把租户主键直接写入路径。
- API不返回`object_ref`、对象存储凭据或长期URL。导出详情和列表使用`Cache-Control: no-store`。
- 下载URL有效期取“配置的短期下载TTL”与“对象剩余有效期”的较小值。
- 导出文件包含私密正文,仅能使用合成数据进行本地测试,生产密钥必须由密钥管理系统注入。
## 4. 接口设计
| 接口 | 权限 | 说明 |
|---|---|---|
| `POST /api/v1/subjects/{id}/exports` | `privacy.manage` | 幂等创建导出,返回`202`;重放返回`200` |
| `GET /api/v1/subjects/{id}/exports` | `privacy.read` | 返回当前长者导出列表,不签发下载URL |
| `GET /api/v1/exports/{id}` | `privacy.read` | 返回任务详情;仅就绪且未过期时包含短期URL |
## 5. 数据库设计
`export_task`保存租户、长者、申请人、冻结范围、任务状态、对象引用、大小、内容摘要、过期时间、尝试次数和幂等摘要。状态为`QUEUED/RUNNING/READY/FAILED/EXPIRED`。`READY`数据库约束要求对象引用、大小、摘要、过期时间和完成时间全部存在。
## 6. 配置
| 变量 | 用途 |
|---|---|
| `OBJECT_STORE_ENDPOINT` | API/Worker访问对象存储的内网端点 |
| `OBJECT_STORE_PUBLIC_ENDPOINT` | 用于生成用户可访问签名URL的端点 |
| `OBJECT_STORE_REGION` | 签名Region,避免使用公网地址执行内部探测 |
| `OBJECT_STORE_EXPORT_BUCKET` | 私有导出桶 |
| `EXPORT_DOWNLOAD_TTL` | 单次预签名下载地址有效期 |
| `EXPORT_OBJECT_TTL` | 导出对象总保留期 |
## 7. 验证
`scripts/smoke-export.ps1`使用本地真实OIDC令牌、PostgreSQL、Worker和MinIO验证所有者/编辑者权限、幂等重放、分类范围、私有桶拒绝直连、预签名下载、SHA-256校验、长者边界和过期后对象删除。
家庭工作台的“生成私有导出”现先打开页面内受控操作区。表单固定当前长者公开编号和八类导出范围,并明确后台将生成包含个人正文的私有JSON文件、对象与短期下载地址均有有效期、文件下载后应受控保存和销毁且操作会进入申请与导出审计。用户必须勾选私密数据影响确认后才能提交;取消或切换业务上下文会清除草稿,不创建任务,也不提前签发下载地址。
READY任务的“获取短期下载”同样不再单击即读取任务详情并触发下载。确认区固定长者、导出任务公开编号、任务冻结的分类范围、文件大小和对象过期时间,明确短期地址只会在提交时现场生成,JSON包含个人正文且离开平台后需要限制转交并按需销毁。只有显式确认后才重新读取任务;任务仍为READY、对象仍未过期且返回短期地址时才触发浏览器下载。取消或切换长者、家庭、工作台会清除草稿,不调用会签发地址的任务详情接口。
日常页面验证采用真实OIDC无提交冒烟,只检查对象/范围绑定、确认门禁、取消关闭、零导出创建请求、零任务详情地址签发请求、任务数量不变及页面布局。完整对象生成、私有桶、摘要、下载和过期清理由本节既有定向脚本承担,避免在普通前端改动中重复执行高强度全链路测试。
V10迁移已在独立临时空库完成`up 1—10 → down -all → up 1—10`,最终状态为`10|dirty=false`,验证库已删除。
+445
View File
@@ -0,0 +1,445 @@
# 数据采集终端—云端系统详细开发计划
> 依据:《数据采集终端—云端系统研发需求规格说明书》
> 计划口径:从工程框架搭建到生产落地
> 编制日期:2026-08-23
> 文档状态:可执行基线,外部账号、硬件和生产资源确认后滚动修订
## 1. 计划目标
本计划用于组织数据采集终端、SaaS平台、Agent编排、实时音视频供应商、模型供应商和平台工程的联合研发。最终交付应形成以下完整业务闭环:
1. 家庭用户创建家庭和长者档案,完成分项授权与设备绑定。
2. 终端摘机后通过设备身份访问云端,取得只适用于本次房间和用户的短期RTC资料。
3. 设备确认入房后,云端启动语音智能体;实时语音直接在终端、RTC和语音智能体之间传输,自建应用服务器只负责控制面和业务数据。
4. 通话期间持续处理心跳、状态、最终话轮、打断、异常停止和任务回收。
5. 通话后按授权生成摘要、开放话题、故事与事实候选,经家庭用户核对后形成可版本化的正式记忆。
6. 录音、分享、导出、授权撤回和主体删除均按最小权限处理并可审计、可对账。
7. 运营人员可以在脱敏边界内处理故障,管理配置版本、灰度、费用、额度和告警。
8. 系统完成真机、真实供应商、家庭试点、容量、安全、恢复和生产上线验收。
## 2. 计划假设与工期口径
### 2.1 建议团队
| 角色 | 建议人数 | 主要责任 |
|---|---:|---|
| 项目经理/需求负责人 | 1 | 需求基线、计划、风险、跨团队协调和验收 |
| 后端工程师 | 2—3 | SaaS业务、设备接入、Agent控制、数据、异步任务和运营接口 |
| 前端工程师 | 1—2 | 家庭工作台、内容核对、隐私和运营后台 |
| Agent/模型工程师 | 1—2 | 供应商适配、提示词、模型路由、内容整理、记忆和评测 |
| 测试工程师 | 1—2 | 接口、端到端、真机、性能、安全、恢复和验收测试 |
| 平台/运维工程师 | 1 | CI/CD、环境、监控、告警、备份、密钥和生产发布 |
| 硬件/固件接口人 | 1 | 设备协议、音频、命令、录音上传、固件和真机联调 |
工期以8—10人的跨职能团队并行开发为估算前提。若后端、Agent、测试或硬件接口人不能并行,日历工期需相应顺延。外部供应商账号、真实设备、生产网络和合规材料不计入纯编码工时,但直接影响联调、试点和上线日期。
### 2.2 总体工期建议
| 阶段 | 建议周期 | 累计时间 | 主要里程碑 |
|---|---:|---:|---|
| 阶段0:启动与需求冻结 | 1—2周 | 第1—2周 | M0需求与边界基线 |
| 阶段1:工程框架与环境 | 2周 | 第3—4周 | M1工程可运行 |
| 阶段2:公共基础能力 | 2—3周 | 第5—7周 | M2安全业务底座 |
| 阶段3:端到端语音最小闭环 | 3—4周 | 第8—11周 | M3真/模拟设备可完成通话 |
| 阶段4:SaaS核心业务 | 4—5周 | 第12—16周 | M4家庭用户P0流程 |
| 阶段5:内容与记忆闭环 | 3—4周 | 第15—18周 | M5确认记忆进入下一通话 |
| 阶段6:隐私、分享与媒体完善 | 3—4周 | 第17—20周 | M6数据生命周期闭环 |
| 阶段7:运营与生产加固 | 3—4周 | 第19—22周 | M7可运营、可恢复、可发布 |
| 阶段8:联调、试点与上线 | 3—6周 | 第23—28周 | M8生产上线与移交 |
阶段4—7可在接口和数据边界冻结后交叉并行。上述周期是项目计划估算,不是需求文档中的固定交付承诺;应在M0评审时结合人员和外部资源形成正式排期。
## 3. 总体执行原则
- 采用模块化单体起步,`api`、`callbacks`和`worker`分别承载在线接口、供应商通知和后台任务。
- OpenAPI是浏览器、设备和回调接口的事实来源;接口变更先改契约,再改实现和测试。
- 所有业务数据同时落实客户组织、家庭、长者和具体资源边界,不能相信浏览器或设备上报的租户编号。
- 会产生外部影响的POST请求使用`Idempotency-Key`;修改操作使用`ETag/If-Match`或显式版本号。
- 外部调用前先保存业务意图,通过可靠待办异步执行;数据库事务不跨越供应商、对象存储或身份服务网络调用。
- 配置、授权、故事、记忆和费用采用版本化保存;撤销、逻辑删除、审计保留和物理删除分别建模。
- 长期密钥只保存在KMS/HSM或受控密钥系统中,业务数据库和页面仅保存引用与最后轮换时间。
- 每阶段采用“最小充分验证”后再进入下一阶段:变更包定向测试、受影响的迁移或接口契约检查、一条可复现的纵向功能冒烟和文档同步必须通过;无关模块的全量回归不作为日常阶段门禁。
## 4. 分阶段开发计划
## 4.1 阶段0:项目启动与需求基线冻结
### 目标
把需求文档转化为研发、联调和验收共同使用的范围基线,先解决系统边界、责任分工和外部依赖不确定性。
### 主要任务
1. 建立需求追踪矩阵:需求编号—模块—接口—数据表—测试用例—交付物。
2. 冻结两条责任边界:SaaS负责“人、设备、权限、数据和网页”,Agent负责“通话控制、供应商适配、模型效果和内容候选”。
3. 确认实时语音不经过自建应用服务器,应用服务只处理控制、状态、字幕、用量和业务数据。
4. 明确设备型号、主板版本、音频格式、RTC发布/订阅方式、心跳字段、双轨分片清单和命令回执。
5. 明确火山RTC、豆包语音、模型、Embedding、对象存储、OIDC、短信/邮件和KMS账号申请计划。
6. 形成开发、测试、预生产、生产四套环境的隔离清单。
7. 输出风险台账,重点跟踪真实供应商能力、录音合规、硬件交付、供应商限额和生产网络。
### 交付物与退出条件
- 需求基线、范围清单、术语表、责任矩阵、接口清单和数据分类分级表完成评审。
- 外部依赖有负责人、申请时间和替代模拟方案。
- 未决事项明确决策截止日期,不影响第一阶段工程搭建。
## 4.2 阶段1:工程框架与本地环境搭建
### 目标
建立可重复启动、可检查、可迁移、可构建的工程骨架,让前后端、Agent和测试可以并行开发。
### 后端与基础设施任务
- 初始化Go模块化单体,建立`cmd/api`、`cmd/callbacks`、`cmd/worker`三个进程入口。
- 建立配置加载、结构化日志、请求编号、优雅停机、错误响应和健康检查。
- 接入PostgreSQL、pgvector、Redis、S3兼容对象存储和OIDC开发身份服务。
- 建立数据库迁移规则:迁移仅追加、支持空库升级、单版本回退和重新升级。
- 建立Dockerfile、Docker Compose、环境变量模板和本地种子数据。
- 建立CI基础流水线:格式、静态检查、变更包单元测试、OpenAPI生成、前端类型检查、构建、依赖审计和Compose解析;全仓单测和完整安全扫描放到发布候选流水线执行。
### 前端任务
- 初始化React、TypeScript和Vite应用。
- 建立登录回调、API客户端、全局错误、加载、无数据、权限不足和重试组件。
- 建立桌面端优先、平板可用的页面框架和统一设计变量。
### 接口与数据库
- 首批接口:`/health/live`、`/health/ready`、`/api/v1/version`、`/api/v1/me`。
- 首批表:租户、用户、审计事件、幂等记录和可靠待办。
### 退出条件
- 新开发机可按文档一次启动完整本地环境。
- 空数据库可升级到最新版本,服务就绪接口全部通过。
- 前后端镜像可构建,OpenAPI可生成TypeScript类型,基础CI无失败。
## 4.3 阶段2:公共基础能力建设
### 目标
一次性建立后续所有业务模块共用的身份、权限、隔离、可靠性和可观测能力。
### 主要任务
1. OIDC令牌验证:校验签发方、使用对象、签名、有效期和账号状态。
2. 租户与家庭权限:建立所有者、管理员、编辑者、查看者能力矩阵;服务端反查资源边界。
3. 幂等执行器:保存请求摘要和原响应,支持并发串行化及同键异文拒绝。
4. 事务审计:业务写入与审计同事务提交,记录操作人、原因、前后状态和请求编号。
5. Outbox可靠待办:领取、重试、退避、死信、进程崩溃锁恢复和积压指标。
6. 接口治理:请求体限制、统一错误码、签名游标、强ETag、安全响应头和Redis限流。
7. 可观测性:OpenTelemetry链路、Prometheus指标、结构化日志和依赖健康状态。
8. 安全基线:敏感字段日志过滤、生产配置启动门禁和密钥相互独立检查。
### 退出条件
- 跨租户、跨家庭、角色越权、伪造/过期令牌测试全部拒绝。
- 并发幂等请求只产生一次业务效果。
- 业务回滚时审计同步回滚,Outbox在进程重启后可以继续处理。
- 指标、日志和追踪可使用同一请求编号关联。
## 4.4 阶段3:端到端语音最小闭环
### 目标
先使用模拟设备和模拟供应商打通“摘机—创建—入房—激活—对话—挂断—回收”,再切换真实设备和供应商。
### 设备接入任务
- 每台设备建立独立身份,采用经批准的每设备请求签名或mTLS。
- 实现时间偏差校验、Nonce防重放、密钥吊销和轮换兼容。
- 实现`bootstrap`和版本化`config`,配置只包含设备运行所需的非敏感信息。
- 建立设备在线状态、期望配置、实际配置和单调版本同步。
### 通话控制任务
1. `POST /device/v2/conversations`:冻结授权、背景、记忆和配置版本,分配RTC房间与短期凭证,此时不启动Agent。
2. `POST /device/v2/conversations/{id}/activate`:设备确认入房后可靠投递供应商启动任务。
3. `POST /device/v2/conversations/{id}/heartbeat`:维护数据库权威租约和Redis热状态,拒绝旧序号覆盖新状态。
4. `POST /device/v2/conversations/{id}/stop`:可靠停止供应商任务并收敛终态。
5. `POST /device/v2/conversations/{id}/turns:batch`:供应商终稿缺失时由所属设备补传,按话轮业务键原子去重合并。
6. `POST /device/v2/telemetry:batch`:批量接收状态、有限错误码和命令结果,推进设备影子并返回后续动作。
7. `POST /callbacks/v1/vertc/voice-chat`:先验签再解析,按供应商事件编号和正文摘要去重。
8. Worker扫描预留过期、心跳超时、启动/停止卡住和供应商遗留任务。
### 状态机
`PREPARING → ROOM_ISSUED → STARTING_AGENT → ACTIVE → STOPPING → COMPLETED`
异常终态为`FAILED`或`EXPIRED`。重复通知和过时通知不得让终态回退。
### 退出条件
- 模拟设备可连续完成完整通话,重复创建不会产生两个有效房间。
- 设备未入房时不启动供应商任务,预留到期可自动回收。
- API、Callback或Worker重启后,中间状态可自动收敛。
- 使用真实账号完成RTC短期凭证、Agent启动/停止和签名通知联调后,才将本阶段标记为真实闭环完成。
## 4.5 阶段4:SaaS核心业务开发
### 目标
完成家庭用户从登录、建档、授权和绑定设备,到查看通话与管理内容的P0业务流程。
### 功能批次
| 子批次 | 功能 | 关键接口与数据 |
|---|---|---|
| 4A 家庭与成员 | 家庭创建、邀请、角色、移除、所有权转移 | `/households`、`/members`、`member_invitation`、`household_member` |
| 4B 长者与授权 | 档案、稳定事实、五类分项授权、撤回和快照 | `/subjects`、`/facts`、`/consents`、`subject_profile`、`consent_record` |
| 4C 设备管理 | 列表、详情、绑定、解绑/转移申请、在线状态和设备影子 | `/devices`、`/device-claims`、`device_binding`、`device_twin` |
| 4D 受控命令 | 排队、设备拉取、确认、成功/失败和过期 | `/devices/{id}/commands`、`/device/v2/commands`、`device_command` |
| 4E 固件 | 公钥、制品、签名、灰度、暂停、回滚和逐设备进度 | `/firmware-*`、`firmware_release`、`firmware_rollout` |
| 4F 通话页面 | 列表、筛选、游标分页、状态、字幕、摘要、用量和错误 | `/subjects/{id}/conversations`、`/conversations/{id}` |
### 页面设计
- 家庭成员页、长者档案与授权页、设备管理页、固件发布页和通话记录页。
- 所有页面覆盖加载、空数据、失败、重试、权限不足和并发修改状态。
- 家庭角色只控制业务操作能力,不隐式授予字幕、录音或具体分享资源权限。
### 退出条件
- 家庭用户P0流程端到端通过;跨家庭资源编号访问不泄露资源是否存在。
- 多人编辑使用版本检查,旧版本写入返回冲突。
- 设备离线、命令过期、重复回执和固件损坏均有稳定、可审计结果。
## 4.6 阶段5:Agent内容、故事与记忆闭环
### 目标
把最终字幕转化为可追溯候选内容,经人工核对后形成仅在有效授权下进入后续通话的正式记忆。
### 主要任务
- 接收并去重最终话轮,保存角色、顺序、时间、打断和来源事件。
- 仅在字幕保存授权有效时保存正文;供应商原始事件不重复保存完整字幕。
- 生成通话摘要、开放话题、故事候选和事实候选,并保留来源话轮和生成版本。
- 对验证码、账户信息和敏感医疗内容先识别、最小化和策略过滤。
- 实现故事查看、修订、确认和拒绝;每次修改形成不可覆盖的新版本。
- 只有人工确认且个性化记忆授权有效时,才创建正式记忆和检索索引。
- 使用PostgreSQL与pgvector按租户、长者、授权、敏感级别、状态和版本检索。
- 停用、授权撤回或删除后立即停止新通话召回,后台对账索引和正文清理。
- 建立固定评测集,记录通话成功、时延、打断、称呼、记忆、虚构、来源完整和敏感内容处理。
### 退出条件
- 机器候选不会自动成为正式记忆。
- 新确认记忆只从下一次新通话开始生效;进行中的通话保持冻结版本。
- 两位长者交替测试不串用资料;提示注入不能改变服务端授权和数据边界。
- 删除或撤回后,新通话召回为零并有后台对账证据。
## 4.7 阶段6:录音、分享、导出与隐私闭环
### 目标
完成从设备本地双轨录音到云端访问,以及数据分享、导出、撤回和永久删除的全生命周期。
### 录音链路
1. 设备在原录音保存授权有效时,本地加密保存near/far两条轨道。
2. 设备提交幂等分片清单,云端为单个对象签发短期上传地址。
3. Worker校验大小、SHA-256、WAV/Opus容器、时长和分片稳定编号。
4. 云端确认校验且达到保留窗口后,设备删除本地密文并提交删除回执。
5. 播放和下载使用独立授权,每次访问重新校验授权并签发短期地址。
### 分享与状态通知
- 具体资源分享支持故事、通话文字和录音,分别设置接收人、权限、用途、时限和登录要求。
- 登录分享校验指定接收人;匿名分享只保存口令摘要;匿名模式禁止故事编辑。
- 故事共享编辑使用`If-Match`并形成新修订;文字分享不隐式包含音频。
- `/api/v1/events`使用认证SSE发送最小状态事件,支持`Last-Event-ID`短时续传和客户端去重。
### 导出与删除
- 数据访问请求只返回分类盘点和状态,不在列表中复制正文。
- 私有导出由Worker生成,保存至私有对象存储,提供短期下载和摘要校验。
- 主体删除先立即冻结访问、上传和召回,再对PostgreSQL、Redis、对象存储、pgvector、供应商索引和导出文件执行可重试对账。
- 删除证明只保存必要编号、状态、计数和时间,不保留已删除正文。
### 退出条件
- 无原录音授权或无播放/下载授权时不能取得录音地址,每次成功和拒绝均有审计。
- 分享撤销或过期后拒绝新访问;匿名原始口令不进入数据库和日志。
- 隐私删除在任一子系统短暂失败后可以继续重试,最终状态可查询且无正文残留。
## 4.8 阶段7:内部运营、配置、费用、告警与生产加固
### 目标
建立面向内部运营人员的脱敏工作台和生产运行能力,使系统可诊断、可配置、可控费、可告警、可恢复。
### 运营故障处理
- 建立`/ops/*`接口与独立运营角色:客服只读、运营操作、配置审批和平台管理员。
- 按脱敏设备编号和通话编号查询状态、错误、配置版本、用量和上传积压。
- 支持安全停止、设备吊销、后台任务重试、状态对账和脱敏诊断包。
- 临时查看敏感内容必须填写理由、有效期并经过审批;默认不显示字幕正文和原录音。
- 所有运营动作记录操作人、原因、操作前后状态和请求编号。
### 配置发布
- 人设、音色、模型和路由配置按`DRAFT → PENDING_APPROVAL → PUBLISHED → RETIRED`管理。
- 发布前执行字段完整性、资源引用、模型兼容性、费用上限和离线评测检查。
- 支持按客户组织、设备分组或测试长者灰度,并限制并发影响范围。
- 支持版本差异比较和回滚;新通话使用新版本,历史通话保留原配置快照。
- 页面只显示长期密钥引用名称和最后轮换时间。
### 用量、费用和额度
- 按通话归集RTC、端到端语音、ASR、LLM、TTS、存储和通话后处理用量。
- 供应商用量事件幂等去重;按带版本的单价快照计算,价格调整不改变历史费用。
- 配置客户组织每日/月度额度、同时通话数和单次最长时长。
- 达到硬额度时阻止新通话并返回稳定错误;在途通话按已发布策略停止或完成当前轮次。
### 监控、告警和恢复
- 业务仪表盘:通话成功、活跃通话、设备在线、上传、内容审核、隐私任务和费用。
- 技术仪表盘:API、Callback、Worker、数据库、Redis、对象存储、Outbox和供应商依赖。
- 安全仪表盘:认证失败、越权、重放、密钥/证书到期和高风险运营操作。
- 告警覆盖供应商不可用、启动/停止异常、遗留任务、数据库/Redis故障、通知积压、删除超期、上传失败和费用异常增长,并关联运维手册。
- 完成数据库恢复、长期密钥轮换、供应商故障和隐私删除四类演练。
### 专项验证
- 执行需求场景的接口、活跃通话、回调、SSE、媒体分片和并发删除专项压测。
- 执行租户隔离、录音越权、回调重放、提示注入、依赖漏洞、镜像和密钥扫描。
- 在Chrome、Edge和项目确认的其他目标浏览器完成桌面与平板兼容验证。
### 退出条件
- 错误配置在影响新通话前被阻止,回滚后新通话使用上一已发布版本。
- 每次通话的用量、价格版本和总费用可追溯且不重复计费。
- 告警有接收人、严重级别、静默规则、升级路径和运维手册链接。
- 备份可在隔离环境实际恢复,而不是只检查备份任务状态。
## 4.9 阶段8:真实联调、家庭试点与生产上线
### 目标
用真实设备、真实供应商账号和类生产基础设施完成联合验收,并安全发布生产。
### 联调顺序
1. 单设备实验室联调:入房、双向语音、心跳、挂断、录音、命令和固件。
2. 弱网与异常联调:丢包、断网、设备重启、API/Worker重启、供应商超时和重复通知。
3. 多设备稳定性:连续多轮通话、并发通话、任务遗留和资源回收。
4. 小范围内部试用:仅合成或经批准的脱敏数据,检查内容质量与运维流程。
5. 家庭试点:明确知情同意、退出方式、反馈渠道和每日风险复盘。
6. 预生产演练:部署、数据库迁移、回滚、备份恢复、密钥轮换和告警通知。
7. 生产发布:分批放量,观察业务、技术、安全和费用指标后逐步扩大。
### 上线门禁
- 首期需求、用例、缺陷和遗留风险可追踪,阻断级缺陷为0。
- 真实账号完成RTC、三种语音处理方式、字幕、状态、用量和配额验证。
- 客户组织隔离、录音越权、通知重放、遗留任务回收和隐私删除专项通过。
- 生产域名、证书、OIDC、对象存储、数据库、Redis、KMS、备份、告警接收人和应急联系人已配置。
- 业务方、研发方、测试方和硬件方签署联合验收与上线检查清单。
### 交付与移交
- 源代码、版本标签、构建说明、镜像和软件材料清单。
- OpenAPI、数据字典、迁移、架构、功能、流程和接口文档。
- 自动化测试、覆盖率、性能、安全、恢复、真机和试点报告。
- 部署、回滚、监控、告警、备份恢复、密钥轮换和故障处理手册。
- 账号权限、密钥引用、云资源、已知问题、值班机制和后续优化建议。
## 5. 关键业务流程与阶段依赖
```text
需求冻结
↓
工程框架 ──→ 身份/权限/审计/幂等/Outbox
↓ ↓
设备身份与配置 ──→ 通话创建/激活/心跳/停止 ──→ 真实供应商联调
↓ ↓
家庭/长者/授权/绑定 最终字幕/摘要/候选
↓ ↓
录音上传与访问 人工核对/正式记忆/检索
└──────────┬──────────────┘
↓
分享/导出/撤回/删除/SSE
↓
运营配置/费用/告警/恢复/安全
↓
真机试点 → 预生产演练 → 灰度上线 → 项目移交
```
关键依赖如下:
- 没有设备身份和绑定关系,不能开始真实通话。
- 没有分项授权快照,不能保存字幕、录音或生成长期记忆。
- 没有最终话轮来源,不能确认故事和事实候选。
- 没有配置版本冻结,不能做供应商问题、质量和费用追溯。
- 没有可靠删除与审计,不进入家庭试点。
- 没有真实账号、真机、告警接收人和恢复演练,不进入生产上线。
## 6. 测试与质量门禁
日常开发采用“功能完整、验证精简”的风险分层策略:每个子批次只验证本次改变的核心规则、接口契约、数据迁移和一条真实身份纵向链路,不为提高覆盖率重复补低价值用例,也不在普通功能批次重复执行全量压测、全浏览器矩阵或全仓回归。身份隔离、授权、幂等、通话状态、隐私删除、费用额度和密钥等高风险边界仍保留定向门禁;全量、安全、容量、恢复和真机矩阵集中在发布候选及上线前执行。
验证分为三档:
1. **日常必跑**:编译或类型检查、变更包测试、受影响的OpenAPI或数据库迁移检查、单条端到端轻量冒烟、运行服务就绪检查。
2. **条件触发**:只有改动涉及身份权限、状态机、数据删除、并发容量、外部供应商、浏览器交互或恢复机制时,才增加相应专项用例。
3. **发布候选必跑**:在计划发布的固定版本上执行一次全仓回归、安全扫描、关键业务全流程、容量与恢复验证,避免把高成本测试重复摊到每个小批次。
“功能完全可用”的最低判断标准不是用例数量,而是本次功能的入口、正常处理、一个关键失败分支、最终状态、用户可见反馈和数据清理均已闭环;任何一项缺失都不能仅凭接口返回成功宣告完成。
| 测试层次 | 每阶段要求 | 上线前要求 |
|---|---|---|
| 单元测试 | 只运行变更包及其直接依赖;新增或修正的核心规则至少一个正向、一个关键失败用例 | 核心规则无未解释失败,并运行一次全量单测 |
| 数据库测试 | 有新迁移时执行正向升级、单版回退和重升;仅对新增约束做定向事务验证 | 完整升级/回退方案和备份点验证 |
| 接口契约 | OpenAPI生成前端类型,检查破坏性变更 | Device/SaaS/Callback契约冻结 |
| 集成测试 | 只组合本次功能实际依赖的服务,不重复跑无关模块 | 类生产依赖组合通过 |
| 端到端测试 | 每个可见业务子批次一条真实身份纵向烟测并精确清理数据 | 家庭P0、通话、记忆、媒体、隐私和运营全流程 |
| 性能测试 | 普通功能批次不重复执行;仅在热点路径、容量参数或并发模型变化时定向运行 | 按需求场景完成容量、瓶颈和扩容报告 |
| 安全测试 | 依赖审计、越权、重放、敏感日志和配置门禁 | 渗透、密钥、镜像、租户隔离和隐私专项 |
| 恢复测试 | Worker重试和中间状态恢复 | 数据库恢复、密钥轮换、供应商故障和删除演练 |
| 页面测试 | TypeScript检查和生产构建;有明显交互或布局变化时才检查对应页面 | 目标浏览器、桌面和平板兼容矩阵 |
每个阶段的“完成”必须同时满足:代码合并、受影响迁移与接口同步、最小充分验证、页面或设备可见结果确认、文档同步和遗留风险登记。真实页面、真机、供应商或恢复验收只在本阶段功能依赖这些环境时执行;尚未取得外部条件的项目明确登记为“本地闭环、外部待验”,不能伪装为已经完成。
## 7. 项目管理机制
### 7.1 迭代节奏
- 采用两周一个迭代;每个迭代只承诺能够形成可演示纵向链路的事项。
- 每周维护需求追踪、风险、接口变更、数据迁移和外部依赖五张清单。
- 每个子批次先冻结接口和数据,再并行开发后端、前端、Agent与测试。
- 每次演示使用端到端场景,不以单模块接口成功代替业务闭环。
### 7.2 评审与变更
- 需求变更先评估业务、隐私、接口、数据库、固件兼容、运维和回滚影响。
- 破坏设备协议的变更发布新接口版本,不根据User-Agent猜测固件能力。
- 生产配置、数据库迁移和高风险运营动作执行双人复核。
- 未经真实环境验证的性能数字、兼容性和供应商能力必须标为待验证。
## 8. 风险与应对
| 风险 | 影响 | 预防与应对 |
|---|---|---|
| 真实供应商账号延期 | 语音、模型、用量和费用无法验收 | 先用模拟供应商完成契约与异常;账号到位后安排专门联调窗口 |
| 硬件或固件延期 | 无法验证音频、弱网、录音和命令 | 提供模拟设备和协议回放;硬件每日构建接入同一OpenAPI |
| 授权或隐私规则不明确 | 返工或合规风险 | 阶段0冻结数据分类、授权范围、保留和删除规则;高风险默认关闭 |
| 供应商事件重复/乱序 | 状态错误、重复费用 | 事件编号+正文摘要幂等、状态单向推进、定期对账 |
| 长事务调用外部服务 | 锁等待和不一致 | 业务意图+Outbox+可重试Worker,不跨网络持有事务 |
| 配置错误影响全部通话 | 大范围不可用或费用异常 | 草稿、审批、离线校验、小范围灰度、上限和一键回滚 |
| 内容候选被误当事实 | 错误记忆进入后续通话 | 来源必需、人工确认、版本化、授权门禁和召回过滤 |
| 删除只完成部分系统 | 隐私风险 | 立即冻结、分子系统状态、可靠重试、最小证明和定期对账 |
| 容量估计偏差 | 高峰不可用或成本失控 | 类生产压测、按模块指标定位、容量余量和分阶段放量 |
| 文档与代码漂移 | 联调失败和运维误操作 | OpenAPI生成、迁移检查和文档状态纳入CI门禁 |
## 9. 当前实施状态与后续顺序
截至2026-08-25,本地工程已经完成阶段1—7的本地开发与验证闭环。1000活跃通话、50 RPS持续30分钟、500回调/秒、500条SSE、10000媒体分片、100并发删除、隔离数据库恢复、进程/Redis恢复、供应商故障、安全扫描、真实Chrome/Edge兼容、全仓检查、跨功能烟测和无测试残留对账均已完成。后续顺序为:
1. 外部基础设施探针、运营预检台账、联调问题门禁、可归档技术验收快照、试点日/周证据和v3候选证据包均已完成;生产内容安全已具备本地前置拦截、托管HTTP策略、输入/输出双检和安全失败语义;远程CI供应链已具备绑定提交、运行、14项检查、双镜像摘要和三份SBOM的不可豁免门禁。`PILOT`周期、远程CI、真机固件、真实终端RTC六通矩阵、真实Agent生成/Embedding/内容安全质量、真实告警网关、云KMS/HSM、四类生产等价恢复、七类生产基础设施和Safari精简矩阵均不可由本地模拟报告放行。下一步在目标环境逐项替换证据索引中的`PENDING`。
2. 待真实火山RTC/豆包、大模型/Embedding账号、终端硬件和生产资源到位后,完成阶段8真机联调、家庭试点、预生产恢复与灰度发布。
实际完成证据与未完成项持续维护在[开发状态与门禁记录](development-status.md)中;本计划只定义执行顺序和退出条件,不用计划状态替代测试证据。
+255
View File
@@ -0,0 +1,255 @@
# 开发状态与门禁记录
> 更新日期:2026-08-25
> 当前接口契约版本:0.64.0
> 当前主里程碑:M8目标环境联调、试点与上线验收(外部条件待提供)
## 阶段状态
| 阶段 | 状态 | 已取得的证据 | 尚未完成 |
|---|---|---|---|
| 第一阶段:项目准备与基线冻结 | 本地研发基线完成 | `project-baseline.md`、ADR-0001、需求和技术栈已对应 | 真实硬件、供应商、OIDC、云资源和法务资料仍需业务方提供与确认 |
| 第二阶段:工程框架与环境搭建 | 核心工程完成 | 三个Go进程、React Web、OpenAPI、两版迁移、Compose、镜像构建、健康冒烟和本地检查均通过 | 远程Git地址、实际CI运行、dev集群持续部署和密钥扫描平台待外部环境确定 |
| 第三阶段:公共基础能力建设 | 已完成 | OIDC、租户隔离、权限、事务审计、幂等、outbox、OpenTelemetry、Prometheus指标、网络/用户/租户/设备分层限流、供应商启动容量保护、签名游标和ETag均已实现并通过测试;本地专项容量门禁已覆盖核心入口 | 参数和容量余量仍需在生产等价环境复测 |
| 第四阶段:端到端语音最小闭环 | 本地模拟闭环完成 | 设备身份、`bootstrap/config`、创建、三种语音模式冻结与供应商模板选择、激活、启动、心跳、停止、终稿补传、批量遥测、录音直传能力、命令结果、签名回调、断网重连、重复/乱序保护和后台异常回收均已完成 | 真实硬件、火山RTC和豆包账号联调需要业务方提供外部参数 |
| 第五阶段:SaaS核心业务开发 | 本地业务闭环完成 | 已完成家庭成员邀请与角色协作、长者、稳定事实、五类分项授权、设备读取/配置/绑定、可靠命令、签名固件灰度/暂停/回滚和逐设备升级进度、通话查询、双轨录音状态/授权/播放下载的数据库、服务、HTTP契约与React工作台;服务端隔离、ETag、审计、纵向烟测及Chrome/Edge兼容检查均通过 | 固件生产签名设施、macOS Safari和类生产容量仍待外部环境验证 |
| 第六阶段:Agent内容与记忆闭环 | 本地开发闭环完成,生产模型协议已接入 | 最终话轮、可切换本地/火山方舟内容生成器与Embedding、事务外模型调用、摘要、开放话题、故事/事实候选、人工版本审核、授权门禁、已确认记忆、索引失效、固定边界召回及React审核页面均已实现;端到端烟测通过 | 真实方舟API Key、内容效果与召回质量验收、模型评测和真实内容安全策略待完成 |
| 第七阶段:生产加固 | 本地证据持续收敛 | 记忆可靠删除、隐私访问/导出/删除、运营配置灰度、用量费用额度、告警处置闭环、可重试HMAC告警Webhook及不污染正式告警的接收器联通测试、三类Grafana仪表盘、证书到期监测、OIDC/方舟/RTC外部基础设施探针、本地密钥轮换、生产配置拒绝启动门禁、全部本地专项容量、隔离恢复、供应商故障、安全扫描及Chrome/Edge兼容均通过;主要业务核心包已达到70%覆盖率,供应商客户端达到关键分支85%目标 | 全仓覆盖率及HTTP公共层仍低于需求目标;真实告警网关和值班组映射、生产等价容量、云密钥托管和Safari仍待完成 |
| 第八阶段:联调试点与上线 | 本地准备完成,外部联调待执行 | 外部依赖预检、联调问题门禁、候选验收快照、阻断告警定位、接收器联通测试、内容安全TLS预检和按业务时区生成的试点日/周报均已形成可执行入口;本地真实OIDC、Worker、HMAC验签接收、六项技术门禁及日/周报下载通过 | 远程CI、真实投诉证据、告警网关和值班组映射、真实供应商/真机联调、家庭试点、预生产演练和生产发布 |
## 本轮验证记录
| 检查项 | 结果 |
|---|---|
| Go格式、`go vet`和单元测试 | 通过 |
| OpenAPI生成React类型 | 通过 |
| TypeScript类型检查和Vite生产构建 | 通过 |
| npm依赖审计 | 0个已知漏洞 |
| 当前轻量功能验收 | Web、API、Callbacks、Worker和模拟供应商入口均返回200;owner/editor/outsider三类OIDC账号映射与权限正确;完整语音状态链、终稿/内容/记忆闭环及家庭—长者—事实—授权SaaS操作链均通过,合成数据已清理 |
| 角色化工作台导航 | 登录后由统一业务导航按需挂载家庭业务、运行配置、费用额度、告警处置和联调诊断;普通客户账号仅显示家庭业务,运营账号按角色显示运营入口,避免五个大型页面同时请求和无权限空页面;生产构建、容器更新及Web/API/Callbacks/Worker就绪检查通过 |
| 登录会话续期 | Web已启用OIDC访问令牌提前60秒自动续期;续期成功由用户加载事件刷新服务端权限快照,续期失败或令牌过期时立即隐藏业务工作台并提示重新登录,避免继续展示过期权限和连续401;生产构建及容器资源生效检查通过 |
| 页面请求超时与取消 | 所有健康、查询和写操作统一设置15秒请求超时,页面切换或组件卸载仍可主动取消;超时显示稳定中文提示并释放按钮忙碌状态,避免依赖异常时无限等待和重复提交;生产构建、Web容器资源及API就绪检查通过 |
| Web静态交付策略 | 哈希JS/CSS资源返回一年缓存,`index.html`返回`no-cache`以便及时加载新版本;网页资源增加CSP、`DENY`防嵌套、`nosniff`、来源和摄像头/定位/麦克风权限策略,API代理保持原有响应头;Compose容器内`nginx -t`、Web反向代理和API直连就绪检查均通过 |
| Web运行时环境配置 | Web容器启动时根据`WEB_OIDC_AUTHORITY`和`WEB_OIDC_CLIENT_ID`生成不缓存的`runtime-config.js`,本地保留安全默认值;同一镜像可在dev/preprod/prod覆盖公开OIDC配置,无需修改源码或重新构建,HTTPS OIDC和对象存储已纳入CSP;自定义值和Compose默认值均完成容器内生成验证 |
| 后续验证策略 | 保留身份、权限、通话状态、删除、供应商回调等高风险门禁;停止单纯为覆盖率补低价值测试,功能变更后只运行相关包、主链冒烟及一次发布前全量检查 |
| 轻量功能就绪脚本 | `scripts/check-functional.ps1`固定检查九个必要服务、五个HTTP就绪/存活端点、API与Worker依赖指标、Outbox积压和近期错误日志;日常开发默认执行该脚本及变更模块定向测试,`scripts/check.ps1`仅用于发布候选和CI全量门禁 |
| 录音上传队列可视化 | 终端心跳和批量遥测均可上报待上传数、失败数、最近失败码和失败时间;设备影子接口返回脱敏最近错误,家庭端设备卡片显示无积压、待上传或失败状态及原因。OpenAPI 0.31.0类型生成、相关Go包定向测试、TypeScript检查和生产构建均通过 |
| 联调握手阶段耗时 | 诊断查询从通话权威创建时间和状态历史计算房间资料签发、设备入房确认、智能体启动、智能体停止和全程耗时;缺失或时间倒置的阶段不伪造数值。OpenAPI 0.32.0与React联调卡片同步展示,固定时间数据库集成用例验证250毫秒和2000毫秒阶段结果 |
| 短期凭证时间元数据 | V28只保存短期入房凭证签发时间和过期时间,不保存凭证正文;创建通话按实际Token TTL写入,联调查询和页面展示两个时间点。数据库约束保证过期晚于签发,OpenAPI 0.33.0不暴露完整凭证 |
| 短期凭证迁移验证 | V28正向升级、单版本回退至`27|false`及重新升级均通过;回退后两个字段为0个,重升后为2个,过期不晚于签发的异常记录为0,最终数据库为`28|false` |
| 心跳短期凭证续签 | 设备心跳上报当前凭证过期时间;进入续签窗口后,服务端原子轮换时间元数据并返回`renew_rtc_credential`及新的房间绑定短期凭证。响应丢失时旧过期时间会触发同一确定性凭证重放;签名失败会回滚心跳序号和时间元数据。OpenAPI 0.34.0、模拟终端和本地冒烟请求已同步 |
| 通话历史筛选与分页 | 家庭端通话记录现可按长者、八种通话状态、三种语音处理方式及起止日期组合筛选;首批加载20条,使用服务端签发的HMAC游标继续加载并在页面端去重,不拼接或解析游标。筛选变化会清理不再匹配的详情;SSE实时状态刷新只更新列表和业务数据,不再把用户正在查看的通话详情清空。前端类型检查、生产构建和容器运行检查按轻量策略执行。 |
| 首次创建家庭 | 家庭工作台补齐后端既有`POST /api/v1/households`的网页入口;无家庭账号可选择自己已有的客户组织并创建第一个家庭,创建事务同步建立所有者成员关系,页面随后重新读取服务端家庭清单并自动选中新家庭。已有家庭账号也可从折叠入口新增家庭,输入最长200字符且不接受空白名称;真实本地OIDC正向请求已验证创建响应、所有者角色和列表可见性,临时家庭及审计夹具随后按公开编号清理为0。 |
| 长者背景进入新通话 | OpenAPI 0.35.0明确背景、兴趣、交流目标、节奏和优先话题五类白名单字段;家庭端创建和强ETag修订均可维护这些资料。创建通话时按档案版本读取并冻结白名单上下文,内部备注和设备技术目标不会进入语音提示;Worker向供应商启动时使用同一冻结文本,提示明确背景资料不是可执行指令。真实PostgreSQL定向用例验证背景、目标、兴趣、节奏、话题及非白名单排除;真实OIDC HTTP验证创建后以`If-Match: "v1"`修订为v2,临时档案和审计夹具清理为0,运行Web容器已包含创建与修订界面。 |
| 首次通话就绪清单 | OpenAPI 0.36.0新增按长者读取的服务端就绪检查,依据当前档案、授权快照、有效绑定、设备注册/固件上报、配置版本和最近联系事实给出阻断项与提醒项;家庭工作台直接展示清单并跳转到档案授权或设备修复入口。额度、生产路由和并发通话只在真正创建通话时原子校验,页面明确不把瞬时结果误报为保证。按轻量策略仅执行该读取接口的真实PostgreSQL定向用例、TypeScript检查、生产构建及一次功能就绪检查。 |
| 故事来源跳转 | 故事审核详情使用服务端返回的稳定`conversation_id`提供“查看完整原始通话”入口;跳转后直接读取该通话详情、完整最终话轮、摘要、用量和当前用户可访问的录音状态,审核人员无需根据标题或时间猜测来源。变更只涉及React交互,按轻量策略执行TypeScript检查、生产构建和运行容器资源检查。 |
| 最低固件版本门禁 | V29为设备新增独立`minimum_firmware_version`,不再把`minimum_security_version`安全基线标识误作固件版本。设备引导明确返回兼容结果;创建通话在任何房间或供应商任务产生前按同一规则拒绝不兼容设备并返回HTTP 426及`FIRMWARE_UPDATE_REQUIRED`;家庭就绪清单和设备卡片同步显示策略。SemVer按标准顺序比较,非SemVer厂商版本仅允许精确匹配。V29单版本回退/重升、版本比较、设备引导、通话门禁、就绪查询和前端构建均按定向策略通过。 |
| 模拟录音删除闭环 | 模拟终端`media`场景在对象直传后轮询云端批次,只有分片进入`VERIFIED`且到达`local_delete_after`才释放内存副本并提交`DELETED`回执;校验失败、超时和超过等待窗口均给出明确结果,不删除`SIM_MEDIA_FILE`源文件。`MEDIA_LOCAL_DELETE_GRACE`默认1小时、本地Compose为1秒,便于轻量联调而不改变生产安全默认值。 |
| 联调问题清单 | OpenAPI 0.38.0和V30新增客户组织隔离的问题清单,按需求P0282保存问题现象、设备/通话/请求编号、发生时间、责任方、解决结果和回归结论。创建使用幂等键,维护使用强ETag;已解决状态必须填写解决结果,已验证或已关闭还必须填写回归结论。运营诊断工作台支持登记、筛选、接手、解决、回归、关闭和重新打开,记录不复制字幕或录音正文。 |
| 背景资料准备失败闭环 | OpenAPI 0.39.0和V31落实需求P0285:SaaS授权、固件、额度和路由校验通过后,若Agent背景白名单资料存在不可解析类型,平台仍在同一事务内保存`FAILED/context_preparation_failed`通话与失败快照,不创建RTC任务、不冻结用量预算、不投递启动事件。设备得到不可自动重试的HTTP 422及通话编号;同一幂等键重放同一结果。家庭通话详情和运营诊断工作台展示相同稳定失败码。按低强度策略仅执行背景字段解析与HTTP映射单测、V31迁移、前端类型/构建和一次真实HMAC+OIDC纵向烟测,夹具已恢复清理。 |
| 活动通话授权撤回停止 | OpenAPI 0.40.0落实需求P0287:撤回活动通话冻结快照正在使用的字幕、原录音、故事整理或个性化记忆授权时,SaaS撤回事务同步写入`STOPPING`状态、状态历史、脱敏原因和唯一`conversation.agent_stop_requested`可靠待办;预留房间尚未启动Agent时直接过期。Worker按固定供应商任务完成停止,设备心跳或遥测从PostgreSQL权威状态取得停止动作;家庭分享撤回不打断语音。按轻量策略验证相关Go包、OpenAPI类型、前端构建和一次真实OIDC+设备HMAC+模拟供应商纵向链路。 |
| 设备v2兼容与终端媒体不变量 | OpenAPI 0.41.0和V32落实P0280/P0281:接口变更说明与契约版本同步,CI比较目标分支并拒绝破坏性变化;同一v2内只用可选能力位启用新动作,不按固件版本或User-Agent猜测。`rtc_credential_renewal`在通话创建时冻结,旧终端省略后不收到未知续签动作;`engine_mode`只供云端诊断、成本和记录,模拟终端不解析、不据此改变采集或播放。按低强度策略只验证契约门禁、相关Go包、V32单版回退/重升、前端契约构建和一次轻量运行就绪。 |
| 智能体启动失败设备收敛 | OpenAPI 0.42.0落实P0062:设备确认入房后通过独立只读接口取得权威状态与`wait/continue/leave_room`动作;供应商永久拒绝启动时,Worker同一事务写入`FAILED/provider_start_rejected`、RTC失败、状态历史和系统审计并结束可靠待办,异步失败沿用`provider_start_failed`。模拟终端不再用新幂等键重复激活,而是在`leave_room`时携稳定失败码退出。该批次复用既有表结构,无需新增迁移。 |
| Agent固定评测与发布证据 | OpenAPI 0.43.0与V33落实P0252:新增版本化固定评测集、模拟供应商脱敏观测样例、SaaS证据采集器、评测执行器和受控登记命令,强制覆盖正常闲聊、长停顿、重复表达、姓名日期歧义、冲突事实、情绪话题、用户打断、网络波动、记忆召回和敏感信息十类场景。采集器使用OIDC权限读取每个用例的独立通话及最终话轮,自动计算完成状态、首答时延和最长回答,不复制字幕正文;称呼、记忆、虚构、来源和敏感处理必须由显式脱敏评分卡给出。登记前使用评测集和观测重新计算完整报告,再复核SHA-256和登记正文,为配置版本与报告生成确定性幂等键;任一输入单独篡改均拒绝发送,FAILED报告必须显式允许且不能满足发布门禁。服务端区分`GENERIC`与`AGENT_QUALITY_V1`,标准证据只能登记到Agent组合并校验报告元数据、十个以上用例、计数、全部质量指标及结论一致性;Agent组合发布仅接受标准`PASSED`,通用手工证据不能绕过门禁。模拟基线10/10通过;真实账号质量仍列为外部待验。 |
| Agent质量证据迁移验证 | V33正向升级、单版本回退至`32|false`及重新升级均通过;回退后`evidence_type`字段不存在,重升后字段和枚举约束恢复,最终数据库为`33|false`。纵向验证确认通用`PASSED`发布返回409,标准证据首次登记201、完全重放200且仅一条记录,随后Agent组合发布成功,临时配置残留为0。 |
| Agent组合组件引用门禁 | OpenAPI 0.44.0收紧P0214/P0254:四个组件字段必须是UUID,并在当前客户组织内分别指向已发布过的`PERSONA`、`VOICE`、`MODEL`和`ROUTING`不可变版本;提交审批保存组件校验快照,发布及回滚再次复核。错误类型、不存在、跨客户、草稿或待审批组件不能形成可发布组合;已被新版本替代的`RETIRED`组件仍可用于可追溯回滚。标准评测登记同时绑定目标组合编号和其`model_version_id`,另一组合或另一模型报告不能误用。真实OIDC纵向验证错误组件类型、错误组合绑定和错误模型绑定均返回400,正确证据首次201、重放200,组件快照4项并成功发布;临时配置残留为0。该批次不新增迁移。 |
| Agent组合运行时生效 | OpenAPI 0.45.0完成P0212/P0214/P0240/P0254的运行时交接:新通话按“试验对象 > 设备组 > 客户组织”优先命中`default-agent-bundle`投放,从组合引用的已发布或已退役路由版本取得语音模式,并冻结组合公开编号;未命中组合时兼容旧`default-agent-routing`。Worker按冻结组合读取同租户、正确类型的人设、音色和模型组件,把四个组件版本、系统提示、表达风格、音色资源引用和模型资源引用交给供应商适配器。真实PostgreSQL定向用例已覆盖选择、冻结、`cascade`生效和完整启动参数传递;无数据库迁移。 |
| 自定义Agent版本族投放 | OpenAPI 0.46.0修正运营发布到运行时选择的隐式键名依赖:`config_key`仅标识不可变版本族,运行时根据投放所绑定版本的`AGENT_BUNDLE`类型选择组合,不再要求键名必须为`default-agent-bundle`;未命中组合时同样按类型选择直接`ROUTING`投放作为兼容路径。范围仍按“试验对象 > 设备组 > 客户组织”,同范围按最近激活时间确定生效项。真实PostgreSQL用例使用自定义版本族键验证组合冻结、`cascade`和供应商参数传递;无迁移、无设备契约变化。 |
| Agent组合结构化创建 | 运营页面不再要求手工填写四个组件UUID和组合JSON;按当前客户组织列出已发布或已退役的人设、音色、模型和路由不可变版本,优先自动选择最近发布项,明确标记退役版本并在缺少任一类型时禁用创建。切换客户组织会清理未提交表单,避免跨客户残留选择;服务端提交与发布门禁保持最终权威。该批次仅涉及React交互和文档,不改变接口或数据库。 |
| 配置投放暂停与恢复 | OpenAPI 0.47.0与V34补齐运行配置投放止血能力:审批员/管理员可使用强`If-Match`暂停活动投放或恢复尚未被替代的暂停投放;暂停只阻止后续新通话命中,已创建通话继续使用冻结配置。恢复前复核配置仍为已发布、同键同范围无其他活动投放;新版本同时结束同范围活动或暂停旧投放,防止旧灰度复活。运营台账显示活动/暂停状态和行版本并提供对应操作。V34单版回退、重升、真实PostgreSQL服务状态机及HTTP纵向用例均通过;未修改设备端契约。 |
| 外部依赖预检台账 | OpenAPI 0.48.0与V35把API/Worker周期探针结果写入平台级状态台账:记录进程、依赖、可用状态、检查时间、最近成功/失败、耗时、探针周期、连续失败次数和有限错误码,不保存端点、凭据或原始错误。进程启动会清理本进程已经停用的旧探针,页面超过三倍周期未更新时标记“结果过期”,避免把已停止进程的历史成功误作当前可用。联调诊断工作台向全部运营角色提供只读预检卡片;本地OIDC真实探针、V35回退重升、失败累计恢复和真实浏览器查询按轻量策略验证。 |
| 联调问题验收证据门禁 | OpenAPI 0.49.0与V36在既有问题台账增加LOW/MEDIUM/HIGH/CRITICAL严重级别、根因、修复版本和脱敏回归证据;已解决、已验证状态分别执行必填门禁。清单接口基于当前客户组织全量记录汇总未关闭HIGH/CRITICAL,不受最近100条展示上限影响;诊断工作台展示严重问题、高优先问题和发布阻断状态。V36单版回退/重升、真实PostgreSQL状态机、真实OIDC HTTP闭环、Chrome页面及测试残留清理均按轻量策略通过,最终数据库`36|false`。 |
| 发布候选验收快照 | OpenAPI 0.50.0新增运营侧可下载技术预检快照,不新增迁移。快照在幂等审计事务中冻结候选版本/镜像摘要、运行构建、数据库迁移、所选必需外部探针、联调问题、租户及平台级Outbox和未解决HIGH/CRITICAL告警,计算六项独立门禁与总结果;实验室允许开发构建,预发布/生产要求运行版本与候选一致且构建元数据完整。输出不含探针端点、凭据、原始错误或业务正文,并固定声明不替代真机、供应商、灰度和发布审批。真实PostgreSQL定向用例、真实OIDC HTTP创建/重放/跨租户拒绝、Chrome下载解析与轻量功能就绪检查均通过,夹具残留为0;当前本地快照如实被3条既有HIGH中间态告警阻断,需按告警处置流程复核后关闭,未被测试脚本伪造为整体可发布。 |
| 验收快照阻断定位 | OpenAPI 0.51.0为技术验收快照增加最多100条阻断告警的公开编号、规则、级别、状态、关联资源及存在性证据,数量截断显式标记;不返回告警正文,也不因关联资源`MISSING`自动关闭。诊断页面在下载后直接展示六项门禁和阻断项,切换客户组织会清除旧结果。真实PostgreSQL定向用例用缺失合成通话验证`MISSING`关键失败分支;真实OIDC/Chrome先验证3条阻断定位,再确认其引用的合成通话、RTC任务和可靠任务均不存在,通过强ETag告警接口保留时间线并审计关闭,重新生成快照后六项门禁全部通过。无阻断时`blocking_items`固定返回`[]`而非`null`,下载夹具残留为0。 |
| SSE事件刷新合并 | 家庭工作台不再让每条SSE事件分别触发成员、长者、授权、内容、设备和固件整套查询;事件刷新按2秒最小间隔合并,人工业务操作仍立即刷新,410过期游标也复用同一合并队列。真实OIDC Chrome连接中分散注入8条最小事件,形成5批、85个业务响应,0个HTTP失败/429,页面无错误,事件夹具残留0。 |
| 告警Webhook通知 | 新告警与超时升级在告警事务内生成稳定去重Outbox任务;Worker支持HMAC-SHA256签名、2xx确认、网络/超时/408/425/429/5xx重试和永久4xx死信;空地址安全关闭。适配器、配置、真实PostgreSQL告警事务、Worker受控重放和四个应用就绪端点已完成轻量验证;真实网关仍需外部参数 |
| Docker Compose配置解析 | 通过 |
| 后端和Web容器镜像构建 | 通过 |
| 空数据库迁移 | 成功到版本2,`dirty=false` |
| PostgreSQL扩展 | `pgcrypto`、`vector`已启用 |
| API、Callbacks和Worker就绪检查 | 均返回HTTP 200,PostgreSQL与Redis为`up`;三个命令入口均已抽取可测试装配函数,集成用例验证服务名、上下文和启动错误透传,三个命令包覆盖率均由0%提升到20% |
| 交付与验收命令工具 | 固件签名工具已验证SHA-256、Manifest、Ed25519正向验签和篡改拒绝;SSE工具已验证Bearer、事件游标、`Last-Event-ID`重连、非200和取消;模拟终端已验证请求摘要/签名头、幂等键、WAV头和错误响应;模拟供应商已验证配置拒绝及随机端口优雅启停,四个命令包覆盖率为33.3%/57.7%/27.9%/67.7% |
| Web、MinIO和Keycloak访问 | 均返回HTTP 200 |
| 数据库迁移 | 成功到版本4,`dirty=false` |
| OIDC真实签名令牌 | `owner/editor/outsider`均通过签名、签发方、使用对象和有效期验证 |
| 未登录和伪造令牌 | 均返回HTTP 401及稳定错误码 |
| 客户组织隔离和角色权限 | 跨租户、查看者管理和家庭角色隐式录音访问全部被拒绝 |
| 当前账号与权限快照 | `/api/v1/me`已覆盖未认证401、所有者账号200、未建档账号403及数据库故障500;账号、角色与家庭能力快照响应新增`Cache-Control: no-store`,避免认证态权限信息被中间缓存 |
| 幂等执行 | 重放返回原结果;请求摘要冲突被拒绝;5个并发请求只执行1次 |
| 事务审计 | 业务回滚时审计同步回滚,业务提交时同步提交 |
| outbox可靠待办 | 去重、正文冲突、领取、完成和死信测试通过;容器Worker实测完成事件 |
| 核心模块集成覆盖率 | 身份85.5%、幂等75.6%、审计72.7%、outbox75.5% |
| 接口治理单元测试 | ETag并发控制100%、统一JSON响应封装100%、签名游标78.1%;HTTP公共层当前整体56.8% |
| Redis限流集成测试 | 突发限额、拒绝和令牌恢复通过,覆盖率81.2% |
| 可观测性容器烟测 | 三个服务就绪;Prometheus三个target均为`up=1`;Collector收到Span;日志包含trace/span编号 |
| Worker积压指标 | pending、processing、dead三种状态均可抓取 |
| 设备与通话迁移 | 主库到版本5且`dirty=false`;临时空库完成1—5全量升级、版本5回退及重新升级 |
| 通话状态机 | 主路径、准备失败、预留过期、启动失败、供应商同步/异步启动与停止、未知启动对账、主动结束、运行中失败、重复事件和终态防回退测试通过;供应商故障进入停止状态时允许空设备停止原因并使用受控原因继续收口 |
| 模拟设备夹具 | 合成设备、长者、当前绑定、设备影子和测试授权可重复初始化 |
| 设备身份与租户隔离 | 签名正文篡改、过期、重放、吊销和依赖故障分支有测试;跨租户配置查询被拒绝 |
| 设备启动纵向烟测 | Compose模拟设备使用真实HMAC和Redis nonce访问`bootstrap/config`均返回200,设备报告写入PostgreSQL |
| 设备接口契约 | OpenAPI 0.7.0可生成React类型;创建、激活、心跳、停止及回调契约均已冻结 |
| 通话创建集成测试 | 同键重放只创建1条通话、2条状态历史、1次审计和1个outbox事件;不同正文冲突;数据库不保存完整短期Token |
| 通话创建HTTP烟测 | 真实设备签名两次请求均为201,第二次标记重放,通话编号和Token一致;状态`ROOM_ISSUED`且RTC任务仅为`ALLOCATED` |
| 通话激活集成测试 | 校验设备归属、房间/用户、预留期限和配置版本;首次激活仅创建1个固定供应商启动待办,重复激活不重复投递 |
| 模拟供应商与启动回调 | 固定任务编号幂等启动;回调先验签再解析;相同事件重放成功,不同正文冲突,过时事件不导致状态回退 |
| 通话激活HTTP纵向烟测 | 创建201、激活202、同键重放带标记;Worker调用1次;模拟供应商发送2次相同回调但仅落1条事件;最终通话和RTC任务均为`ACTIVE` |
| 激活链日志与敏感信息 | 两次回调请求均有独立请求/追踪编号;API、Callbacks、Worker和模拟供应商日志未发现设备密钥或RTC Token |
| 心跳数据库集成测试 | 严格递增序号续租;旧序号不更新租约;设备报告版本高于期望版本被拒绝;设备影子只前进不回退 |
| 心跳Redis集成测试 | Lua仅接受更大序号,同时更新通话租约与设备在线状态;乱序刷新不能覆盖较新状态 |
| 心跳HTTP纵向烟测 | 真实设备签名返回200和`continue`动作;PostgreSQL/Redis均为序号2;随后序号1安全返回且数据库租约时间不变 |
| 主动停止集成测试 | 首次停止只产生1个供应商待办和1次审计;同键重放返回原结果;重复结束回调仅落1条事件,最终状态不回退 |
| 完整通话HTTP纵向烟测 | 六段状态链`PREPARING → ROOM_ISSUED → STARTING_AGENT → ACTIVE → STOPPING → COMPLETED`通过;Start/Stop各调用1次,热租约最终释放 |
| 后台异常回收集成测试 | 预留到期自动EXPIRED;租约到期自动STOPPING;卡在启动/停止的任务按时间桶投递幂等对账;4次恢复均形成系统审计 |
| 供应商失败回调集成测试 | `STARTING_AGENT`收到签名失败事件后,通话和RTC任务均进入`FAILED`;`ACTIVE`运行中失败进入`STOPPING`并可靠调用供应商停止,重复失败回执保持幂等且保留可诊断失败码;终态不回退 |
| 后台异常回收容器烟测 | 人工将合成房间预留设为过期后,5秒扫描周期内收敛为`EXPIRED|COMPLETED`并生成1条`conversation.recovery`审计 |
| SaaS核心迁移 | 主库到版本6且`dirty=false`;临时空库完成1—6升级、版本6回退及重新升级,新增档案字段、稳定事实和分项授权表 |
| 家庭与长者服务集成测试 | 所有者创建/修改、编辑者写长者、跨租户拒绝、旧ETag冲突、事务审计及合成数据清理均通过 |
| 分项授权服务集成测试 | 同类型有效授权唯一性在长者行锁事务中保证;授权追加、重复阻止、撤回幂等、历史查询及两版不可变快照均通过 |
| SaaS接口契约 | OpenAPI 0.13.0覆盖家庭、成员协作、长者、授权、设备、通话内容、故事审核、记忆管理、隐私数据访问、私有导出和主体删除接口,可生成React类型 |
| SaaS HTTP纵向烟测 | 真实OIDC令牌完成家庭创建、403隔离、角色拒绝、412旧版本、长者创建/修改、授权/重复/两次撤回和快照计数,测试数据已清理 |
| 设备绑定迁移 | 主库到版本7且`dirty=false`;临时空库完成1—7升级、版本7回退及重新升级;绑定码仅保存SHA-256摘要 |
| 稳定事实闭环 | 模型候选与家庭事实独立保存来源、确认状态、敏感等级和版本;确认缺少来源被拒绝,旧ETag写入返回412 |
| 设备管理服务 | 家庭内列表/详情、Redis在线状态与数据库降级、设备影子读写、非敏感配置白名单、敏感字段递归拒绝和事务审计均通过 |
| 一次性设备绑定 | 跨租户403、首次绑定、完全相同目标安全重试、单设备唯一有效绑定和历史绑定保存通过;烟测后绑定及审计已清理 |
| 通话查询工作台 | 长者维度游标分页、条件过滤、脱敏详情、冻结快照摘要和六段状态历史已实现;真实OIDC烟测验证跨租户403并清理合成数据 |
| React SaaS业务页面 | 完成家庭切换、长者档案、事实登记/确认、五类授权/撤回、设备绑定、在线状态、孪生配置、通话列表/详情和只读权限交互;生产构建与容器重建通过 |
| 家庭成员协作 | 一次性邀请创建/撤销/接受、跨客户组织受邀账号映射、成员列表、角色强ETag、成员移除、所有者保护及双向所有权转移均通过集成测试和真实OIDC烟测;邀请复用失败审计已验证 |
| 内容与记忆迁移 | 主库到版本8且`dirty=false`;临时空库完成1—8全量升级、全部回退及重新升级,九张内容与记忆表完整,验证库已删除 |
| 隐私请求迁移 | 主库到版本9且`dirty=false`;独立临时空库完成1—9全量升级、`down -all`和重新升级,最终`9|false`,验证库已删除 |
| 私有导出迁移 | 主库到版本10且`dirty=false`;独立临时空库完成1—10全量升级、`down -all`和重新升级,最终`10|false`,验证库已删除 |
| 设备命令迁移 | 主库到版本11且`dirty=false`;V11已完成单版本回退及重新升级,最终`11|false` |
| 最终转写接入 | 签名回调支持最终话轮;正文只在冻结字幕授权允许时保存;按业务键去重,同键不同内容冲突;供应商事件正文不复制转写内容 |
| 终端终稿补传 | `POST /device/v2/conversations/{id}/turns:batch`已实现设备归属、状态、留存模式和当前授权校验;1—500条批量终稿按业务键原子合并,完全相同计为重复、不同内容整批409回滚;幂等重放、越权设备、禁止留存和内容再提取事件均通过HTTP单元测试及真实PostgreSQL集成测试 |
| 设备批量遥测 | V24新增设备遥测事件历史及设备影子单调序号/最近错误快照;`POST /device/v2/telemetry:batch`原子处理状态、有限错误码和命令结果,验证关联通话归属、阻止旧状态覆盖和敏感结果字段,返回配置刷新/拉取命令/停止通话动作;V24回退重升、真实PostgreSQL集成测试及签名HTTP烟测均通过并清理夹具 |
| 可运行模拟设备 | `SIM_SCENARIO`现支持启动/配置、断网后新Nonce重连、完整通话、创建/停止/字幕/遥测幂等重放、字幕补交、遥测、命令结果和授权录音直传;Compose组合场景实测最终通话/RTC均COMPLETED、Start/Stop各1次、3条话轮、1条关联遥测、开放Outbox为0,验证夹具已精确清理 |
| 可重放供应商通知样例 | 模拟供应商新增API Key保护的`POST /mock/v1/admin/replay`,对已启动任务稳定重放任务开始、最终字幕、智能体回答、被打断、用量、错误和停止七类脱敏通知,并可控制1—10次完全相同投递;样例字段、打断标志、用量结构、签名及重复投递均有单元测试 |
| 摘要与候选生成 | 默认本地确定性适配器和可配置火山方舟Chat Completions适配器均只使用长者话轮;模型网络调用位于数据库事务外,返回后短事务复核状态、授权和话轮SHA-256指纹;生产内容安全采用本地高风险前置拦截+托管HTTP策略,输入生成前与模型输出落库前双检,BLOCK/REVIEW转受控提示,外部故障安全重试且不落库;生产配置拒绝本地策略、HTTP端点和占位密钥。相关轻量测试及真实PostgreSQL组合验证通过,真实账号策略效果待验收 |
| 生产记忆Embedding | API查询与Worker索引共用可配置本地/方舟Embedding提供器,方舟协议调用`${ARK_BASE_URL}/embeddings`并请求、强校验1536维float向量;索引在事务外生成向量后短事务复核版本、状态、授权和普通敏感级别;查询向量在通话事务前生成,临时失败无记忆降级但不阻断通话;顺序幂等重放和同键冲突不重复调用Embedding。协议、维度拒绝、数据库无长锁、单次计费语义及本地主链已完成轻量验证,真实召回质量待验收 |
| 火山RTC生产适配 | API新增官方二进制正式Token生成器,按房间/用户/到期时间确定性重建且不持久化短期Token;Worker新增AK/SK签名的`StartVoiceChat/StopVoiceChat`适配器,动态覆盖AppId/RoomId/TaskId/设备与Agent用户并追加授权背景,`Result=ok`或稳定TaskId已启动均收敛为ACTIVE,限流和服务端错误可重试,生产配置拒绝本地Token与Mock协议。完成Token格式/签名、协议映射和关键错误分类轻量测试,真实入房、可选供应商事件细分和计费仍待账号联调 |
| 三种语音处理方式路由 | 通话事务从活动运营投放冻结`s2s_pure/s2s_hybrid/cascade`,StartProcessor读取冻结值并传给供应商;火山适配器支持`voice-chat-template-bundle-v1`逐模式选择,生产Worker要求三模板齐全,生产API缺少活动路由时返回503而不再静默回退。三个模板选择、缺失模板拒绝、路由缺失门禁和数据库到供应商请求传播的相关测试通过;三种真实供应商Config及音质/费用仍待账号验收 |
| 逐设备生产密钥 | 新增受控目录型设备密钥解析器,数据库只保存`device-secret://<name>`;解析器限制单层安全名称、解析后根目录约束、4KiB上限和32字节下限,每次请求读取以支持Secrets/KMS CSI原子轮换;生产API拒绝单一静态开发密钥。本地解析、隔离、短密钥拒绝和无需重启轮换测试通过,真实KMS CSI挂载和量产注入仍待外部环境 |
| 通用Secrets文件注入 | 数据库连接串、Redis、RTC AppKey、火山AK/SK/临时Token、方舟、对象存储和各类签名密钥支持`NAME_FILE`绝对路径读取;普通文件与64KiB上限校验、末尾换行处理、NUL拒绝及明文/文件双源冲突拒绝在依赖连接前执行。文件读取与冲突轻量测试通过,云KMS工作负载身份和真实CSI轮换仍待外部环境 |
| Kubernetes生产部署基线 | 新增API、Callbacks、Worker、Web的Kustomize基线和stage/prod覆盖层,独立数据库迁移镜像与Job,预创建Secret只读挂载、逐设备密钥目录、启动/存活/就绪探针、Prometheus Pod发现注解、只读根文件系统、非root、资源限额、滚动发布、拓扑分散、PDB/HPA、TLS Ingress及默认拒绝入口网络策略;清单已完成本地渲染与对象结构校验,不包含真实密钥,真实集群、Ingress、Metrics Server、CSI和外部依赖仍待环境验收 |
| 外部基础设施探针 | API周期读取并验证OIDC JWKS状态、JSON结构和非空密钥集;启用方舟的API/Worker及启用火山RTC的Worker仅执行DNS、TCP和证书校验TLS握手,不消耗模型或语音额度;结果同时进入Prometheus指标和V35脱敏预检台账,连续5分钟失败触发CRITICAL告警但不影响readiness。本地Keycloak真实探针指标及运营页面均可见,方舟/RTC目标仍待生产网络验证 |
| Secrets Store CSI部署模式 | 新增生产应用和数据库迁移CSI覆盖层,API/Callbacks/Worker按职责挂载六个同命名空间`SecretProviderClass`,不使用`subPath`,迁移Job直接从文件读取数据库连接串;提供器参数、工作负载身份、轮换版本和真实挂载仍待目标云KMS/Secret Manager环境验证。逐设备密钥可按请求读取轮换值,其他启动期密钥明确要求轮换后受控滚动重启 |
| 内容安全基线 | 故事生成前识别验证码、支付账户和高敏感医疗关键词;命中时不复制敏感正文到摘要,不生成普通故事/事实候选;规则单元测试及带银行卡号/验证码的真实提取集成测试通过,生产内容安全服务仍待接入 |
| 故事人工审核 | 查看、人工修订、确认和拒绝接口均已实现;来源必需、角色权限、强ETag、版本留痕和事务审计门禁通过端到端验证;新增真实PostgreSQL集成测试覆盖摘要来源、故事/事实读取、人工修订、旧版本冲突、来源复制、确认门禁及终态重复确认拒绝 |
| 已确认记忆与索引 | 人工确认前记忆数为0;确认且个性化记忆授权有效后才创建版本化记忆;Worker完成1536维本地开发索引;修订生成新版本并使旧索引失效;停用和授权撤回会清空向量、标记索引失效;新增集成测试验证确认生成、关键词检索、跨成员隔离、内容修订、无变化拒绝及停用索引失效 |
| 记忆可靠删除 | 删除接口先将记忆置为`PENDING_DELETE`并立即移出召回;Outbox后台把本地索引置为`DELETED`、清除所有记忆版本正文、完成删除审计;页面可提交删除并显示处理中状态;删除申请及终态幂等重放已纳入真实数据库回归 |
| 新通话记忆召回 | 通话创建使用服务端固定租户和长者边界检索;同时过滤当前授权、活动状态、当前版本、普通敏感级别、有效期和活动索引;实际记忆版本与`minimal-v2`程序版本冻结到背景快照;真实pgvector测试验证普通记忆命中、敏感记忆排除、跨长者隔离和非法limit拒绝,召回模块覆盖率81.2% |
| React内容审核页面 | 通话详情展示摘要和最终话轮;故事与记忆页展示来源、事实、记忆搜索、人工修订/确认/拒绝、记忆修订/停用/删除及索引状态;查看者审核控件禁用;TypeScript和生产构建通过 |
| 内容端到端烟测 | 重复最终转写仅落2条话轮,生成2类整理结果和1个故事候选;人工确认后索引`ACTIVE`且下一次通话冻结1个记忆版本;修订形成第2版,停用和永久删除后新通话召回均为0;删除正文和索引状态已验证;跨租户403;测试后内容残留0且Outbox无pending/processing/dead |
| 隐私数据访问闭环 | V9迁移建立请求、子任务和最小删除证明表;所有者提交、编辑者拒绝、幂等重放/冲突、Worker状态收敛、请求范围过滤和`content_included=false`均通过真实OIDC HTTP烟测 |
| 私有数据导出 | V10迁移建立导出任务;Worker按冻结范围生成JSON并写入MinIO私有桶;未签名访问403、短期URL下载、SHA-256校验、所有者/编辑者权限、幂等重放和过期删除均通过HTTP烟测;真实PostgreSQL集成测试进一步覆盖范围化制品、摘要、READY、重复处理和到期清理,不存在任务的迟到Outbox事件已修正为安全空操作 |
| 主体级隐私删除 | 精确称呼确认、活动通话409保护、受理事务立即冻结、授权撤回/记忆失效、Redis/MinIO/pgvector/供应商引用/PostgreSQL六子系统可重试对账和无正文墓碑已实现;临时主体真实OIDC烟测通过并清理 |
| 设备命令可靠链路 | V11迁移、家庭端幂等排队、设备HMAC拉取、重复投递、ACK/成功/失败回执、过期终态和Web诊断入口已实现;真实本地OIDC+设备签名烟测通过并清理;新增处理器用例覆盖终端鉴权、命令拉取、可重试依赖失败、回执参数归一化、状态冲突及结果摘要透传;设备核心包进一步覆盖命令到期、首投/重投、ACK、成功/失败终态不可变、跨设备拒绝和非法正文,覆盖率75.5% |
| 固件安全发布 | V12迁移建立受信Ed25519公钥、固件版本、灰度批次和设备分配;合法签名与摘要校验、非法签名409、暂停取消未投递命令、暂停后下载拒绝、已验证版本回滚、设备下载摘要及最终状态收敛均通过本地OIDC+HMAC+MinIO烟测并清理;新增服务与HTTP集成测试覆盖公钥登记、发布准备/确认、对象摘要校验、设备范围下载、灰度启动和暂停 |
| 固件进度与管理页 | V13迁移把分配状态细化为`ASSIGNED/DOWNLOADING/INSTALLING/CONFIRMED/FAILED/ROLLED_BACK`并保存进度和最近回执;版本/批次列表、逐设备详情、React公钥登记、制品直传、灰度、暂停和回滚已实现;25%下载、80%安装、100%确认的接口回归通过 |
| 双轨录音上传与访问 | V14迁移建立上传批次、稳定分片、批次关联、PLAY/DOWNLOAD独立授权和访问日志;设备幂等清单、15分钟单对象直传、Outbox后台大小/SHA-256/WAV PCM16容器与时长校验、延迟本地删除、5分钟播放/下载、撤销与成功/拒绝审计均通过本地OIDC+HMAC+MinIO烟测;新增真实PostgreSQL服务集成测试覆盖上传重放、Worker验证、PLAY授权、DOWNLOAD拒绝、撤销及删除回执并精确清理夹具 |
| 录音设备删除回执与管理页 | V15迁移保存本地密文删除结果、时间和错误码;过早成功回执409、失败后重试、成功幂等和云端对象状态不变均通过设备签名烟测。React通话详情支持双轨状态、PLAY/DOWNLOAD独立能力、5分钟播放/下载、按成员/用途/时限授权、有效授权列表和立即撤销 |
| Web状态事件流 | V16迁移建立15分钟最小事件续传和设备在线状态表;会话、故事、媒体、导出、隐私、指令及设备上线/离线事件已实现。Bearer认证SSE、`Last-Event-ID`恢复、活动家庭成员过滤、跨租户/过期游标410、客户端去重和自动重连均通过烟测;新增真实PostgreSQL测试覆盖起始游标、有效/过期/跨用户游标、分页、过期清理及设备离线事件,模块覆盖率86.5% |
| 具体资源分享 | V17—V18已建立分享授权、匿名口令摘要和访问日志;登录定向与匿名口令访问、单项权限、幂等重放、撤销、过期、跨租户隔离、故事共享编辑/412并发保护、React管理页和逐次审计均已实现并通过烟测;新增真实数据库集成测试覆盖登录定向转写分享、匿名令牌、错误令牌、接收人不匹配、幂等重放、撤销、故事修订/版本/撤销、匿名故事、录音试听/下载/授权撤回和成功/拒绝访问日志,模块覆盖率78.5%;HTTP层新增鉴权、UUID、幂等键、匿名口令、`If-Match`及全部分享错误映射用例 |
| 资源分享迁移与页面验收 | V18单版本回退和重新升级均成功,最终`18|false`;实际Chrome 1280×900检查资源分享配置、登录/匿名提示和有效分享区,未发现横向溢出、控制台错误或失败请求 |
| 运营身份与配置版本 | V19新增`customer/ops_viewer/ops_operator/ops_approver/ops_admin`平台角色和五类运行配置版本;草稿幂等、结构/敏感键校验、双人审批、单一发布版、旧版退役、不可变回滚、事务审计及新通话配置冻结均通过真实OIDC+设备HMAC烟测 |
| 运营配置页面 | React工作台支持客户组织选择、五类配置JSON与密钥引用、草稿、审批/发布、回滚、版本历史和相邻版本正文对比;实际Chrome 1440×1100未发现失败请求、控制台错误或横向溢出 |
| 运营配置灰度与发布门禁 | V20新增评测证据、受控资源引用、设备灰度组和投放台账;无通过评测、凭据/音色/模型引用缺失、类型错误、已撤销或核验超过180天均阻断发布;租户、试验对象、设备组范围可并存,同一目标只保留一个活动投放;新增服务及HTTP真实数据库测试覆盖草稿幂等、越权、提交ETag、创建人自批拒绝、评测创建/查询、发布门禁、双人发布、投放查询、旧投放退役、不可变回滚、资源引用和设备组的租户/版本/状态/非法输入分支,运营配置模块覆盖率74.5% |
| 运行时灰度与影响控制 | 新通话按试验对象、设备组、租户优先级选择路由,设备组使用稳定哈希百分比分桶,并以冻结该配置版本的未结束通话数执行并发影响上限;真实设备HMAC烟测验证设备组优先命中、不可变回滚和背景快照版本冻结 |
| 运营投放与资源页面 | React工作台新增资源登记/撤销、设备组创建、活动投放台账、评测证据、范围/比例/并发上限配置;实际Chrome 1440×1100整页检查无失败请求、控制台错误或横向溢出 |
| 运营灰度迁移与清理 | V20完成单版本回退和重新升级;回退后恢复V19单发布版唯一索引并移除四类V20表,重升最终`20|false`;烟测后配置、投放、评测、资源、设备组、运营审计/幂等、分享、Web事件和Outbox open/dead均为0 |
| 用量费用与额度迁移 | V21新增价格版本/费率、额度策略、通话用量、冻结费用行和预算快照六类表;单版本回退验证表已移除且版本为`20|false`,重新升级最终`21|false` |
| 价格版本与核价 | 价格草稿幂等、创建人禁止自批、另一名审批人发布、旧版本退役和费用行版本/单价冻结已实现;七类用量使用精确十进制数量和微单位金额,真实签名回调验证总费用`CNY 0.132450` |
| 用量回调幂等 | `voice_chat.usage / FINAL`先验签再校验;同一事件完全重放返回duplicate且费用仍为7条,同事件编号不同正文返回409,回调事件不复制用量明细正文 |
| 额度与通话预算 | 客户组织/设备日月费用、并发和最长时长策略已实现;创建前按账期时区检查,达到硬额度返回429 `USAGE_QUOTA_EXCEEDED`;新通话冻结策略编号、限额、时长和在途动作 |
| 费用展示与脱敏 | 运营周期汇总和家庭单通话用量接口均只返回标准化分类、数量、单位、费用和币种,不返回供应商任务/事件或内部价格编号;跨租户及非家庭成员访问返回403;新增HTTP真实数据库集成用例,覆盖价格版本创建/查询、创建人自批拒绝、双人发布、额度策略创建/查询、汇总时间参数校验及`no-store`响应 |
| 用量费用运营页面 | React页面包含费用概览、七类费率草稿、双人发布、价格台账、费用构成、客户组织/设备额度和历史策略;实际Chrome 1440×1100未发现失败请求、控制台错误或横向溢出 |
| 告警处置迁移 | V22新增版本化路由、有限时长静默、客户组织事件和不可变动作时间线四类表;活动路由和未关闭指纹使用部分唯一索引,迁移库最终为`22|false` |
| 告警自动发现与归并 | Worker扫描死信、隐私删除超时、录音上传失败、固件灰度失败、日费用额度80%和语音任务卡滞;同客户组织同指纹事务加锁归并,重复命中只增加次数并刷新摘要 |
| 告警升级与静默 | 活动路由冻结接收组、响应/升级时限和运行手册;超时追加`ESCALATED`动作;规则/分类/资源静默最长30天,静默不丢命中,撤销或到期后仍存在的信号自动`REOPENED` |
| 告警人工处置 | `ops_operator`以上角色使用强ETag完成确认、缓解和关闭;首次确认记录负责人,每一步写动作时间线和事务审计;跨租户403、旧版本412、已关闭重发新事件均有纵向烟测;新增服务及HTTP真实数据库测试覆盖路由版本替换、静默幂等/撤销、运营范围/幂等头、缺失If-Match返回428、跨账号隔离、确认—缓解—关闭、过期版本、重复终态拒绝、告警归并、静默期间保留命中、撤销后重开及取消扫描,告警模块覆盖率73.4% |
| 告警运营页面 | React工作台提供四项概览、状态队列、资源/请求/运行手册上下文、完整时间线、路由版本和静默管理;实际Chrome 1440×1200检查无失败请求、控制台错误或横向溢出 |
| 告警接收器联通测试 | OpenAPI 0.52.0新增最近50次测试查询和活动路由测试创建接口;测试复用正式Webhook的Worker、HMAC、超时和重试分类,但使用独立Outbox事件且不写`alert_incident`。本地Compose新增验签接收器;真实OIDC纵向烟测确认`PENDING → DELIVERED`、唯一接收、幂等重放和测试数据清理,真实数据库接口测试及Chrome 1440×1200页面检查通过 |
| 发布候选证据包基础闭环 | `cmd/release-evidence`初版使用`release-candidate-evidence-bundle-v2`清单:候选版本/构建摘要必须与六项门禁通过的技术快照一致,预发布/生产要求真实源提交;当时九类外部证据逐项登记`VERIFIED/PENDING/WAIVED`。`PILOT`包强制同租户、同候选版本/构建摘要、同IANA时区、一个已结束自然周的7份连续日报和1份完整周报,并保留`complaints/REQUIRED`;缺日、重复日、周中快照、跨租户/候选、时区/窗口或脱敏策略不一致均拒绝打包。当前格式已由后续真机固件门禁升级到v3和十类索引。 |
| 试点运行日报 | OpenAPI 0.55.0要求候选版本和`sha256:`不可变构建摘要后,按IANA时区自然日生成脱敏运营日报,不新增迁移或服务端正文表。日报聚合设备在线、8类通话状态、供应商失败与通知积压、上传活动与积压、告警开闭和按币种费用;自然日活动与生成时刻状态分字段表达,不做汇率换算。候选身份进入响应、幂等和最小审计,并由PILOT证据包与验收快照强制匹配。时区/DST定向单测、Go包、前端构建、真实OIDC 7日报纵向链路和Chrome下载通过,烟测数据精确清理 |
| 试点运行周报 | OpenAPI 0.55.0要求候选版本和`sha256:`不可变构建摘要后,按周一开始的七个IANA业务自然日生成脱敏周报,不新增迁移或服务端正文表。周报从审计事件和权威业务表汇总人工修订率及样本、故事确认/拒绝、正式记忆版本、隐私请求固定分布、固件版本和配置修订差异;周中快照明确标记不完整,无比例分母时显示不可计算。因平台没有投诉权威表,JSON与页面固定要求附客服/CRM证据。候选身份由PILOT证据包与验收快照及7份日报强制匹配;DST/周一/基点测试、真实OIDC周报与119文件打包、Chrome下载和缺口提示通过,烟测残留为0 |
| 生产配置门禁 | `APP_ENV=prod`时拒绝HTTP OIDC、数据库明文传输、无Redis认证、对象存储非TLS、本地模拟供应商、占位密钥和API重复签名密钥;配置单元测试覆盖默认拒绝、合规通过和重复密钥拒绝 |
| 1000活跃通话状态 | 950条健康租约保持ACTIVE;50条过期租约全部进入STOPPING;每条只形成一个停止意图,无重复任务,夹具已清理 |
| 供应商回调长稳态 | 500次/秒持续10分钟,300000次请求、0%失败、0中断、p95 8.90ms、p99 11.81ms;300000个唯一事件全部处理/忽略,100组会话/RTC任务保持ACTIVE |
| 普通API长稳态 | 固定50 RPS持续30分钟,90001次请求、0%失败、0中断、p95 3.67ms、p99 4.12ms、最大278.57ms;每次响应的PostgreSQL/Redis状态均为up |
| SSE连接容量 | 500条认证SSE全部建立并保持5秒,50条使用`Last-Event-ID`断点重连成功 |
| 媒体清单容量 | 10批×1000分片准确落库和关联;批量SQL优化后平均0.12秒、p95/最大0.16秒,API容器内存峰值15.8MiB |
| 隐私删除并发 | 100个并发受理全部成功、HTTP失败率0%、提交p95 385.15ms;11.58秒内600个子任务、100个墓碑和100个主体冻结全部对账 |
| PostgreSQL隔离恢复 | 实际`pg_dump`/`pg_restore`到随机隔离库,迁移`22|false`、66张表、190个外键和关键计数一致;本地约5.14秒,临时库/备份已清理 |
| 进程与Redis恢复 | API/Callbacks/Worker重启恢复;停机期间Outbox仅执行一次;Redis 14个热状态键清空后由签名设备请求重建,PostgreSQL权威计数不变 |
| 供应商故障恢复 | V23持久化按租户/供应商的可用状态;连续临时失败暂停新启动但停止任务继续尝试;演练确认暂停后新外呼0、停止尝试2次、历史接口200、CRITICAL告警出现,恢复后全部收敛;V23单版本回退/重升通过 |
| 安全专项 | Go 1.26.6容器`govulncheck`可达漏洞0、npm高危审计0、最终镜像HIGH/CRITICAL已修复漏洞0、Gitleaks无泄漏;HTTP认证/授权/注入式输入/1MiB正文/回调签名/Nonce重放/安全头/日志过滤烟测通过 |
| 压测与告警基线 | k6固定到达率门禁覆盖50 RPS/30分钟和500回调/秒/10分钟,阈值为错误率/p95/p99;Prometheus规则覆盖服务不可用、5xx、p95、Outbox积压/死信、隐私部分失败及固件失败/停滞 |
| 浏览器兼容检查 | 实际Chrome 151和Edge 151在1440×1000、1024×900视口完成OIDC、运营工作台、认证SSE及8个SaaS页签检查;均为0横向溢出、0控制台错误、0失败请求,FCP分别124ms和116ms;Safari明确待macOS真浏览器补测 |
| 分层限流与供应商容量 | 网络、OIDC用户、客户组织和设备令牌桶已独立实施;供应商启动另有RPS/突发门禁和PostgreSQL顾问锁保护的跨Worker活动任务上限;单元及集成测试通过 |
| 供应商未知结果处理 | `UNKNOWN`启动任务先调用供应商查询;已活动则本地对账、仍启动则延迟确认,只有供应商确认404才使用原稳定任务编号重新启动;不再盲目换号重试。供应商HTTP客户端新增启动、停止、查询在成功、限流、5xx、永久错误、非法响应、网络中断和响应读取失败下的测试,包覆盖率90.4%,三类操作各90.5% |
| 后台任务重试分类 | 普通业务错误和未注册处理器立即死信;仅显式临时错误、超时、网络中断、临时供应商/对象存储/数据库错误按指数退避和随机变化重试;真实PostgreSQL测试验证永久任务为`dead`、临时任务为`pending` |
| 晚到终稿处理 | 供应商停止通知早于最终字幕时,原提取任务会等待;若终稿在通话终态后到达,则按通知摘要和最大话轮序号创建新的幂等提取版本;真实PostgreSQL顺序测试通过 |
| 证书库存与告警 | V25扩展受控资源引用支持`CERTIFICATE`及到期时间;30天/7天分级到期告警、供应商通知积压告警和真实PostgreSQL安全事件测试通过;V25单版本回退及重升最终`25|false` |
| 诊断作业恢复迁移 | V26为Outbox增加公开编号、人工重试次数、最近重试时间和操作人;V26单版本升级、回退到V25及重新升级均已通过,最终`26|false` |
| 运营敏感数据临时访问迁移 | V27建立单通话限时申请和逐次访问日志;最长4小时、两类最小能力、同申请人/通话单一开放申请、申请人禁止自批;V27升级、单版本回退到V26及重升最终`27|false` |
| 监控仪表盘 | Compose已装载Grafana 12.2.0,业务/技术/安全三个仪表盘均已发现;Prometheus对API、Callbacks、Worker三个目标均为`up` |
| 本地长期密钥轮换 | 新设备签名密钥通过环境注入生效、旧密钥401、镜像摘要不变,演练完成后原本地密钥和API服务已恢复;不宣称替代生产KMS/HSM验证 |
| 集成诊断工作台 | OpenAPI 0.34.0和React工作台支持五类精确检索、短期凭证时间元数据、握手阶段耗时、配置刷新、安全停止、状态对账、管理员设备吊销、已注册死信受控重试和脱敏JSON诊断包;活动设备被吊销时同步进入可靠停止,未知事件类型不允许人工重试;真实数据库用例覆盖精确检索、时间元数据、配置刷新、受控停止和对账 |
| 敏感数据临时提权 | React工作台和API支持单通话字幕读取/录音播放申请、另一人审批、申请人绑定、到期自动失效、原录音授权复核、5分钟播放地址及逐次成功/拒绝审计;新增HTTP真实数据库纵向用例覆盖申请、幂等重放、审批队列、另一人批准、受控字幕、短时录音地址及全部稳定错误映射,处理器覆盖率72.2%—100%,夹具已清理 |
| Web容器安全基线 | Trivy发现原Web镜像root运行和旧Nginx/Alpine高危漏洞后,已改为UID 101、8080非特权监听、Nginx 1.30.4及构建时系统补丁;配置误配和最终Web镜像HIGH/CRITICAL复扫均为0 |
| CI供应链门禁 | 工作流已加入固定版本/摘要的govulncheck、go-licenses、npm audit、前端许可证检查、Gitleaks、Trivy配置/镜像扫描、Syft CycloneDX SBOM及不可变构建元数据;新增离线一致性校验器把完整提交、远程运行、14项检查、后端/Web镜像摘要和三份SBOM绑定为不可豁免报告,本地工具链保持`external_validation_required=true`;Go 1.26.6容器漏洞扫描、Gitleaks及最新Worker/Callbacks镜像HIGH/CRITICAL扫描均为0,仍待远程托管CI固定候选运行 |
| 全仓质量门禁 | V28代码已通过既有V27全量基线,并新增OpenAPI 0.34.0类型生成和相关模块定向验证;完整发布候选仍执行`scripts/check.ps1`。主要业务核心包已达到70%,但全仓、HTTP公共层和入口装配仍低于需求目标,不能标为整体已满足 |
| 最终残留对账 | 当前数据库为`28|false`;新增设备命令、SaaS命令、告警检测、运营提权服务及HTTP纵向测试夹具残留均为0,Outbox open/processing/dead均为0且仅保留历史`completed`;既有安全基线保持不变,当前增量按轻量策略验证 |
| V14跨功能回归 | 双轨媒体上传/校验/访问审计、固件签名/拒绝/列表/暂停/回滚及升级进度、设备命令和主体隐私删除均通过;烟测后媒体、访问授权、固件记录及Outbox open/dead均为0,服务日志未发现panic、fatal或受检密钥名称 |
| 主体删除页面可用性 | 家庭工作台已把连续浏览器弹窗替换为页面内不可逆操作确认区:明确六类删除对账范围和活动通话保护,删除原因限制500字符,称呼必须与当前长者完全一致才启用提交,错误称呼即时提示,取消后清空敏感输入。父级动作返回实际成功状态,只有删除对账完整完成才关闭确认区;失败时保留输入和页面错误供复核。真实OIDC Chrome轻量检查验证错误称呼阻断、精确称呼放行、取消清理、桌面/平板无溢出、无控制台错误和非预期失败请求;检查不实际提交删除,不改变种子主体。页面里程碑同步为M8联调、试点与上线准备 |
| 记忆管理页面可用性与删除终态 | 故事与记忆页已用主详情区替换记忆编辑、启停和删除的浏览器弹窗,展示资源/正文版本、索引、来源、最近变更及完整正文;修订必须正文变化且填写原因,启停说明索引与授权影响,永久删除必须填写原因并勾选不可逆确认。`PENDING_DELETE/SUPERSEDED/DELETED`只读;服务端新增强制终态校验,直接调用PATCH也不能把删除中记忆恢复为ACTIVE。真实PostgreSQL生命周期用例证明删除中重激活返回冲突;临时记忆夹具的真实OIDC Chrome检查验证六项按钮门禁、零PATCH/DELETE请求、无溢出/控制台错误/失败请求,夹具在`finally`精确清理 |
| 设备音量配置页面可用性 | 设备管理页已移除写死的“设为60%”动作,改为0—100逐级滑块;同时展示云端期望值和设备上报值,未配置时明确标识并以60%作为首次建议值,已有相同配置时禁止重复保存。提交保留其他期望配置字段,成功后直接回显服务端递增版本;服务端新增扬声器音量整数范围校验,继续执行顶层分类白名单、敏感字段递归拒绝和强ETag并发保护。按轻量策略完成输入校验单测、设备影子真实PostgreSQL定向测试、TypeScript生产构建和真实OIDC Chrome无写入冒烟;页面无横向溢出、控制台错误或失败请求 |
| 通话详情加载与恢复可用性 | 通话列表和详情已把语音处理、保存策略及状态来源转换为家庭业务名称。每次打开详情会取消上一条未完成请求并立即清理旧内容,核心接口失败时在右侧显示错误和“重新加载”,不会继续展示上一条通话或把错误混入长者资料;仅保存字幕或完全不留存的通话不再调用录音媒体接口。临时通话夹具的真实OIDC Chrome故障注入验证首次详情请求失败、旧详情隐藏、页面内重试恢复、选中态保持和业务标签展示;除预期注入失败外无失败请求、横向溢出或控制台错误,夹具在`finally`精确清理 |
| Web登录会话失效恢复 | 所有认证API的HTTP 401统一触发页面级会话失效,不再只停留在家庭或运营子页面。客户端合并并发401,清除`sessionStorage`旧OIDC用户、卸载全部业务工作台并取消在途请求;身份区显示明确原因和“重新登录”。已保存用户过期、令牌到期及静默续期失败走相同清理流程,403和普通业务错误不会误触发退出。真实OIDC Chrome轻量检查注入一次家庭接口401,验证旧会话条目归零、业务区卸载、预期请求取消、重新认证后账号与工作台恢复,无非预期失败请求、控制台错误或横向溢出 |
| 工作台URL导航与恢复 | 家庭业务保持根地址,运行配置、费用额度、告警处置和联调诊断使用可恢复的`workspace`查询参数;点击入口写入浏览器历史,刷新、直接地址、后退和前进均恢复正确工作台。URL不授予权限,客户端等待云端账号加载后校验运营角色,无权访问或未知值回退家庭业务并规范化地址;会话重新认证后权限不变则保留原工作台。真实OIDC Chrome按人工节奏验证配置/费用切换、刷新、前进后退、未知值和告警深链,零429、失败请求、控制台错误及横向溢出 |
| 运营敏感访问申请页面可用性 | 联调诊断工作台已用页面内申请表单替换单通话敏感访问创建时的浏览器弹窗:表单固定当前通话,字幕读取强制最小能力,录音播放按需勾选,理由必填且最长500字符,有效期限定15分钟/1小时/4小时,并明确申请不等于授权、禁止录音下载。取消会清空草稿且不创建申请。OIDC回调同时安全恢复登录前站内相对路径,拒绝跨源、协议相对和回调页返回地址。真实OIDC Chrome从未登录联调深链验证全部表单门禁、取消关闭、零创建请求、零横向溢出、零控制台错误和零失败请求;临时通话夹具已精确清理 |
| 联调受控操作页面可用性 | 配置刷新、状态对账、安全停止和设备吊销已用页面内受控操作确认区替换浏览器原因/确认弹窗;确认区固定显示目标设备,通话类操作同时显示目标通话,理由必填且最长500字符。安全停止和设备吊销增加影响确认,重新检索或切换客户组织会清除旧目标,取消不发送请求。真实OIDC Chrome复用临时诊断夹具验证四项门禁、目标绑定、危险操作确认、零修改请求、零横向溢出、零控制台错误和零失败请求,夹具已精确清理;服务端角色、租户、状态、幂等和审计边界保持不变 |
| 诊断包导出与死信重试页面可用性 | 脱敏诊断包导出和死信任务受控重试已改为页面内表单。导出表单冻结打开时的中文检索字段和精确值,明确文件脱敏边界和导出审计,理由必填且最长500字符;重试表单固定任务公开编号、注册事件类型、聚合类型、尝试次数和稳定错误码,理由必填并要求确认根因或依赖已经处理。重新检索、刷新任务、切换客户组织或取消会清除相应旧草稿。真实OIDC Chrome使用临时通话和`platform.noop`死信夹具验证全部门禁、目标绑定、零导出/重试请求、零横向溢出、零控制台错误和零失败请求,夹具已精确清理 |
| 联调问题状态流转页面可用性 | 调整级别、接手处理、登记解决、登记回归、关闭问题和重新打开已统一为页面内流转编辑区。编辑区固定问题编号、当前状态、责任方、现象和ETag版本,并按动作显示不同必填字段;同级别禁止重复保存,解决要求根因/修复版本/结果,回归要求脱敏证据/结论,关闭要求完整复核确认,重新打开要求责任方。刷新、筛选、切换客户组织或取消会清除草稿,服务端If-Match并发保护保持不变。真实OIDC Chrome使用覆盖OPEN/IN_PROGRESS/RESOLVED/VERIFIED/CLOSED的五条临时问题验证六项门禁、目标版本、零PATCH、零横向溢出、零控制台错误和零失败请求,夹具已精确清理 |
| 敏感访问审批页面可用性 | 临时敏感访问的批准和拒绝已改为页面内审批区,固定展示申请编号、版本、申请人、通话、能力、到期时间和原始理由。批准依据与拒绝原因均必填且最长500字符;批准还必须确认非本人申请、单通话、最小能力、有效期及原申请人绑定,拒绝明确提示说明范围不足。刷新、切换客户组织或取消会清除审批草稿;页面隐藏本人申请的审批入口,服务端继续执行禁止自批、角色、租户、状态和到期校验。真实OIDC Chrome使用另一用户的PENDING夹具验证批准/拒绝门禁、目标绑定、零审批请求、零横向溢出、零控制台错误和零失败请求,夹具已精确清理 |
| 通用离线评测登记页面可用性 | 非Agent组合配置的通用离线评测已用配置版本内联表单替换连续浏览器弹窗,固定展示配置编号、类型、版本族、版本号和行版本。套件版本最长100字符、报告引用最长300字符且均必填;通过/失败默认不选并必须显式决定,通过还需确认报告指标支持结论及可能参与发布门禁,取消不会被误登记为失败。Agent组合仍不显示手工通用评测入口。真实OIDC Chrome使用临时ROUTING草稿验证字段/结论/二次确认门禁、零评测POST、零横向溢出、零控制台错误和零失败请求,配置及评测夹具已精确清理 |
| 资源撤销与配置投放控制页面可用性 | 资源引用撤销、配置投放暂停和恢复已统一为页面内受控操作区。资源操作固定公开编号、名称、类型、引用和行版本;投放操作固定公开编号、配置键、范围、状态、比例、并发上限和行版本。三类操作均要求最长500字符的原因及影响确认,明确资源撤销不删除外部密钥、不改写历史会话,暂停/恢复只改变后续新通话命中。切换客户组织、刷新或取消会清除草稿,服务端角色、租户、状态与If-Match门禁保持不变。真实OIDC Chrome复用一条低强度无提交冒烟验证三类目标绑定、字段/确认门禁、零PATCH、零横向溢出、零控制台错误和零失败请求,临时资源、版本及投放夹具已精确清理 |
| 价格版本审批发布页面可用性 | 价格草稿审批发布已用页面内双人审批确认区替换浏览器输入弹窗,固定展示价格版本编号、供应商、币种、费率项数、生效时间、创建人和行版本。发布依据必填且最长500字符;提交前必须确认本人不是创建人,且报价/合同、全部费率和生效时间均已复核,并明确旧版本退役但历史费用不重算。切换客户组织、刷新或取消会清除草稿,服务端角色、禁止自批、状态和If-Match门禁保持不变。真实OIDC Chrome使用另一账号创建的临时价格草稿验证目标绑定、原因/确认门禁、零发布PATCH、零横向溢出、零控制台错误和零失败请求,价格及费率夹具已精确清理 |
| 告警状态处置页面可用性 | 确认并接手、记录缓解和验证关闭已用告警详情内联处置表单替换浏览器输入弹窗,固定展示告警编号、规则、分类、级别、当前状态、命中次数和行版本。处置说明必填且最长1000字符;关闭还要求确认缓解措施、恢复指标、关联影响及遗留风险均已复核。重新筛选、切换客户组织、选择其他告警、刷新或取消会清除草稿,服务端角色、租户、状态和If-Match门禁保持不变。真实OIDC Chrome使用OPEN/ACKNOWLEDGED/MITIGATED三条临时告警验证目标绑定、字段/关闭确认门禁、零状态POST、零横向溢出、零控制台错误和零失败请求,夹具已精确清理 |
| 告警测试、重试与静默撤销页面可用性 | 接收器联通测试、失败测试受控重试和静默提前撤销已统一为页面内受控操作区。联通测试固定活动路由、接收组、运行手册和行版本,并明确测试原因只进入审计、不外发且不会创建正式告警;重试固定任务编号、测试关联号、尝试次数、人工重试次数和错误码,要求确认依赖问题已修复;静默撤销固定静默范围、窗口和行版本,明确仍在发生的信号会重新进入队列。三类原因最长500字符并要求影响确认。真实OIDC Chrome使用临时活动路由、静默和失败测试任务验证全部门禁、零发送/重试/撤销POST、零横向溢出、零控制台错误和零失败请求,夹具已精确清理 |
| 家庭高影响操作页面可用性 | 授权撤回、成员移除、家庭所有权转移、固件批次暂停、固件版本回滚和分享撤销已统一为页面内受控操作区。表单固定家庭/长者、成员、授权策略、固件批次与目标版本、分享资源及行版本,并明确各操作对访问、记忆、在途设备和离线副本的影响;成员移除和所有权转移要求逐字输入目标成员显示名,其余操作要求显式影响确认。真实OIDC Chrome以不提交方式验证六项表单的目标绑定和门禁,确认零业务变更、零横向溢出、零控制台错误和零失败请求;临时固件及分享夹具已精确清理,种子授权和成员状态未改变 |
| 配置版本受控流转页面可用性 | 配置草稿提交审批、待审批版本发布和已退役历史内容回滚已改为具体版本卡片内的全宽受控流转区,固定展示版本编号、类型、配置键、状态、创建人、变更说明和行版本。发布/回滚范围、比例、并发上限和依据只保存在当前目标草稿内,修改范围会清除影响确认;页面明确提交不等于发布、发布只影响后续新通话、回滚创建新不可变版本。真实OIDC Chrome使用三条临时状态夹具验证目标/范围/确认门禁、取消关闭、数据库状态不变、零流转请求、零横向溢出、零控制台错误和零失败请求;首轮截图发现窄列不可读后已修复为全宽布局并复验,夹具已精确清理 |
| 邀请撤销与设备诊断页面可用性 | 待接受邀请撤销和设备诊断下发已从单击即执行改为家庭工作台顶部受控操作表单。邀请表单固定家庭、接收人提示、角色、编号、到期时间和状态,明确一次性凭据失效但现有成员不受影响;诊断表单固定设备、长者、软硬件版本、在线状态和配置/记录版本,明确音频/网络/存储范围、5分钟有效期及不重启不改配置。两项均要求显式确认,取消或业务上下文变化会清空草稿。真实OIDC Chrome使用一条临时邀请和现有模拟设备验证目标/确认门禁、取消关闭、邀请与命令数量不变、零POST、零横向溢出、零控制台错误和零失败请求;邀请夹具已精确清理 |
| 成员角色与稳定事实确认页面可用性 | 成员角色变更已从下拉即保存改为受控表单,固定家庭、成员、当前角色和行版本,目标角色改变会清除旧确认,并展示管理员/编辑者/查看者的实际能力及高敏感资源不随角色授予的边界。待核对事实确认也改为受控表单,固定长者、事实正文、来源引用、敏感等级、状态和行版本,明确只影响后续新通话且模型推断必须人工复核。真实OIDC Chrome使用种子编辑者和一条临时待核对事实验证目标/角色变化/确认门禁、取消关闭、成员及事实版本不变、零PATCH、零横向溢出、零控制台错误和零失败请求;事实夹具已精确清理 |
| 录音授权撤销与隐私盘点/导出页面可用性 | 录音访问授权撤销、八类隐私数据盘点和私有导出已从单击即提交改为家庭业务受控操作表单。撤销表单固定长者、成员、权限、用途和有效期,并提示已签地址最多仍有效5分钟及离线副本不可远程收回;盘点明确只返回数量和状态、不复制正文;导出明确包含私密正文、使用短期地址并需受控保存销毁。三项均要求显式影响确认,取消或上下文变化会清除草稿。真实OIDC Chrome以一次无提交轻量冒烟验证目标/八类范围/确认门禁、零业务请求、三类数据库记录不变、零横向溢出和零非预期浏览器错误;通话媒体403为快照未授权下的预期安全门禁,临时通话与授权夹具已精确清理 |
| 录音与私有导出敏感地址签发页面可用性 | 录音分片播放、录音分片下载和READY私有导出下载已从单击即签发地址改为页面内受控操作。录音表单固定长者、通话、轨道、分片、时长、大小和用途,区分浏览器播放的旁听风险与本地声音文件的保管责任;导出表单固定任务编号、八类范围、大小和对象过期时间。三项都在确认后才调用服务端,服务端继续二次核验权限、原录音授权、分片或任务状态。真实OIDC Chrome使用一条已验证分片、最小PLAY/DOWNLOAD授权和READY导出任务执行无提交轻量冒烟,验证全部目标/确认门禁、取消关闭、零媒体POST、零导出详情GET、访问日志及任务不变、零横向溢出/控制台错误/失败请求;夹具已精确清理 |
| 录音能力与具体资源分享创建页面可用性 | 录音PLAY/DOWNLOAD显式授权和故事、通话文字、录音分片分享创建已接入家庭业务受控操作。录音确认区固定长者、成员、能力、用途和到期时间,明确授权按长者覆盖有效期内当前及后续录音、同类旧授权会被替换;分享确认区固定具体资源、接收人、单项能力、用途、到期时间和登录方式,分别说明登录接收边界与匿名口令等同凭据、只显示一次和分渠道转交要求。取消或上下文变化不创建授权,打开/取消新分享也不提前清除旧匿名口令。真实OIDC Chrome使用合成故事、通话、已验证录音分片及最小当前账号录音授权验证录音授权和三种分享资源、匿名/登录两种模式的目标/确认门禁,零创建POST、两类授权表数量不变、零横向溢出/控制台错误/失败请求;夹具已精确清理 |
| 成员邀请与设备绑定一次性凭据页面可用性 | 成员邀请创建和设备屏幕绑定码消费已接入家庭业务受控操作。邀请确认区固定家庭、接收人提示、目标角色和七天有效期,明确提示不绑定真实身份、实际凭据持有者加入家庭、明文只显示一次及泄露撤销要求;设备确认区固定家庭和长者,只显示绑定码掩码/长度/末四位,明确云端不生成设备码、绑定建立唯一长期关联且错误目标不能复用同一码改绑。设备输入增加16—128字符门禁,取消或上下文变化清空完整码;邀请取消不生成凭据。真实OIDC Chrome无提交轻量冒烟验证两项目标/确认门禁、短码阻断、设备码不回显、取消清理、零邀请/绑定POST、邀请和绑定表数量不变、零横向溢出/控制台错误/失败请求 |
| 创建家庭与接受邀请关系控制页面可用性 | OpenAPI 0.56.0下,创建家庭和接受成员邀请已接入家庭业务受控操作。创建确认区固定客户组织、当前账号和家庭名称,明确当前账号成为所有者以及初始家庭不自动包含长者、授权、设备或通话。接受邀请新增登录态只读预览接口,按一次性凭据展示目标客户组织、家庭、接收人提示、角色和到期时间,响应禁止缓存且页面只显示掩码凭据;正式提交仍原子复核邀请和成员状态。定向Go测试、前后端生产构建及一次真实OIDC Chrome无提交轻量烟测通过;烟测仅产生一次预览POST,创建/接受写请求为0,取消后家庭、成员和邀请状态不变,无横向溢出、控制台错误或失败请求,临时邀请家庭夹具已精确清理 |
| 长者档案与稳定事实创建页面可用性 | 新增长者长期档案和家庭成员登记稳定事实已接入家庭业务受控操作。档案确认区固定家庭、称呼、背景、兴趣、交流目标、节奏和优先话题,明确个人资料长期保存、仅影响后续新通话且不等同分项授权;事实表单新增普通/敏感/高度敏感选择,健康背景默认提升为敏感,确认区固定长者、完整内容、家庭录入来源、敏感等级和待核对状态,并明确不会自动确认或创建记忆。前端生产构建、Web镜像重建及一次真实OIDC Chrome无提交轻量烟测通过;两类创建POST均为0,取消后长者和事实表数量不变,无横向溢出、控制台错误或失败请求 |
| 档案修订与设备期望配置页面可用性 | OpenAPI 0.57.0下,长者档案修订和设备扬声器期望配置已接入家庭业务受控操作。档案表单无变化时禁止提交,确认区逐项展示当前版本到下一版本的旧值和新值,明确只影响后续新通话、不扩大授权且强ETag冲突不覆盖;设备确认区固定设备、长者、期望旧值/新值、上报实际值和期望版本变化,明确离线领取与实际应用边界。修复设备配置覆盖缺陷:网页仅发送白名单增量,服务端按顶层分类合并,保留未提交的历史编码、采样率和心跳配置。真实PostgreSQL定向用例、前后端生产构建及一次真实OIDC Chrome无提交烟测通过;两类PATCH为0,取消后档案和设备影子版本/正文不变,无横向溢出、控制台错误或失败请求 |
| 分项授权与固件灰度创建页面可用性 | 新增分项授权和启动固件灰度批次已接入家庭业务受控操作。授权确认区冻结长者、类型、后续新通话范围、政策、告知摘要、证据和数据影响;切换类型清除旧告知确认,已有同类有效授权在页面阻断且服务端仍事务复核。灰度页只允许选择`READY`版本和当前家庭内型号/硬件兼容设备,确认区冻结制品摘要、比例、候选设备及当前版本,并明确服务端确定性选样和24小时可靠命令边界。按降低测试强度的要求仅执行前端生产构建、Web镜像重建、一次真实OIDC Chrome无提交冒烟和轻量残留检查;授权/灰度创建POST均为0,授权、批次及命令数量不变,无横向溢出、控制台错误、失败请求或临时固件夹具残留 |
| 固件信任根与制品上传页面可用性 | 固件签名公钥登记和制品上传校验已接入家庭业务受控操作。网页先验证公钥为标准Base64的32字节Ed25519公钥、签名为64字节Ed25519结果;公钥确认区冻结家庭、密钥编号和完整SHA-256指纹并明确编号不可变、轮换须使用新编号,制品确认区冻结型号/主板/版本、文件名/大小/完整摘要、签名密钥、安全基线、发布说明和逐字段签名清单。确认后仍执行15分钟私有地址、浏览器直传以及服务端大小/摘要/签名三重校验。按降低测试强度要求只执行生产构建、镜像重建、一次真实OIDC Chrome无提交冒烟和轻量数据核验;公钥/版本/审计数量不变,相关POST和对象PUT均为0,无横向溢出、控制台错误或失败请求 |
| 固件上传中断恢复与未完成版本处置 | OpenAPI 0.58.0与V37补齐`UPLOADING`版本恢复:可重新签发15分钟私有上传地址、用大小和SHA-256完全一致的本地文件覆盖同一对象并重复完成校验,也可把无法继续的版本条件退役并幂等清理对象。完成校验与退役均检查状态条件实际更新行数,消除并发误报;唯一性只约束未退役清单,退役后允许相同业务版本创建纠正版且保留历史。网页恢复表单阻断错误文件,恢复/退役均使用目标冻结和影响确认。按低强度策略仅执行V37升级、真实PostgreSQL服务/HTTP定向用例、生产构建、一次真实OIDC Chrome无提交冒烟和残留检查;零续传/完成/退役/PUT请求,夹具状态与审计不变,无溢出、控制台错误或失败请求 |
| 可信固件公钥台账、吊销与轮换保护 | OpenAPI 0.59.0补齐租户可信公钥查询和受控吊销:台账只展示完整SHA-256指纹、状态、登记/吊销时间及上传中版本、已就绪版本、运行中批次引用数量,不暴露原始公钥。密钥编号不可变;相同公钥可幂等重放,不同公钥不得覆盖,已吊销编号不得重新激活,轮换使用新编号。上传中版本或运行中批次引用会阻断吊销;已吊销密钥不能创建新版本或启动新灰度,历史已就绪版本和审计仍保留。网页提供引用阻断原因和目标冻结的吊销确认。按低强度策略仅执行真实PostgreSQL服务/HTTP定向用例、生产构建、镜像重建、一次真实OIDC Chrome无提交冒烟和轻量残留检查;吊销请求为0,夹具状态与审计不变,无溢出、控制台错误或失败请求 |
| 已验证固件版本暂停、恢复与退役生命周期 | OpenAPI 0.60.0补齐版本级暂停、恢复和受保护退役。`READY`暂停在同一事务阻断新灰度/新下载、暂停关联运行批次并取消未投递命令;恢复重新核验签名密钥活动状态,只恢复版本而不自动重启旧批次。运行中批次通过`FIRMWARE_RELEASE_IN_USE`阻断直接退役;未完成版本退役继续尝试清理对象,已验证版本退役保留不可变制品、清单、历史批次和审计。网页按状态展示操作,运行引用阻断退役,吊销密钥阻断恢复,并使用目标冻结和影响确认。按低强度策略执行真实PostgreSQL服务/HTTP定向用例、生产构建、镜像重建、一次真实OIDC Chrome无提交冒烟和轻量残留检查,不重复完整设备安装/回滚回归 |
| 固件版本影响预览与处置结果查询 | OpenAPI 0.61.0新增版本影响只读接口,实时汇总关联批次状态、设备分配/下载/安装/确认/失败阶段和命令排队、送达、确认、终态及暂停取消数量;观察清单覆盖下载中、安装中及命令已送达或确认但分配尚未收敛的设备,返回批次、设备、阶段、进度、命令状态、观察原因和最后上报时间。完整汇总不截断,清单最多100条并显式标记。网页版本卡片可按需查看影响,生命周期操作成功后同步刷新处置结果。按低强度策略执行真实PostgreSQL服务/HTTP定向用例、生产构建、镜像重建、一次真实OIDC Chrome只读冒烟和残留检查,不重复完整设备升级/回滚回归 |
| 模拟终端固件升级执行与中断恢复 | 模拟终端的`firmware_update`不再直接合成成功:从固定设备边界取得5分钟私有地址,限制1字节至256 MiB,校验小写SHA-256,并用设备侧`SIM_FIRMWARE_TRUSTED_KEYS`预置信任锚验证Ed25519清单签名;随后依次上报下载开始、制品验证完成、安装开始和确认终态。支持下载、摘要、签名、安装四类失败及下载后/安装后中断注入。服务端允许同一设备重新拉取未过期`ACKNOWLEDGED`命令且保持状态不回退,使进程重启后可从安全起点重复执行。按低强度策略仅执行模拟器/设备命令定向Go用例、一个真实PostgreSQL状态用例和一次“校验后中断—重启—确认”的纵向冒烟;最终两次投递收敛为`SUCCEEDED/CONFIRMED/100`、影响观察数为0,临时数据库和对象夹具已清理,未重复Web、暂停和回滚全量回归 |
| 设备A/B分区升级、启动健康确认与自动回退 | OpenAPI 0.62.0在既有命令结果对象中增加向后兼容的`staging/reboot_pending/boot_verifying/rolled_back`可选阶段,不扩展旧设备必填字段和固件分配主状态枚举。模拟器只在显式启用时把制品写入非活动分区,使用权限受限的耐久日志保存命令、版本、摘要、活动/目标分区、启动尝试和健康截止时间;重启后重新拉取同一`ACKNOWLEDGED`命令,健康通过才确认新分区,失败或超时则回到旧分区并报告回退版本。家庭固件批次详情展示写入、等待重启、健康确认和已回退事实。按低强度策略执行定向Go用例、前端类型/生产构建和一次B分区启动失败自动回A分区纵向冒烟;实测从`ACKNOWLEDGED/INSTALLING/90/REBOOT_ARMED`收敛为`FAILED/FAILED/95/BOOT_HEALTH_FAILED_ROLLED_BACK`,批次暂停、观察设备为0且全部夹具已清理。该证据只证明协议闭环,真实Bootloader与掉电一致性仍属于阶段8外部验收 |
| 固件真机联调阶段事件与一致性证据 | OpenAPI 0.63.0和V38新增每设备不可变固件阶段事件,直接命令ACK和批量遥测补交均在命令/分配事务内记录;`assignment+command_status+result_digest`对完全重复回执去重,事件保存命令/分配状态、阶段、进度、结果码、A/B分区、启动尝试、健康截止、回退、安装版本、制品摘要和签名密钥,不向页面返回完整结果正文。批次详情展示按云端接收时间排序的阶段时间线。新增`firmware-device-evidence-v1`和`cmd/firmware-conformance`,离线核对候选构建、设备/发布/命令、Bootloader/分区表、制品/信任锚、事件顺序、启动尝试、10分钟时间相关性及最终分区/版本,并把两份输入SHA-256写入报告。低强度验证执行V38正向/单版回退/重升、定向Go与前端类型检查、一次A/B回退纵向冒烟;实测7条云端事件与模拟设备独立证据全部一致,报告PASS且继续标记外部验收必需,临时业务/对象/证据文件均清理 |
| 真机固件报告纳入阶段8证据包门禁 | `release-evidence-index-v2`新增不可豁免的`REAL_DEVICE_FIRMWARE`,`release-candidate-evidence-bundle-v3`在清单内保存真机报告数、实际/完整覆盖设备数、四场景设备计数和具体缺口。每份报告必须严格符合`firmware-conformance-report-v1`、全部检查通过、范围为`HARDWARE_CAPTURE`并绑定同一候选版本/构建摘要;模拟报告、跨候选报告、失败/字段漂移报告和同设备同场景重复报告均拒绝。`VERIFIED`要求至少3台不同设备中的每台都完成AB成功、AB回滚、写入后断电和启动中断电,缺项必须保持`PENDING`。按低强度策略执行固件校验器/证据打包定向Go测试和一条真实OIDC候选包纵向烟测,不重复固件刷写、前端或全仓回归。 |
| 真实告警接收器和值班组映射证据 | 新增`alert-receiver-capture-v1`、`alert-receiver-conformance-report-v1`和`cmd/alert-receiver-conformance`,把同一候选的全部活动路由、最近成功联通测试、接收端按`test_ref`唯一验签回执、主/升级逻辑组到脱敏目标引用的映射,以及一条已确认并解决的受控正式告警演练形成10项一致性检查。真实来源全部通过后报告为`REAL_RECEIVER`;本地接收器固定为`MOCK_RECEIVER/external_validation_required=true`。v3证据包新增`alert_receiver_validation`缺口摘要,`REAL_ALERT_RECEIVER`不可豁免且首个文件必须是合格真实报告。按低强度策略执行两个相关Go包测试、一条真实Worker/HMAC本地接收器纵向冒烟;报告PASS但模拟范围继续待验,临时路由、Outbox、幂等、审计和磁盘文件均清理。 |
| 云KMS/HSM轮换证据与发布门禁 | 新增`kms-rotation-capture-v1`、`kms-rotation-conformance-report-v1`和`cmd/kms-rotation-conformance`,绑定候选构建、预生产/生产环境、云提供器、工作负载身份、提供器审计、新旧版本、认证探针、业务连续性和安全复核引用。真实报告必须同时覆盖逐请求`DYNAMIC_READ`和进程级`ROLLING_RESTART`,新凭据成功、旧凭据拒绝且不导出密钥正文;本地演练只生成`LOCAL_SECRET/external_validation_required=true`。v3证据包新增`kms_rotation_validation`,`KMS_ROTATION`不可豁免,首个文件必须为合格`REAL_KMS_HSM`报告。按低强度策略执行KMS报告/证据包定向Go测试和一条现有本地HMAC轮换纵向演练;新密钥200、旧密钥401、镜像不变、API恢复,临时容器和报告均清理,真实云门禁保持待验。 |
| 生产等价恢复演练证据与发布门禁 | 新增`production-recovery-capture-v1`、`production-recovery-conformance-report-v1`和`cmd/recovery-conformance`,把候选构建、环境/故障域/变更工单、PostgreSQL恢复、Redis重建、API/Callbacks/Worker滚动重启、供应商停机恢复、实测恢复用时、权威状态摘要、业务连续性、无重复效果、清理及审计引用形成11项检查。真实报告必须在预生产或生产等价环境完成四类演练;本地子集固定为`LOCAL_RECOVERY/external_validation_required=true`。v3证据包新增`production_recovery_validation`,该门禁不可豁免。按低强度策略执行恢复报告/证据包定向Go测试和一条现有服务重启+Redis清空重建演练;三个服务重启、停机Outbox一次完成、Redis从0经签名请求重建、PostgreSQL权威计数不变,报告PASS但真实PostgreSQL/供应商演练继续待验,夹具和磁盘文件已清理。 |
| 生产基础设施一致性证据与发布门禁 | 新增`production-infrastructure-capture-v1`、`production-infrastructure-conformance-report-v1`和`cmd/infrastructure-conformance`,把同一候选的域名TLS、OIDC、PostgreSQL、Redis、对象存储、网络隔离和监控七类组件,与环境/区域/集群/变更工单、规定控制项、观测时间以及探针/配置/审计引用形成9项检查。真实来源完整通过后报告为`PRODUCTION_INFRASTRUCTURE`;本地可达性固定为`LOCAL_INFRASTRUCTURE/external_validation_required=true`。v3证据包新增`production_infrastructure_validation`,该门禁不可豁免且首个文件必须为合格真实报告。按低强度策略执行采集器/证据包定向Go测试和一条无业务写入的本地纵向冒烟:API、OIDC、PostgreSQL、Redis、MinIO和Prometheus均可达,网络及告警配置存在,报告`PASS/LOCAL_INFRASTRUCTURE`并继续外部待验;证据包保留报告加七组件共8项缺口,临时文件已清理。 |
| Safari兼容精简矩阵与发布门禁 | 新增`safari-compatibility-capture-v1`、`safari-compatibility-conformance-report-v1`和`cmd/safari-conformance`。真实报告按候选冻结时的发布依据覆盖`CURRENT/PREVIOUS`两个不同Safari版本、桌面/平板两视口和认证会话、家庭、运营、实时导航、媒体文件、受控操作六流程,共24项;每项要求功能通过且控制台错误、非预期失败请求、横向溢出为0。非Safari工具固定为`NON_SAFARI_TOOLCHAIN/external_validation_required=true`。v3证据包新增`safari_compatibility_validation`,该门禁不可豁免且首个文件必须为合格`REAL_SAFARI`报告。按低强度策略执行采集器/证据包定向测试和一条不声称兼容的结构工具链冒烟:12项本地结构结果生成`PASS/NON_SAFARI_TOOLCHAIN`并继续外部待验;证据包保留真实报告及两个版本共3项缺口,临时文件已清理。 |
| 真实终端RTC/语音供应商精简矩阵与发布门禁 | 新增`real-device-rtc-capture-v1`、`real-device-rtc-conformance-report-v1`和`cmd/rtc-conformance`。真实报告以至少3台候选终端执行6通:`s2s_pure/s2s_hybrid/cascade`各正常完成1通,再覆盖凭证续签、网络切换恢复和受控停止;每通必须证明房间/用户/任务绑定、双向媒体、固定状态序列、供应商启停、最终字幕、用量、质量通过、任务去重和资源回收。协议模拟固定为`PROTOCOL_SIMULATION/external_validation_required=true`。v3证据包新增`real_device_rtc_validation`,该门禁不可豁免且首个文件必须为合格真实报告。按低强度策略执行采集器/证据包定向测试和一条协议结构工具链冒烟:1台模拟终端1通结构结果生成`PASS/PROTOCOL_SIMULATION`并继续外部待验;证据包保留真实报告、设备/通话数量、三方式和四场景共10项缺口,临时文件已清理。 |
| 真实Agent生成、Embedding和内容安全质量门禁 | 新增`real-agent-models-capture-v1`、`real-agent-models-conformance-report-v1`和`cmd/agent-models-conformance`。内容生成复用现有固定10类Agent评测,并使用评测集、脱敏观测和报告重新计算防篡改;Embedding按版本化阈值覆盖精确/改写召回、跨长者隔离、撤销/敏感排除和无匹配降级六类;内容安全覆盖验证码、金融账户、提示注入、敏感医疗、正常内容和模型输出六类。真实证据还必须绑定受控提供器、账号/区域、数据条款、额度和审计引用;本地生成器、哈希向量和规则固定为`LOCAL_AGENT_TOOLCHAIN/external_validation_required=true`。v3证据包新增`real_agent_models_validation`,该门禁不可豁免。按低强度策略执行聚合器/证据包定向测试和一条重算现有模拟报告的本地工具链冒烟:既有10类模拟报告重新计算一致,两组六类结构完整,输出`PASS/LOCAL_AGENT_TOOLCHAIN`并继续外部待验;证据包保留真实报告、三提供器和12类别共16项缺口,临时文件已清理。 |
| 托管内容安全外部预检与告警 | OpenAPI 0.64.0把`content_safety_tls`纳入外部依赖查询和候选验收快照。生产Worker仅执行DNS、TCP和受信TLS握手,不发送待分类正文;结果复用依赖可用性指标、数据库脱敏状态和运营诊断页面,连续5分钟不可用进入统一CRITICAL告警,记录缺失/失败/过期均可阻断候选技术快照但不驱逐Pod。按低强度策略执行App/探针/诊断定向测试、前端类型生成/类型检查/生产构建和Prometheus规则校验。 |
| 阶段8外部执行轻量预检 | 新增`scripts/stage8-preflight.ps1`,以单一只读入口核对候选Git提交、远程仓库、Compose本地基线、外部配置变量名称和十类证据索引结构/状态;报告明确不读取密钥值,也不把环境变量或本地服务可用性当作验收证据。默认仅报告缺口,`-Strict`可供自动化阻断未就绪交接;当前实测本地运行基线就绪、索引结构有效,但无候选提交/远程仓库、构建摘要仍为占位值且十类证据均为`PENDING`。 |
## 下一开发批次
1. 生产内容安全适配器、远程CI供应链门禁及内容安全外部预检/告警均已完成本地运行闭环;当前没有已知的仓库内业务功能缺口。
2. 在目标环境执行远程CI、真实基础设施、网关、云密钥、四类恢复、内容安全六类质量和macOS Safari矩阵,逐项替换`PENDING`。
3. 在业务方提供真实账号、硬件和生产基础设施后,为已实现的方舟内容与Embedding适配器配置真实模型并验收内容效果、召回质量、额度和数据条款,同时接入火山RTC/豆包语音,执行3台真机固件场景、家庭试点、生产等价恢复和灰度发布。
+72
View File
@@ -0,0 +1,72 @@
# 设备 API 兼容性约定
> 当前设备接口主版本:`/device/v2`
> 当前接口契约:OpenAPI 0.64.0
> 适用对象:云端 API、终端固件、模拟终端和设备联调工具
## 1. 兼容目标
设备通常无法与云端同步升级,因此同一主版本内必须保证已投产终端无需识别新增字段也能继续完成启动、通话、心跳、停止、遥测和录音补传。云端只依据请求正文中明确声明的能力协商可选行为,不根据固件版本字符串、HTTP `User-Agent`、设备型号或服务端推测结果改变协议语义。
## 2. `/device/v2`允许的兼容变更
- 响应对象增加非必填字段;旧终端必须忽略未知字段。
- 请求对象增加非必填字段;字段省略时必须保持原有行为。
- 增加新端点,但不得改变已有端点的请求、响应和状态转换。
- 在既有可选能力位明确为`true`时启用对应新动作;未声明、`false`或无法识别时均走旧行为。
- 修正实现缺陷,但不得改变已发布字段的类型、含义、必填性和错误处理约定。
## 3. 必须升级到`/device/v3`的变更
以下任一变化都属于破坏性变更:删除或重命名字段、改变字段类型或业务含义、给既有请求增加必填字段、缩小已发布枚举导致旧值失效、改变签名算法或规范化规则、让旧固件收到无法安全忽略的新控制动作、改变既有状态机或重试/幂等语义。此类变更必须新增`/device/v3`,并保留v2迁移期和独立契约。
CI在拉取请求中以目标分支OpenAPI为基线执行破坏性变更检查。若确实需要破坏性变更,应先建立新主版本路径和迁移方案,不得通过修改或删除旧版契约绕过门禁。
## 4. 能力协商
创建通话时,终端通过`capabilities`声明能力。`rtc_credential_renewal`是可选布尔字段:
- 明确为`true`:进入续签窗口后,心跳可能在`continue`之后返回`renew_rtc_credential`及新短期凭证。
- 省略或为`false`:云端不返回该新增动作,保持旧版v2终端心跳行为。
- 能力在通话创建时冻结到通话记录,后续心跳不允许临时切换,避免同一通话内协议行为漂移。
模拟终端显式声明该能力,用于验证新链路。旧固件无需修改请求即可继续工作。
## 5. `engine_mode`终端不变量
创建响应中的`session.engine_mode`只用于云端诊断、成本归集和通话记录。终端不得根据该字段切换麦克风采集、音频编码、近端/远端轨道发布、智能体轨道订阅或扬声器播放。终端媒体行为只由固定设备协议、RTC房间资料和显式设备能力控制。
模拟终端的创建响应结构故意不定义也不解析`engine_mode`;JSON解码会忽略该未知字段。契约测试会阻止生产模拟终端重新依赖该字段。
## 6. 变更记录
| OpenAPI版本 | 设备兼容变更 | 兼容结论 |
|---|---|---|
| 0.64.0 | 运营外部预检增加`content_safety_tls`枚举和候选快照第四类可选必需依赖;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 |
| 0.63.0 | 增加设备固件阶段事件查询及云端证据字段;设备已有命令回执仅记录同一结果的不可变事件,未增加旧终端必填字段 | v2兼容,旧终端行为不变 |
| 0.62.0 | 命令结果增加A/B写入、待重启、启动健康和回退可选字段,省略时保持原有单分区结果语义 | v2兼容,旧终端可继续省略 |
| 0.61.0 | 新增家庭侧固件版本影响只读接口,未修改设备协议 | v2完全兼容 |
| 0.60.0 | 新增家庭侧固件版本暂停、恢复和退役操作,设备仍通过既有命令与配置接口工作 | v2完全兼容 |
| 0.59.0 | 新增家庭侧固件信任根台账与吊销操作,未改变设备签名请求和回执格式 | v2完全兼容 |
| 0.58.0 | 新增家庭侧未完成固件上传恢复与退役接口,未修改设备端点 | v2完全兼容 |
| 0.57.0 | 设备期望配置更新改为服务端白名单合并,避免未提交字段丢失;请求字段和终端拉取语义不变 | v2兼容性修复 |
| 0.56.0 | 新增家庭创建和邀请预览等SaaS操作,未修改设备端点 | v2完全兼容 |
| 0.55.0 | 试点日/周报增加运营侧候选版本和构建摘要,未修改设备端点、字段、能力位或终端行为 | v2完全兼容 |
| 0.54.0 | 新增运营侧试点运行周报生成接口,未修改设备端点、字段、能力位或终端行为 | v2完全兼容 |
| 0.53.0 | 新增运营侧试点运行日报生成接口,未修改设备端点、字段、能力位或终端行为 | v2完全兼容 |
| 0.51.0 | 发布候选快照增加脱敏阻断告警定位与资源存在性信息,未修改设备端点、字段、能力位或终端行为 | v2完全兼容 |
| 0.52.0 | 新增运营告警接收器联通测试和状态查询,未修改设备端点、字段、能力位或终端行为 | v2完全兼容 |
| 0.50.0 | 增加运营侧发布候选验收快照与归档下载,未修改设备端点、字段、能力位或终端行为 | v2完全兼容 |
| 0.49.0 | 增强运营侧联调问题验收证据和高严重度发布阻断汇总,未修改设备端点、字段或终端行为 | v2完全兼容 |
| 0.48.0 | 增加运营侧外部依赖预检台账和只读页面,未修改设备端点、字段或终端行为 | v2完全兼容 |
| 0.47.0 | 增加运营侧配置投放暂停/恢复和并发版本控制;暂停只改变后续新通话的云端选择,未修改设备端点、字段或终端行为 | v2完全兼容 |
| 0.44.0 | 仅收紧运营侧Agent组合的组件版本引用校验,未修改设备端点或字段 | v2完全兼容 |
| 0.45.0 | 新通话优先冻结已投放Agent组合并把已发布人设、音色、模型和路由版本传入供应商启动;未修改设备端点、字段或终端行为 | v2完全兼容 |
| 0.46.0 | 运行时按配置类型识别运营自定义键名的Agent组合和旧路由投放,消除保留键名依赖;未修改设备端点、字段或终端行为 | v2完全兼容 |
| 0.43.0 | 仅增加运营侧Agent质量证据类型和发布门禁,未修改设备端点或字段 | v2完全兼容 |
| 0.42.0 | 新增只读设备通话状态端点,明确返回`wait`、`continue`或`leave_room`;旧端点语义不变 | v2兼容,新终端可识别供应商启动失败并及时退房 |
| 0.41.0 | 新增可选`rtc_credential_renewal`能力位;续签动作按通话冻结能力返回;明确`engine_mode`终端不变量 | v2兼容,旧终端省略能力后保持旧行为 |
| 0.40.0 | 活动授权撤回后,心跳从权威通话状态返回既有`stop`动作 | v2兼容 |
| 0.34.0 | 增加短期RTC凭证续签动作 | 由0.41.0补充能力协商,消除旧终端收到未知动作的风险 |
每次OpenAPI版本更新都必须同步本表;设备端破坏性变更还必须给出v2保留期限、v3升级方式、灰度范围和回滚条件。
+24
View File
@@ -0,0 +1,24 @@
# 设备命令可靠下发说明
当前版本实现“云端排队、设备主动拉取、分阶段回执”的设备命令基线,避免依赖设备持续在线或由公网直接访问设备。家庭内具有`device.manage`能力的成员可创建命令;设备使用原有HMAC身份从固定设备边界拉取,只能读取和回执自己的命令。
## 状态机
`QUEUED → DELIVERED → ACKNOWLEDGED → SUCCEEDED/FAILED`为正常主链。超过`expires_at`且尚未终结的命令转为`EXPIRED`;重复拉取会增加`delivery_attempts`并返回同一未终结命令。设备已上报`ACKNOWLEDGED`但尚未提交终态时,命令仍可被同一设备重新拉取用于进程重启恢复,服务端保持`ACKNOWLEDGED`而不回退为`DELIVERED`。重复提交相同阶段或终态回执保持幂等。非法跨设备回执、过期回执和状态回退返回409。
## 接口
| 接口 | 身份 | 作用 |
|---|---|---|
| `POST /api/v1/devices/{id}/commands` | OIDC + `device.manage` | 以`Idempotency-Key`创建30秒至24小时有效的命令 |
| `GET /api/v1/devices/{id}/commands` | OIDC + `device.manage` | 查看最近100条命令、投递次数和结果摘要 |
| `GET /device/v2/commands?limit=10` | 设备HMAC | 拉取本设备未过期、未终结命令;首次投递记为`DELIVERED`,处理中重拉保持`ACKNOWLEDGED` |
| `POST /device/v2/commands/{id}/ack` | 设备HMAC | 上报`ACKNOWLEDGED`、`SUCCEEDED`或`FAILED` |
支持`apply_config`、`reboot`、`diagnostics`和`firmware_update`四类命令。`firmware_update`只接受固件发布服务生成的私有对象引用、已验证SHA-256摘要和签名清单;制品直传、Ed25519验签、确定性灰度、暂停和已验证版本回滚详见[固件制品、签名、灰度与回滚开发说明](firmware-rollout.md),不接受任意公网URL作为固件来源。
家庭工作台的诊断命令使用页面内受控操作表单,不再点击按钮后立即排队。表单固定展示设备公开编号、型号、长者、硬件/固件版本、在线状态、期望配置版本和设备记录版本,并明确诊断范围固定为音频、网络和存储。操作者必须确认命令有效期为5分钟、离线设备可能来不及领取,以及该动作不会重启设备或修改配置;取消,或切换页签、家庭、长者,会清空草稿且不创建命令。服务端仍执行`device.manage`、家庭租户隔离、设备状态、幂等和可靠投递门禁。
V11迁移建立`device_command`表,包含租户/设备复合外键、申请人幂等约束、JSON对象约束、过期索引和投递索引。`scripts/smoke-device-commands.ps1`已用真实OIDC与设备HMAC验证权限拒绝、幂等重放、拉取、两阶段回执、终态和测试数据清理。
页面增量只执行`test/e2e/visual-family-invitation-device-controls.mjs`的真实OIDC无提交冒烟:检查设备目标和固定诊断范围、确认门禁、取消关闭、诊断命令数量不变、零POST、零横向溢出、零控制台错误和零失败请求,不重复设备HMAC全状态链测试。
+113
View File
@@ -0,0 +1,113 @@
# 设备A/B分区固件升级与启动确认协议
## 1. 目的与适用边界
本协议用于把云端已经完成的签名制品、灰度命令和阶段回执,与量产终端常见的A/B双分区升级机制对齐。它定义设备必须持久化的升级状态、云端可观察阶段、启动健康窗口和自动回退结果,但不替代具体芯片、Bootloader、分区表、看门狗或厂商烧写SDK。当前`cmd/sim-device`实现的是协议级模拟器,用于在真机到位前发现状态机和联调问题;是否能安全写入真实分区、断电后继续启动及由Bootloader自动回退,仍必须在量产候选硬件上验收。
旧设备不声明或不启用A/B能力时继续使用既有`downloading → installing → confirmed/failed`链路。A/B模拟器只有在`SIM_FIRMWARE_AB_UPDATE=true`且提供绝对路径`SIM_FIRMWARE_STATE_FILE`时启用,不改变设备v2既有必填字段和命令终态。
## 2. 设计原则
1. 设备只信任安全存储或受控镜像预置的厂商公钥,不信任升级命令携带的公钥。
2. 制品大小、SHA-256和Ed25519签名全部通过后,才允许写入非活动分区。
3. 任何时刻都不得直接覆盖当前活动且已确认可启动的分区。
4. 设置下次启动分区和健康截止时间前,必须先持久化升级日志;云端回执不得先于本地耐久状态。
5. 新分区只有在核心进程、配置读取、设备身份和必要外设健康检查通过后才能确认。
6. 健康检查失败或超过截止时间时,由设备/Bootloader回到旧分区,并向云端提交带回退事实的稳定失败终态。
7. 设备重启后可重新拉取仍为`ACKNOWLEDGED`的同一命令;云端增加投递次数但不把状态回退到`DELIVERED`。
## 3. 设备侧状态机
```mermaid
stateDiagram-v2
[*] --> StableA: A分区已确认
StableA --> Downloading: 收到firmware_update
Downloading --> ArtifactVerified: 摘要和签名通过
Downloading --> Failed: 下载/摘要/签名失败
ArtifactVerified --> StagedB: 写入并校验非活动B分区
StagedB --> RebootPending: 持久化启动尝试和健康截止时间
RebootPending --> BootVerifyingB: 重启进入B分区
BootVerifyingB --> StableB: 健康检查通过并确认B
BootVerifyingB --> RolledBackA: 健康失败或超时
RolledBackA --> Failed: 上报已自动回退
StableB --> [*]
Failed --> [*]
```
当活动分区为B时,下一次升级使用完全对称的B→A流程。`power_loss_after_stage`保留`STAGED`日志,重启后从设置启动分区继续;`power_loss_during_boot`保留`REBOOT_PENDING`日志,后续启动继续执行健康检查。重复执行必须针对同一命令编号、版本、发布编号和摘要,任一不一致都以`LOCAL_UPDATE_STATE_CONFLICT`停止,不能覆盖未知的在途升级。
## 4. 本地耐久日志
模拟器状态文件只保存非敏感升级元数据,权限为当前进程可读写。字段如下:
| 字段 | 含义 |
|---|---|
| `phase` | `STAGED`、`REBOOT_PENDING`、`CONFIRMED`或`ROLLED_BACK` |
| `command_id`、`release_id` | 绑定当前可靠命令和云端发布记录 |
| `target_version`、`previous_version` | 目标版本和回退基线版本 |
| `sha256` | 已验证制品摘要,用于重启后核对同一升级意图 |
| `active_slot`、`previous_slot`、`target_slot` | 当前稳定分区、回退分区和待启动分区 |
| `boot_attempt_id` | 每次设置下次启动分区时生成的随机启动尝试编号 |
| `health_deadline` | 新分区必须完成健康确认的UTC截止时间 |
| `updated_at` | 最近一次耐久更新时刻 |
`CONFIRMED`和`ROLLED_BACK`记录作为下一次升级的本地稳定基线保留。量产实现应把等价数据写入具备掉电保护的Bootloader环境区或专用元数据分区,并使用冗余、校验和或事务写策略处理半写入,不能直接照搬普通文件实现。
## 5. 云端回执与主状态映射
命令仍只使用`ACKNOWLEDGED`、`SUCCEEDED`和`FAILED`三类状态。A/B细节放在可选`result_summary`中,云端将所有设备侧写入、重启和健康确认阶段保持为`firmware_assignment.status=INSTALLING`,避免扩展既有主状态枚举破坏客户端兼容。
| 回执阶段 | 命令状态 | 分配主状态 | 建议进度 | 关键字段 |
|---|---|---|---:|---|
| `downloading` | `ACKNOWLEDGED` | `DOWNLOADING` | 5—100 | `result_code`、`verified_sha256` |
| `installing` | `ACKNOWLEDGED` | `INSTALLING` | 80 | `INSTALL_STARTED` |
| `staging` | `ACKNOWLEDGED` | `INSTALLING` | 85 | `active_slot`、`target_slot` |
| `reboot_pending` | `ACKNOWLEDGED` | `INSTALLING` | 90 | `boot_attempt_id`、`boot_health_deadline` |
| `boot_verifying` | `ACKNOWLEDGED` | `INSTALLING` | 95 | `active_slot`、`previous_slot` |
| `confirmed` | `SUCCEEDED` | `CONFIRMED` | 100 | `installed_version`、`active_slot` |
| `rolled_back` | `FAILED` | `FAILED` | 95 | `rollback_applied=true`、`rollback_version`、`failed_slot` |
启动失败回退示例:
```json
{
"status": "FAILED",
"result_summary": {
"phase": "rolled_back",
"progress_percent": 95,
"result_code": "BOOT_HEALTH_FAILED_ROLLED_BACK",
"rollback_applied": true,
"active_slot": "A",
"failed_slot": "B",
"rollback_version": "0.2.0-sim",
"boot_attempt_id": "<随机编号>"
}
}
```
回退已经保护设备可启动性,但目标版本未成功,因此云端仍把命令和分配标记为失败、把批次暂停并停止扩量。运营页面从`last_result`展示非活动分区写入、等待重启、健康确认或已自动回退,不把“回到了可用旧版本”误报成升级成功。
## 6. 模拟器参数和故障注入
| 参数 | 默认值 | 作用 |
|---|---|---|
| `SIM_FIRMWARE_AB_UPDATE` | `false` | 显式启用A/B升级协议 |
| `SIM_FIRMWARE_STATE_FILE` | 无 | A/B耐久日志绝对路径;启用时必填 |
| `SIM_FIRMWARE_ACTIVE_SLOT` | `A` | 首次运行且没有稳定日志时的活动分区,只允许A/B |
| `SIM_FIRMWARE_CURRENT_VERSION` | `0.2.0-sim` | 首次运行的回退基线版本 |
| `SIM_FIRMWARE_BOOT_HEALTH_WINDOW` | `2m` | 启动健康窗口,范围1秒至30分钟 |
| `SIM_FIRMWARE_FAULT=power_loss_after_stage` | 无 | 写完非活动分区后模拟掉电,保留`STAGED` |
| `SIM_FIRMWARE_FAULT=power_loss_during_boot` | 无 | 新分区健康确认时模拟掉电,保留`REBOOT_PENDING` |
| `SIM_FIRMWARE_FAULT=boot_health_failed` | 无 | 模拟新分区健康失败并自动回退 |
## 7. 轻量纵向验证
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-sim-firmware.ps1 -Mode ABRollback
```
脚本创建一个临时签名版本和单设备批次。第一次运行下载、验签、写入B分区并持久化`REBOOT_PENDING`,断言云端为`ACKNOWLEDGED/INSTALLING/90/REBOOT_ARMED`;第二次运行模拟B分区健康失败,断言本地回到A分区、原版本恢复,云端收敛为`FAILED/FAILED/95/BOOT_HEALTH_FAILED_ROLLED_BACK`,批次暂停且观察设备为0。结束后精确清理数据库、对象存储、固件文件和本地状态文件。
同一脚本还生成独立的`firmware-device-evidence-v1`设备侧证据,读取包含7条去重阶段事件的云端批次响应,并调用`cmd/firmware-conformance`核对命令、发布、启动尝试、制品摘要、签名密钥、阶段顺序、时间相关性、分区和回退版本。证据字段及报告归档规则见[固件真机联调一致性证据采集与校验说明](firmware-conformance-evidence.md)。
该结果证明协议和云端状态闭环可联调,不证明真实Bootloader、闪存掉电一致性或真实外设健康检查已经通过。真机验收仍须覆盖至少3台量产候选终端、A→B和B→A、写入中断电、首次启动断电、健康超时、看门狗复位、连续升级、云端暂停和已验证版本回滚。
+289
View File
@@ -0,0 +1,289 @@
# 设备接入与启动配置联调说明
## 1. 当前接口
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | `/device/v2/bootstrap` | 上报启动信息,取得服务器时间、可信设备状态、绑定、最低安全基线、最低固件版本及兼容结论 |
| GET | `/device/v2/config` | 取得版本化期望配置和当前绑定;支持 `If-None-Match` |
| POST | `/device/v2/conversations` | 冻结授权和背景;成功时分配RTC房间并进入`ROOM_ISSUED`,背景准备失败时保存可诊断`FAILED`结果且不创建房间 |
| POST | `/device/v2/conversations/{id}/activate` | 确认设备已成功入房,可靠投递固定语音供应商任务 |
| GET | `/device/v2/conversations/{id}` | 读取权威通话状态、稳定失败码和设备下一步动作;用于确认智能体启动结果 |
| POST | `/device/v2/conversations/{id}/heartbeat` | 按递增序号上报在线、RTC、配置和录音队列状态并刷新短期租约 |
| POST | `/device/v2/conversations/{id}/stop` | 记录挂断原因并可靠停止固定供应商任务,最终收敛到终态 |
| POST | `/device/v2/conversations/{id}/turns:batch` | 在供应商终稿缺失时补传设备终稿,并与已保存供应商终稿去重合并 |
| POST | `/device/v2/telemetry:batch` | 批量上报设备状态、有限错误码和命令结果,返回配置/命令/停止动作 |
| POST | `/callbacks/v1/vertc/voice-chat` | 接收经HMAC签名的语音供应商异步事件 |
设备自报的型号、设备编号或版本不构成身份依据。云端使用签名中的设备公开编号查询登记记录,并从数据库取得租户、状态和凭据引用。
### 1.1 家庭端设备绑定
设备绑定码由未绑定设备在受控配网或屏幕流程中显示,家庭Web工作台只调用`POST /api/v1/device-claims`消费该码,不生成绑定码。输入框限制16—128字符并关闭自动填充;进入确认区后不回显完整码,只显示掩码、总长度和末四位。确认区同时冻结当前家庭、长者及其公开编号,要求操作者当面核对设备屏幕、设备型号和实际使用人,并明确绑定将建立唯一有效的长期家庭/长者关联,错误目标不能通过再次输入同一码自行改绑。
只有显式确认后才提交完整绑定码。服务端重新校验操作者的`device.manage`能力、家庭与长者边界、设备未绑定、绑定码摘要、到期和撤销状态;相同码和相同目标可安全重试,不同目标或已有有效绑定返回冲突。取消、切换页签/家庭/长者会同时清除确认草稿和输入框中的完整码,不发送请求。页面增量只执行真实OIDC无提交轻量冒烟,验证短码阻断、固定目标、完整码不回显、确认门禁、取消清理、零`device-claims` POST和绑定记录数量不变;完整首次绑定与安全重试继续由既有真实数据库用例承担。
最低固件版本与最低安全基线是两个独立概念。`minimum_security_version`保存厂商安全能力或签名基线标识;`minimum_firmware_version`保存允许创建新通话的固件下限。设备启动上报当前固件后,`firmware_compatible`立即给出判断;SemVer兼容版本按主版本、次版本、修订号和预发布标识排序,厂商自定义的不透明版本无法安全排序,只允许与策略精确相等。设备必须在兼容结果为`false`时进入升级提示而不是继续通话;云端创建接口仍会再次校验并返回不可自动重试的`FIRMWARE_UPDATE_REQUIRED`,因此绕过设备侧提示也不会分配房间。
## 2. 开发环境签名
每个请求必须包含:
- `X-Device-Id`:已登记的设备公开编号。
- `X-Device-Timestamp`:RFC3339 UTC时间,默认允许前后5分钟误差。
- `X-Device-Nonce`:每个请求唯一的16—128字符随机数。
- `X-Device-Content-SHA256`:原始HTTP正文的SHA-256小写十六进制值;空正文也必须计算。
- `X-Device-Signature`:HMAC-SHA256结果的无填充Base64URL编码。
签名原文按以下顺序拼接,行间使用单个换行符:
```text
UPPERCASE_HTTP_METHOD
REQUEST_URI_WITH_EXACT_QUERY_STRING
X-Device-Timestamp原值
X-Device-Nonce原值
X-Device-Content-SHA256原值
```
云端先核验时间窗、正文摘要和签名,再使用Redis `SET NX`登记随机数。相同设备和随机数只能使用一次;Redis或数据库不可用时设备鉴权采用fail-closed并返回可重试的503,不绕过身份验证。
本地环境使用静态HMAC密钥模拟联调。量产采用每设备独立HMAC时,密钥应写入硬件安全区并通过Secrets/KMS CSI受控挂载到云端;如硬件与PKI条件允许,仍可进一步切换每设备mTLS证书。不能把本地合成密钥烧录到真实设备。
### 2.1 逐设备生产密钥解析
dev、stage和prod把设备密钥通过Docker/Kubernetes Secrets或云KMS CSI驱动挂载到API容器的受控只读目录,并设置`DEVICE_SECRET_DIRECTORY`。设备表不保存密钥正文,只保存`device-secret://<name>`引用;解析器只接受单层安全文件名,解析符号链接后仍必须位于配置根目录内,文件最大4KiB且有效密钥至少32字节。
每次验签都按设备记录读取对应文件,因此不同设备使用不同密钥,原子替换挂载文件即可完成轮换,不需要重新构建镜像或重启API。文件缺失、过短、引用格式错误或越界时统一按无效凭据拒绝,响应与日志不暴露文件路径及密钥内容。生产启动门禁拒绝退回单一`DEVICE_LOCAL_SECRET`。
## 3. 启动参数
`bootstrap`查询参数包括:`firmware_version`、`board_rev`、`rtc_sdk_version`、`codec`、`sample_rate`、`device_time`和`network`。其中固件版本和设备时间必填。服务端把报告写入设备影子的reported区域,网页配置只能修改desired区域。
响应返回服务器时间和设备时钟偏差、登记状态、型号、最低安全版本、期望配置版本及当前绑定。响应不包含内部数据库主键或任何设备密钥。
## 4. 配置缓存
配置接口返回强ETag,例如 `"v1"`。设备下次请求可携带:
```http
If-None-Match: "v1"
```
配置未变化时返回HTTP 304;变化时返回HTTP 200、新ETag、期望配置、设备已报告版本和绑定信息。
### 4.1 家庭端音量配置
家庭端设备管理页把扬声器音量作为设备影子的非敏感期望配置管理,不直接假定设备已经应用。页面同时展示“云端期望”和“设备上报”两个值:未配置时滑块以60%作为首次建议值,但明确显示云端尚未设置;已有配置时从当前`desired_config.volume.speaker`回显,相同值不允许重复提交。用户可在0—100范围内逐级调节;提交前的受控操作区固定设备、长者、旧值、新值、设备上报实际值和期望版本变化,并说明离线设备可能稍后领取。
网页提交`PATCH /api/v1/devices/{id}/twin`时只发送需要修改的白名单顶层分类并携带当前设备影子强ETag。服务端仅接受0—100的整数`desired_config.volume.speaker`,继续递归拒绝密钥、令牌、口令和凭据字段,并把已验证分类按顶层增量合并到现有期望配置,未提交的历史编码、采样率、心跳和其他分类不会被音量修改删除。版本过期时返回412,页面刷新后由最新设备影子重新回显;成功时响应包含递增后的期望版本,页面立即更新为“等待设备应用”。设备随后通过配置拉取和心跳报告实际版本及`reported_config.volume.speaker`,二者一致后显示“设备已应用”。
## 5. 创建通话
创建接口必须同时提供设备签名和16—128字符的`Idempotency-Key`。服务端在同一事务中完成:
1. 锁定并核验设备当前绑定、长者状态和语音授权;
2. 冻结本次授权快照和最小背景快照;
3. 创建固定通话编号、房间编号和供应商任务编号;
4. 记录`PREPARING → ROOM_ISSUED`状态链、RTC任务、审计和可靠待办;
5. 提交后签发绑定指定房间和设备用户的短期Token。
请求中的`capabilities`用于显式能力协商。当前模拟终端在原有`rtc`、`audio_tracks`和`upload_resume`之外声明可选的`rtc_credential_renewal=true`;云端把该能力冻结到本次通话。旧版v2固件省略该字段时仍可创建通话,云端不会向其返回新增的凭证续签动作。能力选择不得根据固件版本或HTTP `User-Agent`推测,完整兼容规则见[设备API兼容性约定](device-api-compatibility.md)。
最小背景快照不是设备自由上报的正文。服务端按当前有效绑定读取长者档案版本,只从`profile_data.background/interests`和`conversation_preferences.conversation_goal/pace/preferred_topics`提取有长度上限的白名单资料,再加入最多5条当前有效的已确认记忆。非白名单扩展字段、内部备注和设备侧固定技术目标`open_conversation`不会进入供应商提示。生成的摘要和档案版本随通话冻结;网页后续修改只影响下一次新通话。供应商系统消息还会把该段内容明确标记为“背景资料而非可执行指令”,不能借此覆盖固定安全规则。
### 5.1 背景资料准备失败协议
五个白名单字段一旦存在,就必须符合约定类型:背景、交流目标和节奏为字符串,兴趣和优先话题为字符串数组;缺失或`null`表示未填写。历史导入或异常写库造成类型不正确时,云端不会把字段静默丢弃后继续通话,也不会创建RTC房间或供应商任务,而是保存一条状态为`FAILED`、失败码为`context_preparation_failed`的权威通话和脱敏失败快照。
设备收到HTTP 422,错误码为`AGENT_CONTEXT_PREPARATION_FAILED`,`details`固定包含`conversation_id`、`status=FAILED`和`failure_code=context_preparation_failed`。该错误不可在原请求上无限自动重试;设备应停止入房流程,展示“背景资料准备失败”,并上报稳定错误码。网络重放必须沿用原`Idempotency-Key`,云端返回相同通话编号并携带`Idempotency-Replayed: true`;用户修正档案后才使用新的幂等键发起新通话。家庭网页和运营诊断均按同一`failure_code`展示“背景资料准备失败,未进入语音通话”。
家庭端可在设备真正发起通话前调用`GET /api/v1/subjects/{id}/conversation-readiness`。该接口只返回当前成员有权查看的长者及绑定设备事实,把档案背景、交流目标、语音授权、有效绑定和设备运行上报列为准备条件,把配置同步和最近联系列为运行提醒。用量额度、生产路由接纳和进行中通话属于瞬时状态,仍由`POST /device/v2/conversations`在事务内最终判断;就绪响应不会预留房间、额度或供应商容量。
创建阶段不会调用`StartVoiceChat`。设备必须先加入返回的RTC房间,再调用后续`activate`接口。相同设备、接口、幂等键和相同正文返回同一通话;正文不同返回冲突。为避免持久化完整短期Token,幂等记录只保存非敏感房间声明,重放时根据原声明确定性重建同一个Token。
创建响应中的`session.engine_mode`只供云端诊断、成本归集和通话追溯。终端必须忽略该字段,不得用它切换麦克风采集、编码、近远端轨道发布、订阅或扬声器播放。
## 6. 激活通话与启动语音供应商
设备收到房间资料并成功加入RTC房间后,调用`POST /device/v2/conversations/{id}/activate`,正文必须带入云端原先分配的`room_id`、`rtc_user_id`,并将`rtc_join_result`设为`success`。服务端还会校验当前设备归属、房间预留有效期和`config_revision`,任何不一致都不会启动供应商任务。
首次有效激活在一个事务中完成`ROOM_ISSUED → STARTING_AGENT`、RTC任务`ALLOCATED → STARTING`、审计记录和`conversation.agent_start_requested` outbox事件。Worker使用创建通话时已经冻结的`provider_task_ref`调用供应商,重试时不得生成新任务编号。重复的相同幂等请求返回原结果;通话已经处于`STARTING_AGENT`或`ACTIVE`时再次确认也不会创建第二个启动事件。
激活接口受理后,设备使用`GET /device/v2/conversations/{id}`查询启动结果,不通过更换幂等键重复调用激活接口充当轮询。返回动作含义如下:
- `wait`:房间已分配或智能体仍在启动,设备保持入房并按有限次数继续查询;
- `continue`:权威状态为`ACTIVE`,设备进入正常双向语音和心跳;
- `leave_room`:通话正在停止或已进入终态,设备立即退出RTC房间;`FAILED`时同时显示稳定脱敏`failure_code`。
供应商明确、不可重试地拒绝启动时,Worker在同一数据库事务内把通话和RTC任务收敛为`FAILED/provider_start_rejected`,写入状态历史和系统审计,并正常完成可靠待办,不让通话停留在`STARTING_AGENT`或把永久错误反复重试。供应商异步报告启动失败时返回既有`provider_start_failed`。两种情况都不向设备暴露供应商原始响应;模拟终端识别`leave_room`后立即结束启动流程。
本地`mock-provider`暴露在`http://localhost:59200`,用于模拟供应商启动和异步回调。它按固定任务编号幂等受理启动,并故意发送两次完全相同的`voice_chat.started`回调。Callbacks进程先按时间戳和原始正文校验HMAC签名,再以`provider + event_id`去重;同一事件编号和同一正文返回成功,同一事件编号却对应不同正文时返回409。首个有效启动事件使通话和RTC任务进入`ACTIVE`,重复或过时事件不能让状态回退。
生产模式不复用Mock路径。API使用RTC AppKey生成官方正式Token格式,Token只授权本次`room_id + rtc_user_id`的发布与订阅并在短期后失效;相同创建请求重放时按相同房间、用户和到期时间重建同一Token,数据库和日志均不保存Token。Worker使用火山引擎AK/SK对`StartVoiceChat/StopVoiceChat`签名,平台稳定任务编号映射为`TaskId`,房间映射为`RoomId`,已冻结的设备/智能体RTC用户映射到`AgentConfig`。通话创建时冻结的`s2s_pure`、`s2s_hybrid`或`cascade`会进入可靠启动任务,Worker从`voice-chat-template-bundle-v1`选择同名模板;生产启动要求三个模板全部存在,避免运营路由切换后仍使用另一种处理链。官方同步响应`Result=ok`表示RTC已接受并启动该任务,平台据此进入`ACTIVE`,后续由设备心跳维持租约;火山接口没有任务查询API,因此不确定启动的恢复逻辑会以同一`TaskId`安全重发,供应商“任务已启动”响应同样按已存在活动任务收敛,不生成新任务编号。服务端状态事件可作为状态细分和用量补充,但不是最小通话链进入ACTIVE的单点依赖。
生产API优先选择活动投放所绑定的`AGENT_BUNDLE`版本,并从组合引用的路由版本取得处理模式;`config_key`是运营自定义版本族标识,不要求保留键名。未命中组合时兼容活动投放所绑定的直接`ROUTING`版本。两类投放都未命中时返回503 `RUNTIME_CONFIG_UNAVAILABLE`,不会回退到开发用`minimal-v2 + s2s_hybrid`。dev、test和local环境保留该默认值,便于模拟设备和本地闭环运行。该变化仅影响云端新通话配置选择,不改变设备v2请求、响应字段或终端媒体行为。
阶段8真实验收按[真实终端与RTC/语音供应商一致性证据](real-device-rtc-evidence.md)执行6通精简矩阵,三种方式各完成正常闭环,并额外覆盖凭证续签、网络切换恢复和受控停止。协议模拟只验证工具链,不计入`REAL_DEVICE_RTC`。
模拟供应商还提供仅限本地、使用`X-Mock-Provider-Key`保护的`POST /mock/v1/admin/replay`。请求指定已启动的`task_ref`、样例名和1—10次重复投递次数,可重复播放`started`、`final_transcript`、`agent_answering`、`interrupted`、`usage`、`error`和`stopped`七类脱敏通知。`agent_answering`和`interrupted`以最终智能体话轮表达,后者明确设置`interrupted=true`;`usage`只使用标准化分类、计量代码、数量和单位。相同样例针对同一任务生成稳定事件编号,用于验证通知幂等,不应在生产部署中暴露该管理接口。
供应商回调签名使用以下原文:
```text
X-Provider-Timestamp原值
原始HTTP正文的SHA-256小写十六进制值
```
签名算法为HMAC-SHA256,响应编码为无填充Base64URL。本地共享密钥只用于联调;生产应改由密钥管理服务托管并建立轮换机制。
## 7. 通话心跳与租约
通话进入`STARTING_AGENT`或`ACTIVE`后,设备按创建响应中的`heartbeat_interval_sec`调用心跳接口。请求至少包含严格递增的`sequence_no`、设备时间与单调时钟、云端通话状态、RTC上下行状态、网络、固件版本、已应用配置版本、录音队列以及命令结果数组。录音队列必须报告待上传数和失败数;发生失败时可附带脱敏的`last_failure_code`与`last_failure_at`,家庭端据此显示上传积压和最近失败原因。
PostgreSQL保存`last_heartbeat_seq`和权威`lease_expires_at`;Redis只保存可重建的热租约和设备在线状态。处理规则如下:
1. 新序号在数据库行锁内更新通话、RTC任务和设备影子,并把租约续期为五个心跳周期;
2. 同序号重放或更小序号的乱序心跳返回当前结果,不更新设备报告,也不延长数据库或Redis租约;
3. Redis使用Lua比较序号,避免两个已经提交的并发请求以相反顺序覆盖热租约;
4. 设备报告的配置版本低于期望版本时,服务端接受心跳并返回最新`desired_config_revision`;高于云端期望版本时返回冲突,防止未知配置污染设备影子;
5. RTC已经离房、设备报告正在停止或通话已是终态时返回`stop`动作,终态永远不会因迟到心跳回到运行态;
6. 已在创建通话时明确声明`rtc_credential_renewal=true`的设备,把当前使用的Token过期时间原样放入`rtc_credential_expires_at`;进入续签窗口时响应在`continue`后追加`renew_rtc_credential`,其中`rtc`结构包含同一房间和设备用户的新短期凭证;未声明能力的旧版v2设备只收到原有动作;
7. 设备先替换运行内存中的RTC凭证,再在下一次心跳报告新的过期时间。若续签响应丢失,旧过期时间会使服务端按已保存的新过期时间重新生成同一确定性凭证,不重复延长有效期;
8. Token签名失败时数据库事务整体回滚,不推进心跳序号或凭证时间,设备可用同一序号安全重试;凭证正文仍不写入PostgreSQL、Redis、审计或日志;
9. PostgreSQL事务提交后Redis刷新失败不回滚权威心跳,后台可从数据库重建热租约。
心跳中的`last_error`只接受短错误码,不接受自由文本;云端不会把命令结果、短期Token或供应商密钥写入请求日志。
## 8. 停止通话与异常回收
设备挂断后调用`POST /device/v2/conversations/{id}/stop`,同时提供16—128字符的`Idempotency-Key`。正文包含原因代码、设备时间、最后心跳序号、RTC是否已经离房和near/far本地录音分片计数。首次有效请求执行以下事务:
1. `ACTIVE`或`STARTING_AGENT`进入`STOPPING`,清空权威租约并记录停止原因;
2. RTC任务进入`STOPPING`;
3. 创建唯一的`conversation.agent_stop_requested`可靠待办和操作审计;
4. 事务提交后删除Redis通话热租约,并把设备在线状态恢复为`idle`。
Worker始终使用创建通话时固定的`provider_task_ref`调用Stop。相同幂等键重放返回原结果;换新幂等键重复停止、Worker重试或后台对账也不会产生第二个供应商任务。供应商返回`COMPLETED`或签名结束回调到达后,通话和RTC任务统一进入`COMPLETED`、写入结束时间,并追加`STOPPING → COMPLETED`状态历史。完全相同的结束回调只落一条供应商事件。
家庭用户在通话中撤回该通话冻结快照正在使用的字幕、原录音、故事整理或个性化记忆授权时,SaaS在撤回事务内同步保存停止意图:`ACTIVE/STARTING_AGENT`进入`STOPPING`并创建唯一Agent停止待办,尚未启动Agent的房间直接进入`EXPIRED`。设备不需要新增接口;下一次通话心跳返回`stop`,批量遥测若引用该通话则返回`stop_conversation`。即使Worker先完成供应商停止,设备对已进入终态的通话心跳仍收到停止动作。家庭分享授权只控制后续分享访问,不中断实时语音。
如果设备在激活前挂断,`ROOM_ISSUED`直接进入`EXPIRED`,供应商侧不会发生Start或Stop调用。后台恢复扫描器从PostgreSQL扫描非终态通话并使用数据库行锁和`SKIP LOCKED`避免多个Worker重复修复:
- 房间预留到期:进入`EXPIRED`;
- `ACTIVE`租约到期:进入`STOPPING`并可靠投递Stop;
- `STARTING_AGENT`长时间未收敛:按时间桶投递幂等启动对账;
- `STOPPING`长时间未完成:按时间桶安全重试Stop;
- 供应商幂等Start/Stop响应已经确认终态时,即使原回调丢失也可由Worker补齐状态历史;
- 每次发现和恢复均写入`conversation.recovery`系统审计。
本地默认每5秒扫描一次,启动中或停止中超过20秒进入对账。生产值必须依据真实供应商回调延迟、设备心跳间隔和压测结果调整。
## 9. 终稿补传与冲突处理
当供应商最终转写缺失或不完整时,通话所属设备可调用`POST /device/v2/conversations/{id}/turns:batch`补传1—500条最终话轮。请求同时携带设备签名和16—128字符的`Idempotency-Key`。每条终稿包含`turn_key`、严格正整数`sequence_no`、说话方、起止时间、最终文本和是否被打断;同一批内`turn_key`和`sequence_no`分别唯一。
云端在单个数据库事务内按通话归属、留存策略和当前文字留存授权校验后,以`turn_key`和`sequence_no`同时合并:
1. 两个业务键、说话方、时间、文本和打断标志完全一致时计入`duplicate_count`,不重复保存;
2. 两个业务键均未使用时以`source_type=device`保存并计入`accepted_count`;
3. 任一业务键已经对应不同终稿时返回409 `TRANSCRIPT_TURN_CONFLICT`,冲突项位于`error.details.conflicts`,整批新增全部回滚;
4. 留存模式为`none`或文字留存授权已经撤回/过期时返回403 `TRANSCRIPT_STORAGE_NOT_ALLOWED`;
5. 至少新增一条终稿时可靠投递内容再提取任务;相同幂等请求只返回原合并结果,不重复生成任务。
终端收到409后不得覆盖云端终稿,应记录冲突并拉取云端通话内容供排查;网络失败或503时保持原正文和原幂等键退避重试,但必须为每次HTTP请求生成新的签名随机数。
## 10. 批量遥测、错误与命令结果
设备在通话中或空闲期均可调用`POST /device/v2/telemetry:batch`,每批提交1—500条按设备单调编号的事件。事件类型分为`status`、`error`和`command_result`,每条事件必须提供设备内唯一的`event_key`、`sequence_no`和发生时间,并且只携带与`event_type`对应的同名对象。请求需使用设备签名和16—128字符`Idempotency-Key`。
- `status`更新设备实际固件、实际配置版本、网络、RTC和录音队列快照;旧序号不能覆盖新快照,报告版本高于云端期望版本返回409。
- `error`只接受最长80字符的有限错误码和`warning/error/critical`级别,不接收可能包含个人信息的自由文本堆栈。
- `command_result`把属于当前设备且未过期的命令收敛到`ACKNOWLEDGED/SUCCEEDED/FAILED`,并同步固件分配进度;结果对象递归拒绝密钥、令牌、口令、私钥和凭据字段。
- `conversation_id`可选,但一旦提交,云端必须验证通话属于当前签名设备;终态或停止中的通话返回`stop_conversation`动作,不能借遥测访问任意长者的通话。
- 完全相同的事件计入`duplicate_count`;同一事件键或序号对应不同内容返回409 `TELEMETRY_EVENT_CONFLICT`并回滚整批。响应还可返回`refresh_config`或`pull_commands`动作。
当前命令结果字段是本地联调基线,硬件团队给出最终设备能力表和结果格式后,应先更新OpenAPI兼容说明再调整实现;不能依赖固件User-Agent猜测字段版本。
## 11. 运行模拟设备与端到端冒烟
完整环境启动后执行:
```powershell
Set-Location E:\Codex\story\platform
docker compose --profile simulation run --rm sim-device
```
模拟器默认执行`bootstrap`和配置读取,只打印状态与脱敏编号,不打印签名密钥。默认设备公开编号为`10000000-0000-0000-0000-000000000511`;可通过`SIM_API_URL`、`SIM_DEVICE_ID`和`SIM_DEVICE_SECRET`覆盖本地值。
`SIM_SCENARIO`支持逗号分隔的场景:
| 场景 | 行为 |
|---|---|
| `bootstrap` | 启动、时间同步和版本化配置读取 |
| `offline` | 先向不可达端点发起请求,确认本地保留后使用新Nonce重连 |
| `conversation` | 创建、创建重放、激活并轮询到ACTIVE、心跳、停止和停止重放 |
| `transcript` | 对本次或`SIM_CONVERSATION_ID`指定通话补交最终字幕并验证幂等重放 |
| `telemetry` | 批量上报设备状态,可关联本次通话,并验证幂等重放 |
| `commands` | 拉取当前设备未终结命令;普通命令提交合成成功结果,`firmware_update`执行私有下载、SHA-256、Ed25519签名、安装和确认状态链 |
| `media` | 为`SIM_CONVERSATION_ID`指定且具有当前录音授权的通话生成或读取WAV PCM16文件,直传对象存储,轮询至云端校验完成,并在`local_delete_after`到达后回报本地密文已删除;可设置`SIM_MEDIA_FILE`、`SIM_MEDIA_DURATION_MS`、`SIM_MEDIA_TRACK`、`SIM_MEDIA_VERIFY_TIMEOUT`和`SIM_MEDIA_DELETE_WAIT_MAX` |
| `all` | 依次执行启动、完整通话、字幕、遥测和命令;录音因需要单独授权和可访问的对象存储公开地址而不自动包含 |
例如执行联合模拟闭环:
```powershell
docker compose --profile simulation run --rm -e SIM_SCENARIO=offline,conversation,transcript,telemetry,commands sim-device
```
固件执行器不会信任命令下发通道携带的公钥。运行前必须通过`SIM_FIRMWARE_TRUSTED_KEYS`预置本地信任锚,值为“密钥编号到标准Base64 Ed25519公钥”的JSON对象,例如`{"vendor-2026":"<32字节公钥的Base64>"}`。执行器只接受固定的`/device/v2/firmware-releases/{id}/download`相对路径,拒绝任意公网地址、查询参数、片段和路径穿越;下载对象限制为1字节至256 MiB。下载后先校验小写SHA-256,再使用本机型号、硬件版本、实际字节数和命令清单重建`firmware-manifest-v1`并验证签名,全部通过才进入安装阶段。`SIM_DEVICE_MODEL`和`SIM_DEVICE_HARDWARE_REVISION`默认分别为`simulated-terminal`和`sim-rev-a`,必须与制品签名清单一致。
`SIM_FIRMWARE_FAULT`可设置为`download_failed`、`digest_mismatch`、`signature_mismatch`、`install_failed`、`interrupt_after_download`或`interrupt_after_install`;默认`none`。前三类安全失败和安装失败都会提交明确的`FAILED`终态;两类中断保留`ACKNOWLEDGED`阶段,设备进程重新启动后会重新拉取同一未终结命令并从安全起点重复执行,不把状态回退为`DELIVERED`。`SIM_FIRMWARE_STEP_DELAY`可在0至30秒内模拟下载校验后及安装后的耗时。
启用`SIM_FIRMWARE_AB_UPDATE=true`后,模拟器把制品写入非活动分区,使用`SIM_FIRMWARE_STATE_FILE`持久化启动尝试,并上报`staging`、`reboot_pending`和`boot_verifying`细分阶段。启动健康失败或超过`SIM_FIRMWARE_BOOT_HEALTH_WINDOW`会恢复旧分区并以`FAILED/rolled_back`报告已执行回退;`power_loss_after_stage`、`power_loss_during_boot`和`boot_health_failed`只在A/B模式下允许。完整状态、字段、顺序和真机边界见[设备A/B分区固件升级与启动确认协议](device-firmware-ab-protocol.md)。
轻量纵向验收执行:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-sim-firmware.ps1
```
脚本只建立一个临时公钥、一个签名版本、一个单设备批次和一条命令,先在制品校验完成后模拟进程中断,再以同一设备重新运行并核对`SUCCEEDED/CONFIRMED/100`及影响汇总,最后精确清理数据库和对象存储夹具。若目标设备已有未终结命令,脚本会在创建夹具前停止,避免消费真实在途任务。
仅验证A/B启动失败自动回退时执行`-Mode ABRollback`。该模式只增加一个本地状态文件,不增加固件版本或目标设备数量;验证完成后同样精确清理。
本地实测组合场景已收敛为`COMPLETED|COMPLETED`,供应商Start/Stop各1次、3条最终话轮(2条供应商+1条设备补交)、1条关联遥测且无开放Outbox。录音场景可在宿主机使用`go run ./cmd/sim-device`执行,以便`localhost:59000`短期上传地址可达;模拟器会等待校验结果并自动提交删除回执。Compose本地环境把`MEDIA_LOCAL_DELETE_GRACE`设为1秒,生产默认值仍为1小时。若删除时间超过`SIM_MEDIA_DELETE_WAIT_MAX`,模拟器明确打印计划时间并安全退出,不会提前回报。`SIM_MEDIA_FILE`仅作为操作人员提供的输入夹具,模拟器只释放内存副本,绝不删除该源文件。
完整环境启动后,可执行自动化通话冒烟:
```powershell
.\scripts\smoke-conversation.ps1
```
脚本会验证创建、首次激活、相同幂等键重放、Worker调用、两次签名回调去重和最终`ACTIVE`状态,并断言供应商仅启动一次。脚本只使用合成数据,验证结束后按本次通话编号精确清理数据库痕迹。
## 12. 错误处理
| HTTP状态 | 错误码 | 处理建议 |
|---|---|---|
| 400 | `VALIDATION_FAILED` | 修正字段后重新生成随机数和签名 |
| 401 | `DEVICE_AUTHENTICATION_FAILED` | 检查设备登记、时钟、摘要、签名或是否发生重放 |
| 503 | `DEVICE_AUTHENTICATION_UNAVAILABLE` | 退避后使用新随机数重试,不关闭鉴权 |
| 503 | `DEVICE_CONFIG_UNAVAILABLE` / `DEVICE_BOOTSTRAP_FAILED` | 退避重试并保留上一次有效配置 |
| 503 | `RUNTIME_CONFIG_UNAVAILABLE` | 生产环境没有命中活动语音路由;停止创建通话,由运营发布或恢复目标范围配置 |
| 403 | `CONSENT_REQUIRED` | 不重试,等待有效授权 |
| 403 | `TRANSCRIPT_STORAGE_NOT_ALLOWED` | 停止补传,等待留存策略或授权恢复,不得本地绕过 |
| 409 | `DEVICE_NOT_BOUND` / `CONVERSATION_ALREADY_ACTIVE` | 修复绑定或查询已有通话 |
| 409 | `IDEMPOTENCY_KEY_REUSED` | 不得用同一幂等键提交不同正文 |
| 409 | `ROOM_ALLOCATION_EXPIRED` | 重新创建通话并取得新的房间预留 |
| 409 | `RTC_ALLOCATION_MISMATCH` | 丢弃本地房间资料,重新拉取或重建通话 |
| 409 | `CONFIG_VERSION_CONFLICT` | 先重新获取设备配置再尝试创建新通话 |
| 409 | `PROVIDER_EVENT_CONFLICT` | 供应商事件编号复用且正文不同,转人工排查 |
| 409 | `CONVERSATION_STATE_CONFLICT` | 查询云端当前通话状态,不要强制覆盖状态机 |
| 409 | `TRANSCRIPT_TURN_CONFLICT` | 保留本地终稿和冲突明细,禁止覆盖云端终稿,转入人工对账 |
| 409 | `TELEMETRY_EVENT_CONFLICT` | 保留冲突事件并停止推进本地确认点,人工核对事件键和单调序号 |
| 404 | `TELEMETRY_CONVERSATION_NOT_FOUND` | 删除错误通话引用,不得改用任意长者编号重试 |
| 409 | `DEVICE_COMMAND_STATE_CONFLICT` | 重新拉取命令;不得覆盖已过期、已取消或其他设备的命令 |
| 503 | `TRANSCRIPT_BATCH_UNAVAILABLE` | 使用原幂等键和原正文退避重试,并生成新的设备签名随机数 |
| 503 | `DEVICE_TELEMETRY_UNAVAILABLE` | 保留本地事件队列,以原正文和原幂等键退避重试 |
| 503 | `CONVERSATION_HEARTBEAT_UNAVAILABLE` / `CONVERSATION_STOP_UNAVAILABLE` | 使用新随机数退避重试;停止请求保留原幂等键和原正文 |
服务端故意不区分“设备不存在”“设备已吊销”和“签名错误”的外部提示,避免泄露设备登记状态;内部可通过请求关联编号排障。
+125
View File
@@ -0,0 +1,125 @@
# 真机、真实供应商、试点与生产发布外部验收清单
> 用途:把本地已验证能力与必须由业务方、硬件方、供应商和生产环境共同完成的验收明确分开。
> 原则:前一阶段退出条件未满足,不进入下一阶段;所有证据使用生产候选版本号、构建摘要、配置版本和请求关联编号归档。
## 1. 外部输入与责任人
开始准备外部环境时,可先运行以下轻量预检。它只读取Git元数据、Compose运行服务、外部配置变量名称和证据索引,不读取密钥值,也不会调用真实供应商或执行完整回归:
```powershell
powershell -ExecutionPolicy Bypass -File scripts/stage8-preflight.ps1 `
-EvidenceIndex testdata/release-evidence-index.example.v2.json `
-OutputPath artifacts/stage8-preflight.json
```
自动化入口可增加`-Strict`;当候选提交、远程仓库、真实构建摘要或证据索引结构尚未就绪时返回非零退出码。该报告仅用于定位进入条件,不替代十类一致性报告和`cmd/release-evidence`最终校验。
| 输入 | 提供方 | 最低要求 | 未提供时影响 |
|---|---|---|---|
| 量产候选终端、板卡/音频硬件版本、设备证书注入流程 | 硬件/设备团队 | 至少3台,含目标麦克风、扬声器、网络模组和安全存储 | 无法验证采集质量、AEC、断网补传、升级和吊销 |
| 火山RTC正式测试应用、房间权限、回调和用量查询账号 | RTC供应商/平台团队 | 与生产同区域和同能力,密钥进入云KMS/Secrets Manager | 正式Token与签名Start/Stop适配器已完成,但不能确认真实入房、可选事件回调和计费 |
| 豆包实时语音/大模型、Embedding及内容安全账号 | Agent/供应商团队 | 明确模型版本、区域、额度、SLA、数据使用和留存条款 | 不能完成真实语音质量、模型效果、召回和内容安全验收 |
| 生产OIDC租户、运营角色和审批人员 | IAM/安全团队 | 客户、客服、操作、审批、管理员角色及两名独立审批账号 | 不能验证真实组织映射、双人审批和离职回收 |
| 云PostgreSQL、Redis、对象存储、KMS/HSM、监控和告警接收器 | 云平台/SRE | 私网、TLS、备份、跨可用区、容量配额和告警路由 | 不能完成生产等价恢复、密钥轮换和容量结论 |
| 试点家庭、长者授权文本、隐私政策和退出流程 | 业务/法务/试点运营 | 书面知情同意、联系人、异常升级和数据删除承诺 | 不允许进入家庭试点 |
## 2. 真机与真实供应商联调
### 2.1 进入条件
- 固定一个生产候选提交和OpenAPI版本,V1—当前迁移在空库与备份恢复库均通过;
- 真实密钥只通过云密钥引用注入,镜像、Git、日志和工单中无明文;
- 设备出厂身份、吊销名单、RTC应用、回调地址、IP/域名白名单和时钟同步已配置;
- 明确联调窗口、供应商技术联系人、回滚负责人和故障升级路径。
- 使用`POST /api/v1/ops/diagnostics/acceptance-snapshots`生成`REAL_DEVICE_PROVIDER`快照,候选版本、镜像摘要、迁移和实际必需依赖均已冻结;若存在阻断告警,按快照公开编号和资源核对状态转入告警工作台复核,不能仅凭`MISSING`自动关闭;技术门禁通过只作为进入联调的必要条件,不替代本节退出条件。
- 在告警工作台对每个实际活动路由执行`POST /api/v1/ops/alert-routing-policies/{id}/delivery-tests`,状态必须收敛为`DELIVERED`,接收端按`test_ref`只记录一次且逻辑接收组映射正确;测试不会生成正式告警,不能替代至少一次受控正式告警演练。
- 导出活动路由、联通测试、已解决的正式演练告警和网关脱敏采集,使用`cmd/alert-receiver-conformance`生成`PASS/REAL_RECEIVER`报告;证据包内`alert_receiver_validation.coverage_complete=true`后,`REAL_ALERT_RECEIVER`才可标记`VERIFIED`。
### 2.2 必测场景
| 领域 | 场景 | 通过标准与证据 |
|---|---|---|
| 设备身份 | 首次启动、正文篡改、Nonce重放、时钟偏差、证书吊销 | 正常请求成功;异常均稳定拒绝;吊销后新请求和新通话立即失败;保存请求编号和安全审计 |
| RTC | 正常入房、错房间、错用户、过期Token、续签、网络切换 | 仅绑定的房间/用户可入;短期Token不落业务库/日志;Wi-Fi/蜂窝切换后状态可收敛 |
| 语音 | 近讲/远讲、打断、回声、静音、噪声、长时通话 | 由业务方冻结主观评分方法和采样条件;保存设备型号、音频参数、供应商任务编号和原始评测记录 |
| 供应商任务 | 启动超时、未知结果、重复/乱序回调、停止、停止后晚到终稿 | 固定任务编号;未知先查询;回调不回退终态;停止不受启动熔断影响;晚到终稿触发新提取版本 |
| 离线补偿 | 断网重连、字幕批量补交、遥测补交、录音断点续传 | 业务键幂等,无重复话轮/分片;旧遥测不覆盖新状态;补交后后台任务全部收敛 |
| 固件 | 签名验证、灰度、暂停、失败重试、A/B双向升级、写入后断电、首次启动断电、健康超时和人工回滚 | 非法签名拒绝;暂停不新增投递;灰度进度与设备回执一致;至少3台候选设备各形成4份`PASS/HARDWARE_CAPTURE`一致性报告,A→B、B→A、健康超时和人工回滚另有操作记录;缺项保持`REAL_DEVICE_FIRMWARE/PENDING` |
| 隐私 | 授权撤回、主体删除、导出、临时敏感数据提权 | 撤回后停止新增留存;删除六子系统对账;临时提权双人审批、到期拒绝、逐次访问审计 |
### 2.3 退出条件
- 至少3台候选终端完成连续通话、断网恢复和升级/回滚,未出现跨设备、跨长者或跨租户数据串用;
- 同一候选构建的真机固件证据达到3台×4结构化场景,`release-candidate-evidence-bundle-v3`中的`firmware_validation.coverage_complete=true`;模拟器报告不得计入真机覆盖;
- 使用`cmd/rtc-conformance`归档至少3台候选终端的6通精简矩阵,报告必须为`PASS/REAL_DEVICE_RTC_PROVIDER`,证据包内`real_device_rtc_validation.coverage_complete=true`;模拟供应商不得计入。
- 供应商启动、停止、回调、用量和错误码映射与接口契约一致,所有未知差异已有书面处置结论;
- 使用`cmd/agent-models-conformance`归档真实内容生成10类固定评测、Embedding六类召回/隔离、内容安全六类策略和数据条款/额度证据;报告必须为`PASS/REAL_AGENT_MODELS`,证据包内`real_agent_models_validation.coverage_complete=true`。
- 发现的问题均进入V36联调问题台账并具备严重级别、根因、修复版本和脱敏回归证据;清单全量汇总显示未关闭HIGH/CRITICAL为0;
- 真实凭据完成一次轮换,旧凭据失效且业务不中断。
- 使用`cmd/kms-rotation-conformance`归档同一候选构建的云提供器、工作负载身份、动态读取和滚动重启两类轮换;报告必须为`PASS/REAL_KMS_HSM`,证据包内`kms_rotation_validation.coverage_complete=true`。
- 以相同候选构建重新生成验收快照并归档,六项技术门禁均通过;若候选构建变化,原快照作废并重新执行受影响场景。
## 3. 家庭试点
### 3.1 进入条件
- 法务批准知情同意、隐私说明、录音/字幕/故事/记忆分项授权、退出与删除流程;
- 试点运营、客服、SRE和安全值班表已发布,告警接收器和工单系统可用;
- 每个家庭有明确设备、长者、成员和授权边界,初始配置版本及灰度范围已冻结;
- 已生成`PILOT`验收快照,范围与本次试点客户组织/设备组一致,方舟和RTC等实际依赖不得从必需列表中省略;
- 备份恢复、供应商故障、固件暂停/回滚和隐私删除演练通过。
### 3.2 试点节奏
1. 内部员工家庭小范围验证,确认安装、网络、音频、授权和客服流程;
2. 少量外部家庭灰度,按客户组织/设备组限制并发影响,逐日评审告警、修订率、失败原因和成本;
3. 扩大前召开门禁评审,仅对新通话发布新配置,任何异常可回滚到上一已发布版本;
4. 试点结束执行授权盘点、导出/删除抽查、设备回收或转正式登记和问题复盘。
### 3.3 试点证据
- 每日:由`POST /api/v1/ops/diagnostics/pilot-daily-snapshots`按约定业务时区生成并归档设备在线、通话状态分布、供应商失败、上传积压、告警和费用快照;日报编号、日期、时区和复核人写入试点台账,异常转入告警或联调问题闭环;
- 每周:由`POST /api/v1/ops/diagnostics/pilot-weekly-snapshots`按周一开始的业务自然周生成并归档人工修订率、故事/记忆确认、隐私请求、固件分布和配置版本差异;平台没有投诉权威表,必须另附同周期客服或CRM脱敏投诉证据,`is_complete=false`的周中快照不得替代完整周报;
- 个案:请求编号、设备/通话公开编号、供应商任务编号、配置版本、处置时间线和结果;
- 退出:每个试点家庭的授权、数据保留、设备归属和待删除资源确认单。
## 4. 生产发布门禁
### 4.1 技术门禁
- CI在远程仓库实际通过,并用`cmd/ci-supply-chain-conformance`绑定完整提交、远程运行、14项检查、后端/Web镜像摘要及源码/双镜像三份CycloneDX SBOM;报告必须为`PASS/REMOTE_CI_SUPPLY_CHAIN`,本地工具链报告不得计入;
- 生产等价容量测试达到业务方冻结的RPS、并发、时延和持续时间,数据库/Redis/对象存储无不可接受饱和;
- PostgreSQL备份恢复、Redis丢失重建、API/Callbacks/Worker滚动重启、供应商停机与恢复演练通过;
- 使用`cmd/recovery-conformance`归档同一候选的四类演练,报告必须为`PASS/PRODUCTION_EQUIVALENT`,证据包内`production_recovery_validation.coverage_complete=true`;本地Compose报告不得计入。
- 使用`cmd/infrastructure-conformance`归档同一候选的七类基础设施和规定控制项,报告必须为`PASS/PRODUCTION_INFRASTRUCTURE`,证据包内`production_infrastructure_validation.coverage_complete=true`;本地可达性和静态清单不得计入。
- 云KMS/HSM轮换、证书到期告警、外部基础设施探针和真实告警接收器均有实测证据;
- 最近两个Chrome/Edge和macOS最近两个Safari版本通过关键流程;
- 使用`cmd/safari-conformance`归档同一候选在真实Safari `CURRENT/PREVIOUS`、两视口和六流程的24项结果;报告必须为`PASS/REAL_SAFARI`,证据包内`safari_compatibility_validation.coverage_complete=true`。
- 核心模块覆盖率和关键分支覆盖率达到需求规定门槛,当前本地未达标项必须在发布前关闭或由风险负责人书面接受。
- 已使用生产候选镜像摘要生成`PRODUCTION_RELEASE`验收快照;运行版本必须等于候选版本且包含提交号和构建时间,六项技术门禁全部通过。
- 使用`cmd/release-evidence`为同一候选版本、镜像摘要和源提交生成最终证据包;十类证据索引不得存在`PENDING`。出现`WAIVED`时必须附有权责任人的书面风险接受,包状态只能进入带豁免的发布评审,不能自动视为批准;八类不可豁免门禁不得使用`WAIVED`。
### 4.2 业务与合规门禁
- 产品、硬件、Agent、SRE、安全、法务和试点运营共同签署验收结论;
- 供应商合同明确数据地域、训练使用、日志/录音留存、删除时限、SLA和事件通报;
- 隐私访问、导出、删除、授权撤回、临时提权和审计留存抽查通过;
- 价格版本、额度策略、告警路由、固件目标和运行配置均完成另一人审批;
- 发布公告、客服话术、回滚条件、状态页和事故联系人准备完成。
### 4.3 发布与回滚
生产采用客户组织/设备组小比例灰度,先验证身份、通话、供应商、上传、费用和告警,再逐级扩大。每一级保存开始/结束时间、版本、范围、观测结果和审批人。出现跨租户、未授权敏感数据访问、无法停止的供应商任务、持续回调验签失败、隐私删除不可对账或高严重度安全事件时立即停止扩量并回滚;历史通话继续使用其冻结配置解释,不重写历史版本。
## 5. 最终签署表
| 门禁 | 负责人 | 证据位置 | 结论 | 日期 |
|---|---|---|---|---|
| 真机与RTC/Agent供应商联调 | 设备/Agent负责人 | 待填写 | 待执行 | — |
| 生产等价容量与恢复 | SRE | 待填写 | 待执行 | — |
| 安全、KMS/HSM与供应链 | 安全负责人 | 待填写 | 待执行 | — |
| 隐私、合同与试点授权 | 法务/业务负责人 | 待填写 | 待执行 | — |
| 浏览器与运营工作台 | QA | 待填写 | Safari待执行 | — |
| 核心覆盖率与发布风险 | 研发负责人 | `coverage.integration.out`及CI | 当前未通过 | 2026-08-23 |
| 生产灰度与回滚批准 | 发布经理 | 待填写 | 待执行 | — |
+122
View File
@@ -0,0 +1,122 @@
# 固件真机联调一致性证据采集与校验说明
## 1. 目标
固件一致性证据用于回答“设备本地实际发生了什么”与“云端收到并据此处置了什么”是否一致。它把设备侧Bootloader、分区、启动尝试和健康结果,与云端可靠命令、固件阶段回执和批次终态绑定,避免只凭页面最后状态或设备口头说明判断升级结果。
本功能包含三部分:
1. V38`firmware_assignment_event`不可变事件表,留存每次下载、安装、写分区、重启、启动健康确认和回退回执;
2. 设备侧`firmware-device-evidence-v1`脱敏证据格式;
3. `cmd/firmware-conformance`离线校验命令,输出带输入SHA-256和逐项结论的`firmware-conformance-report-v1`报告。
校验结果为`PASS`只表示两份输入在协议、身份、顺序和结果上相互一致。模拟器证据固定标记为`PROTOCOL_SIMULATION`,真机证据标记为`HARDWARE_CAPTURE`;两者都保留`external_validation_required=true`,不能据此自动批准生产发布。
## 2. 云端阶段事件
设备通过`POST /device/v2/commands/{id}/ack`直接回执,或通过批量遥测中的`command_result`补交结果时,云端在更新命令和固件分配的同一事务内写入阶段事件。任一步失败会整体回滚,不会出现命令已变化但事件缺失的半完成状态。
事件使用`assignment_id + command_status + result_digest`唯一键去重。`result_digest`是服务端对规范JSON结果摘要计算的小写SHA-256;完全相同的重复ACK仍保持命令幂等,但不会生成重复时间线记录。不同进度、阶段、启动尝试或终态具有不同摘要,会形成新的事件。
| 字段 | 用途 |
|---|---|
| `command_id` | 对外设备命令编号,绑定设备证据 |
| `command_status`、`assignment_status` | 命令状态和云端固件分配主状态 |
| `phase`、`progress_percent`、`result_code` | 设备阶段、进度和稳定结果码 |
| `active_slot`、`target_slot`、`previous_slot`、`failed_slot` | A/B分区转换和失败分区 |
| `boot_attempt_id`、`boot_health_deadline` | 同一次启动尝试和健康窗口 |
| `rollback_applied`、`rollback_version` | 是否实际回退及回退版本 |
| `installed_version` | 成功确认的目标版本 |
| `verified_sha256`、`verified_signing_key_id` | 设备实际验证的制品和本地信任锚编号 |
| `result_digest`、`reported_at` | 去重摘要和云端接收时间 |
事件只保留固件状态和非敏感标识,不返回完整`result_summary`。原结果摘要继续留在当前分配和命令记录中,并受既有递归敏感字段拒绝规则保护。批次详情按设备、云端接收时间和事件编号升序返回时间线;V38之前创建的历史批次没有倒推事件,页面明确显示“暂无逐步时间线”,不会伪造历史。
## 3. 设备证据格式
设备侧文件必须使用`firmware-device-evidence-v1`,未知字段被校验器拒绝,避免采集程序和校验程序在未评审的情况下悄然漂移。
| 分类 | 必需字段 |
|---|---|
| 候选身份 | `candidate_version`、`build_digest`,摘要格式为`sha256:<64位小写十六进制>` |
| 证据来源 | `evidence_source=SIMULATOR/HARDWARE` |
| 设备身份 | 设备公开编号、型号、硬件版本、发布编号、命令编号 |
| 平台基线 | Bootloader版本、分区表SHA-256、是否启用安全启动 |
| 制品身份 | 固件SHA-256、设备本地采用的签名密钥编号 |
| 测试场景 | `AB_SUCCESS`、`AB_ROLLBACK`、`POWER_LOSS_AFTER_STAGE`或`POWER_LOSS_DURING_BOOT` |
| 分区与版本 | 初始、目标、最终分区及初始、目标、最终版本 |
| 启动尝试 | `boot_attempt_id`及设备本地事件时间线 |
| 采集时间 | UTC`collected_at`,不得早于任何本地事件 |
设备证据不得包含设备HMAC密钥、私钥、Token、完整序列号、家庭、长者、录音、字幕、网络口令或云账号。量产采集适配器应从受控Bootloader环境区、只读系统版本文件和升级日志生成证据,并在离开设备前完成字段白名单过滤。
## 4. 一致性校验项
校验器逐项输出`PASS/FAIL`,任何一项失败都会使总结果为`FAIL`:
1. 设备证据版本和来源合法;
2. 候选版本及构建摘要完整;
3. 设备、发布、命令公开编号和型号/硬件版本完整;
4. Bootloader及分区表摘要存在;
5. 制品摘要和签名密钥编号完整;
6. 云端批次唯一包含目标设备;
7. 云端批次与设备证据引用同一发布;
8. 云端时间线包含设备声明的已验证摘要和签名密钥;
9. 全部云端事件绑定同一命令;
10. 云端事件UUID、结果摘要哈希和接收顺序有效;
11. 设备本地事件顺序有效且不晚于采集时间;
12. 场景和必需阶段顺序正确;
13. 云端与设备引用同一启动尝试;
14. 同名设备/云端阶段最大时间差不超过10分钟;
15. 最终分区、版本、命令终态、分配终态及回退事实一致。
时间相关性使用同名阶段的最近时间,不要求设备与云端毫秒级相等;超过10分钟通常表示设备时钟、采集时区、离线补交或证据混用问题,必须先解释并重新采集,不能手工修改报告。
## 5. 命令用法
先以有权查看固件批次的账号下载`GET /api/v1/firmware-rollouts/{id}`完整JSON响应,再从设备导出证据文件。校验在离线环境执行:
```powershell
go run ./cmd/firmware-conformance `
-cloud C:\evidence\rollout-response.json `
-device C:\evidence\device-evidence.json `
-output C:\evidence\firmware-conformance-report.json
```
输出路径必须为绝对路径,文件权限限制为当前用户可读写。通过时进程退出码为0,业务不一致时仍写出完整失败报告并返回1,参数、文件或JSON格式错误返回2。报告包含两份输入文件的SHA-256,可在归档后判断输入是否被替换;报告不复制云端完整结果摘要或设备原始日志。
## 6. 模拟闭环
模拟器在A/B模式下可通过以下变量生成独立设备侧证据:
| 变量 | 作用 |
|---|---|
| `SIM_FIRMWARE_EVIDENCE_FILE` | 设备证据绝对路径 |
| `SIM_FIRMWARE_EVIDENCE_SCENARIO` | 本次场景 |
| `SIM_CANDIDATE_VERSION`、`SIM_BUILD_DIGEST` | 候选构建身份 |
| `SIM_BOOTLOADER_VERSION` | 模拟Bootloader版本 |
| `SIM_PARTITION_TABLE_SHA256` | 模拟分区表摘要 |
| `SIM_SECURE_BOOT_ENABLED` | 模拟安全启动状态,只允许true/false |
运行以下单条低强度烟测:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-sim-firmware.ps1 -Mode ABRollback
```
脚本断言云端形成7条去重事件:两条下载、安装开始、非活动分区写入、等待重启、启动健康检查和已回退;随后使用模拟器独立生成的设备证据执行校验器。报告必须全部通过、范围必须是`PROTOCOL_SIMULATION`且必须继续声明外部验收未完成。烟测结束删除临时数据库记录、对象、固件、状态、两份输入和报告。
## 7. 真机交付要求
硬件团队应为每台候选设备的A→B、B→A、写入后断电、首次启动断电、健康超时和人工回滚分别交付设备证据与云端批次快照。研发或QA使用同一候选校验器生成报告,并把原输入及报告一并放入候选证据库。任何`FAIL`、不同命令编号、不同启动尝试、摘要不一致或时间差超限都必须进入联调问题台账;不得只保留截图或手工修改后的汇总表。
## 8. 阶段8发布证据包门禁
`cmd/release-evidence`使用`release-evidence-index-v2`中的`REAL_DEVICE_FIRMWARE`登记真机报告,并在`release-candidate-evidence-bundle-v3`清单内生成`firmware_validation`覆盖摘要。当前自动门禁要求至少3台不同候选设备中的每一台都完成以下四种可由一致性校验器结构化确认的场景:
- `AB_SUCCESS`:候选分区启动并完成健康确认;实际联调同时覆盖A→B和B→A方向;
- `AB_ROLLBACK`:启动健康失败或超时后自动回到旧分区;人工回滚另附操作与审计证据;
- `POWER_LOSS_AFTER_STAGE`:写入完成、切换启动前断电并恢复;
- `POWER_LOSS_DURING_BOOT`:首次候选分区启动期间断电并恢复。
每份报告必须是`PASS/HARDWARE_CAPTURE`,候选版本、构建摘要、公开设备/发布/批次/命令编号、两份输入摘要和全部20项检查必须完整。相同设备和场景只能登记一次。缺少任一设备或场景时,`REAL_DEVICE_FIRMWARE`必须保持`PENDING`,打包清单会列出每个场景的设备计数和缺口;把不完整项标记为`VERIFIED`会直接拒绝打包。该门禁不可用`WAIVED`绕过,也不接受`PROTOCOL_SIMULATION`报告。人工回滚、A/B双向覆盖及硬件团队签署继续作为同一证据项的外部复核材料,不因结构化四场景通过而自动获得生产批准。
+140
View File
@@ -0,0 +1,140 @@
# 固件制品、签名、灰度与回滚开发说明
## 1. 目标与边界
固件发布链路用于向已绑定家庭的兼容数据采集终端下发经过校验的固件制品。平台不保存固件私钥,也不在浏览器或服务端生成生产签名;生产私钥应保存在离线签名机、HSM或云密钥管理服务中。平台只登记受信任的Ed25519公钥、校验签名和摘要,并通过短期私有对象地址向被分配的设备提供下载。
## 2. 数据对象
| 对象 | 关键内容 | 状态 |
|---|---|---|
| `firmware_signing_key` | 租户、密钥编号、Ed25519公钥、创建人 | `ACTIVE`、`REVOKED` |
| `firmware_release` | 家庭、设备型号、硬件版本、版本号、对象引用、大小、SHA-256、签名、密钥编号及安全基线 | `UPLOADING`、`READY`、`PAUSED`、`RETIRED` |
| `firmware_rollout` | 固件版本、批次名称、灰度比例、创建人、开始/完成时间 | `DRAFT`、`RUNNING`、`PAUSED`、`COMPLETED`、`ROLLED_BACK` |
| `firmware_assignment` | 批次、设备、可靠命令、原版本、下载/安装进度、最近回执 | `ASSIGNED`、`DOWNLOADING`、`INSTALLING`、`CONFIRMED`、`FAILED`、`ROLLED_BACK` |
## 3. 签名清单
Ed25519签名输入使用确定性UTF-8文本,字段顺序和末尾换行不可改变:
```text
firmware-manifest-v1
model={设备型号}
hardware_revision={硬件版本}
version={固件版本}
sha256={小写SHA-256}
size={字节数}
```
研发辅助命令`go run ./cmd/firmware-sign <model> <hardware-revision> <version> <file>`会生成临时密钥并输出测试签名,只能用于合成数据和本地冒烟,不能代替生产签名设施。
## 4. 发布流程
1. 所有者或管理员登记受信任的Ed25519公钥;编辑者和查看者无权操作。
2. 管理端提交制品元数据,平台创建`UPLOADING`版本并返回15分钟私有上传地址。
3. 管理端直接上传制品到私有对象存储,业务API不转发大文件。
4. 管理端请求完成版本;平台流式核对实际大小和SHA-256,再使用租户内活动公钥验证清单签名。
5. 只有两项校验均通过,版本才进入`READY`;摘要、大小或签名不匹配均不能进入灰度流程。
6. 网络中断或原上传地址过期时,管理端必须重新选择与原记录大小和SHA-256完全一致的文件;平台仅为仍处于`UPLOADING`的版本重新签发15分钟上传地址,并在覆盖同一私有对象后重复执行完成校验。无法继续的版本可正式转为`RETIRED`并清理对象;退役后允许以相同业务版本重新创建纠正后的制品,但退役历史和审计仍保留。
7. 管理端选择已绑定且型号/硬件兼容的设备和1—100灰度比例。平台按设备编号确定性选样,并至少选择一台目标设备。
8. 每台目标设备生成一条幂等`firmware_update`可靠命令。设备使用HMAC身份拉取命令,再请求5分钟私有下载地址。
9. 设备在本地再次校验摘要和厂商签名,并通过命令回执上报`downloading`、`installing`阶段及0—100进度;云端依次映射为`DOWNLOADING`、`INSTALLING`。
10. 安装完成后设备上报`SUCCEEDED`或`FAILED`,分配记录进入`CONFIRMED`或`FAILED`。全部目标终态后,批次自动收敛为`COMPLETED`或`PAUSED`。
## 5. 暂停与回滚
暂停仅允许作用于`RUNNING`批次。平台将批次改为`PAUSED`,取消尚未投递的`QUEUED`命令,并立即禁止该批次继续取得固件下载地址。已经下载或正在安装的设备不能依赖云端强制中断,设备固件必须实现本地安全停止点和启动失败自动回退。
回滚不是修改原制品,而是选择另一个已经完成摘要及签名验证的`READY`版本,针对原批次全部目标设备创建新的100%可靠批次。这样可以保留完整版本、命令、设备结果和审计链。
## 6. 接口
| 方法与路径 | 用途 |
|---|---|
| `POST /api/v1/households/{id}/firmware-signing-keys` | 登记受信任公钥 |
| `GET /api/v1/households/{id}/firmware-signing-keys` | 查询租户可信公钥台账、指纹、状态及固件引用数量 |
| `POST /api/v1/households/{id}/firmware-signing-keys/revoke` | 在引用保护通过后吊销可信公钥 |
| `POST /api/v1/households/{id}/firmware-releases` | 创建版本和短期上传地址 |
| `GET /api/v1/households/{id}/firmware-releases` | 查询固件版本、校验状态及元数据 |
| `POST /api/v1/firmware-releases/{id}/finalize` | 校验大小、摘要和签名 |
| `GET /api/v1/firmware-releases/{id}/impact` | 汇总关联批次、设备阶段、命令处置和仍需观察设备 |
| `POST /api/v1/firmware-releases/{id}/upload-session` | 为仍处于上传中的版本重新签发15分钟私有上传地址 |
| `POST /api/v1/firmware-releases/{id}/pause` | 暂停已验证版本分发,并暂停关联运行批次、取消未投递命令 |
| `POST /api/v1/firmware-releases/{id}/resume` | 在签名密钥仍活动时恢复版本分发,不自动恢复旧批次 |
| `POST /api/v1/firmware-releases/{id}/retire` | 退役版本;未完成对象尝试清理,已验证对象和历史保留 |
| `POST /api/v1/households/{id}/firmware-rollouts` | 创建灰度批次 |
| `GET /api/v1/households/{id}/firmware-rollouts` | 查询家庭内最近灰度批次及结果汇总 |
| `GET /api/v1/firmware-rollouts/{id}` | 查询批次、逐设备阶段、进度和最近回执 |
| `POST /api/v1/firmware-rollouts/{id}/pause` | 暂停批次并取消未投递命令 |
| `POST /api/v1/firmware-rollouts/{id}/rollback` | 使用已验证版本建立回滚批次 |
| `GET /device/v2/firmware-releases/{id}/download` | 被分配设备取得短期私有下载地址 |
完整字段、错误结构和响应模型以`openapi/openapi.yaml`为准。
设备登记记录中的`minimum_firmware_version`是新通话准入策略,与固件制品声明的`minimum_security_version`用途不同。前者比较设备当前固件并阻止低版本创建通话,后者描述该制品运行所需的安全基线。调整准入策略属于设备注册/运维配置职责;发布一个新制品不会自动抬高所有设备的通话下限,避免灰度期间未升级设备被意外整体阻断。
V37将固件业务版本唯一性调整为仅约束未退役记录,并把空主板版本统一视为同一值。这样同一租户内相同型号、主板和版本不能同时存在两个活动清单;只有原`UPLOADING`版本正式退役后,才允许使用相同业务版本创建纠正后的制品。退役记录不会物理删除,历史审计仍可追溯。完成校验和退役都使用带原状态条件的更新并检查实际影响行数,二者并发时只有一个状态转换能够成功。
可信公钥按租户形成独立台账。查询接口只返回SHA-256指纹、状态、登记/吊销时间以及`UPLOADING`版本、`READY`版本和`RUNNING`批次引用数量,不返回原始公钥。密钥编号一经登记即绑定同一公钥:相同编号和相同公钥可幂等重放,不同公钥不得覆盖,已吊销编号不得重新激活;轮换必须先用新编号登记新公钥,再让新版本引用新编号。存在上传中版本或运行中灰度批次时禁止吊销,避免中断完成校验或在途升级;仅有已就绪历史版本时允许吊销,但吊销后这些版本不能再启动新灰度,且新版本也不能引用该密钥。
已验证版本支持`READY → PAUSED → READY`和`READY/PAUSED → RETIRED`生命周期。暂停版本在同一事务把关联`RUNNING`批次转为`PAUSED`,取消尚未投递的`QUEUED`设备命令;新灰度创建和新下载地址都会因版本不再是`READY`而被拒绝。已经签发的短期下载地址无法远程收回,最长仍可能在原5分钟有效期内使用;已经下载或进入安装的设备继续依靠设备端安全停止点和失败回退,页面需继续观察其状态。恢复版本前重新核验签名密钥仍为`ACTIVE`,且只恢复版本本身,不自动恢复旧批次或重新创建已取消命令。
退役未完成版本仍尝试删除可能已上传的对象,并允许重复调用以修复对象存储瞬时清理失败。退役已验证版本时保留不可变制品对象、清单、历史批次和审计,防止破坏取证与追溯;若仍有关联`RUNNING`批次则返回`FIRMWARE_RELEASE_IN_USE`,要求先执行版本暂停。退役后版本永久不能创建新灰度或签发新下载地址。
版本影响查询使用服务端权威数据实时汇总,不把页面缓存当作处置结果。批次统计区分运行、暂停、完成和已回滚;设备统计区分已分配、下载中、安装中、已确认、失败和已回滚;命令统计单独显示排队、送达、确认、成功、失败、过期以及暂停时取消的未投递命令。仍需观察设备包括`DOWNLOADING`、`INSTALLING`,以及分配仍为`ASSIGNED`但命令已送达、确认或出现结果待对账的设备。接口返回完整汇总和最多100条观察清单,超过100条时设置截断标志,避免页面误把未展示设备理解为已经收敛。
## 7. 验证
日常开发优先执行低强度纵向冒烟:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-sim-firmware.ps1
```
该脚本只创建一个受信公钥、一个签名制品、一个单设备批次和一条升级命令。模拟终端从固定设备接口取得私有地址,限制下载大小,核对SHA-256,并使用`SIM_FIRMWARE_TRUSTED_KEYS`内预置而非命令携带的Ed25519公钥验证`firmware-manifest-v1`签名;校验完成后上报`ACKNOWLEDGED/DOWNLOADING/100`。脚本随后模拟进程中断,重启后重新拉取保持`ACKNOWLEDGED`的同一命令,重复安全校验并上报安装及`SUCCEEDED/CONFIRMED/100`,最后检查版本影响中的已确认、成功命令、已完成批次和零观察设备。夹具数据库记录与对象存储制品在结束时精确清理。
需要覆盖发布管理全部异常门禁时再运行完整回归:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-firmware-rollout.ps1
```
脚本验证角色拒绝、合法签名、非法签名拒绝、浏览器跨域直传预检、版本/批次列表、灰度暂停、未投递命令取消、暂停后下载拒绝、已验证版本回滚、设备下载SHA-256、25%下载、80%安装和100%确认状态收敛,并在结束后清理制品及数据库合成数据。
## 8. Web管理页
具有`firmware.manage`能力的家庭成员可在“固件发布”页完成公钥登记、制品选择、浏览器SHA-256计算、私有对象直传、服务端签名校验、灰度目标选择、批次暂停和回滚。生产私钥始终不进入页面或平台:页面只接收离线签名结果和公钥。
登记公钥先在浏览器验证输入是标准Base64编码的32字节Ed25519公钥,并计算SHA-256指纹;页面内受控操作区冻结家庭、密钥编号、长度和完整指纹,要求操作者通过受控渠道核对后再提交。页面会对照可信公钥台账:相同编号和相同指纹只作为幂等登记,不同指纹或已吊销编号直接阻断并提示使用新的轮换编号;服务端仍重新解码公钥、复核不可变编号规则、校验固件管理能力并写入审计。私钥没有输入字段,也不会进入确认区、API请求或平台数据库。
可信公钥台账在页面显示完整指纹、状态、登记/吊销时间和三类引用数量。只有活动密钥可以进入吊销确认区;存在上传中版本或运行中批次时按钮禁用并展示阻断原因。允许吊销时,确认区固定密钥编号、完整指纹和引用统计并要求确认影响。已吊销密钥仍保留在台账中用于历史追溯,但不能登记替换、创建新版本或启动新灰度。
制品上传先由浏览器计算文件SHA-256,并验证离线签名是标准Base64编码的64字节Ed25519签名。页面允许填写最低安全基线和最长2000字符的发布说明;受控操作区冻结家庭、型号、主板、版本、文件名、实际大小、完整摘要、签名密钥编号以及逐字段`firmware-manifest-v1`清单。只有操作者确认离线签名对应这份清单后,页面才创建15分钟私有上传地址、把原始文件直接PUT到对象存储并调用完成校验;任一步失败都不会把版本显示为`READY`,只有服务端流式核对大小、摘要和活动公钥签名全部通过才进入灰度候选。
版本停留在“等待校验”时,版本卡片提供“恢复上传”和“退役未完成版本”两个入口。恢复表单在浏览器重新计算所选本地文件摘要,文件大小或完整SHA-256任一不一致即禁止继续;匹配后再进入受控确认区,确认后依次重新签发地址、PUT同一对象和重复完成校验。退役确认区固定家庭、版本编号、清单摘要和签名密钥,明确已就绪版本不会被清理、先前签发地址最多仍可在原15分钟窗口内写入但不能恢复退役状态。退役接口可安全重复,以便对象存储瞬时失败后再次请求清理。
已验证版本卡片提供“暂停分发”“恢复分发”和“退役版本”。暂停确认区显示版本、完整摘要、签名密钥和运行中批次数,明确暂停会阻断新灰度/新下载、暂停关联批次并取消未投递命令;退役按钮在仍有运行批次时禁用并提示先暂停。已暂停版本只有在签名密钥仍活动时允许恢复,确认区明确旧批次不会自动重启。已验证版本退役确认区明确制品与历史保留、短期地址有效窗口和永久停止分发边界。
每个版本卡片均提供“查看影响”。影响面板显示版本权威状态、计算时间、关联批次、各设备阶段、排队及已取消命令数量,并逐台列出安装中、下载中或回执待收敛设备的批次、阶段、进度、命令状态和最后上报时间。暂停、恢复或退役成功后,页面同时刷新版本、批次、公钥引用和当前影响面板,使操作者能够核对处置结果;查询本身只读且不写审计或业务状态。
启动灰度批次使用页面内受控操作表单。版本选择只列出`READY`制品;候选设备必须已注册到当前家庭,且设备型号、硬件版本与所选制品一致。打开确认区时冻结家庭、批次名称、制品编号与摘要、灰度比例以及候选设备编号和当前版本,修改版本会立即清空旧设备选择。操作者确认兼容性和多设备版本变更影响后才提交;服务端再次核验版本、设备归属和兼容性,再按设备编号确定性分桶,至少命中一台设备,并为实际目标创建24小时有效的可靠升级命令。因而页面显示的候选数不被当成最终命中数,也不能绕过服务端状态校验。
版本列表显示设备型号、硬件版本、固件版本、大小、签名密钥和校验状态;批次详情显示每台设备的`ASSIGNED → DOWNLOADING → INSTALLING → CONFIRMED`进度或失败结果。该页已经在隔离的实际Chrome内完成1440×1000与1024×900渲染检查,未发现横向溢出、控制台错误或失败请求;真实多浏览器兼容性仍属于类生产验收范围。
批次暂停和版本回滚使用页面内受控操作表单。暂停表单固定当前批次编号、状态、完成比例、设备总数及各状态数量;回滚表单还固定用户选择的`READY`目标版本,避免确认过程中目标漂移。两项操作都要求显式确认对尚未执行设备、在途设备和后续回滚指令的影响;取消,或切换页签、家庭,都会清空草稿且不发送请求。服务端继续校验管理角色、租户、批次状态、目标版本可用性和并发版本。
本轮按低强度功能验收策略复用既有服务端签名、上传、下载和状态收敛证据,只执行前端生产构建、Web镜像重建以及真实OIDC Chrome无提交冒烟。冒烟分别验证无效公钥/签名阻断、有效输入启用、指纹和清单目标冻结、影响确认及取消关闭;固件公钥、版本和审计数量保持不变,登记/制品创建/完成校验/对象PUT请求均为0,页面无横向溢出、控制台错误或失败请求。灰度批次创建的同轮轻量冒烟继续验证兼容设备选择、确认门禁和零业务写入;本轮不重复执行完整设备下载、安装、暂停和回滚回归。
上传恢复批次按相同低强度策略执行V37升级、真实PostgreSQL服务/HTTP定向用例、生产构建和一次真实OIDC Chrome无提交冒烟。定向用例覆盖续传地址重签、非管理员拒绝、`READY`禁止续传/退役、退役幂等、退役后禁止完成校验、同业务版本纠正重建及HTTP禁止缓存;浏览器覆盖错误文件阻断、匹配文件启用、恢复/退役目标冻结和取消零写入。临时版本、公钥和审计已清理,页面无溢出、控制台错误或失败请求;完整设备升级回归未重复执行。
可信公钥台账批次继续采用低强度功能验收:执行真实PostgreSQL服务层和HTTP定向用例、前端生产构建、容器镜像重建以及一次真实OIDC Chrome无提交冒烟。定向用例覆盖同密钥幂等登记、同编号替换拒绝、引用统计、上传中/运行中引用阻断吊销、吊销幂等、吊销后禁止创建版本和启动灰度;浏览器覆盖完整指纹与引用展示、阻断原因、轮换编号提示、吊销目标冻结和取消零写入。未重复执行完整设备下载、安装和回滚回归,临时密钥、版本、批次及审计夹具已清理。
版本生命周期批次按同一低强度策略执行真实PostgreSQL服务层和HTTP定向用例、前端生产构建、容器重建以及一次真实OIDC Chrome无提交冒烟。定向用例覆盖暂停/恢复幂等、暂停版本阻断新灰度、运行批次与排队命令同步收敛、运行引用阻断退役、吊销密钥阻断恢复、已验证退役保留对象以及稳定HTTP冲突码;浏览器只核对按钮门禁、目标冻结、影响确认和取消零写入,不重复完整设备下载、安装和回滚回归。
影响预览批次继续采用低强度验证:真实PostgreSQL服务层和HTTP定向用例构造一条排队命令和一台安装中设备,分别核对暂停前后的批次、设备、取消命令和观察清单;前端执行生产构建、容器重建及一次真实OIDC Chrome只读冒烟,核对汇总口径、观察原因、无写请求和页面布局。完整设备升级与回滚回归不重复执行。
模拟终端执行器批次继续遵循低强度策略:定向Go用例覆盖正常下载、摘要和Ed25519签名校验、下载/摘要/签名/安装失败、两处中断及清单边界;真实PostgreSQL定向用例只核对`ACKNOWLEDGED`命令重拉不发生状态回退。随后仅执行一次“校验后中断—进程重启—重复安全校验—安装确认”的真实服务纵向冒烟,验证两次投递、最终批次与影响汇总,并确认临时密钥、版本、批次、命令、审计和对象夹具清理完成;不重复Web、暂停和回滚全量回归。
A/B启动确认批次在OpenAPI 0.62.0下扩展可选回执阶段,不增加旧设备必填字段或主状态枚举。启用设备先将制品写入非活动分区并持久化启动尝试,再依次上报`staging`、`reboot_pending`和`boot_verifying`;健康通过才提交成功,健康失败或超时则恢复旧分区并提交`FAILED/rolled_back`及回退版本。云端将三类中间阶段保持为`INSTALLING`,同时保留完整`last_result`供页面展示。低强度验证只执行定向Go用例、前端类型/生产构建和一次B分区启动失败自动回A分区的纵向冒烟,不把模拟结果作为真实Bootloader验收。详细协议见[设备A/B分区固件升级与启动确认协议](device-firmware-ab-protocol.md)。
OpenAPI 0.63.0和V38进一步为每台固件分配保存去重的阶段事件。直接命令ACK与批量遥测补交在更新命令/分配的同一事务内写入事件,重复结果摘要通过SHA-256唯一键保持幂等;批次详情按设备返回完整时间线,页面显示云端接收时间、阶段、命令状态和稳定结果码。`cmd/firmware-conformance`可把该时间线与设备侧Bootloader/分区证据进行离线一致性校验,详细格式和15项检查见[固件真机联调一致性证据采集与校验说明](firmware-conformance-evidence.md)。
+210
View File
@@ -0,0 +1,210 @@
# 集成诊断工作台开发说明
> 对应接口契约:OpenAPI 0.55.0
> 适用角色:`ops_viewer`、`ops_operator`、`ops_approver`、`ops_admin`
> 适用阶段:模拟联调、真机联调、试点运行和生产故障处置
## 1. 建设目标与安全边界
集成诊断工作台用于把设备、通话、RTC房间、供应商任务和接口请求关联到同一条脱敏诊断记录,减少联调时在多个数据库表和日志系统之间人工拼接信息的成本。查询始终使用已经验证的OIDC身份和明确的`X-Tenant-Id`,服务端先解析运营账号与客户组织关系,再固定租户条件执行查询。
工作台不返回字幕正文、录音地址、RTC短期令牌、设备签名密钥、供应商API Key、Outbox正文或配置密钥内容。`ops_viewer`和`ops_approver`只能查询;`ops_operator`可执行配置刷新、安全停止、状态对账、死信重试和诊断包导出;设备吊销仅允许`ops_admin`执行。所有修改动作必须填写原因、提供幂等键,并记录操作人、请求关联编号、资源和结果。
字幕正文和原录音不属于诊断查询返回。需要排查内容问题时,必须转入[运营敏感数据临时访问](ops-sensitive-access.md)流程,按单次通话申请最小能力、由另一人审批并逐次审计。
## 2. 功能设计
### 2.0 外部依赖预检
API和Worker按配置周期检查OIDC JWKS、方舟HTTPS入口及火山RTC HTTPS入口。OIDC检查要求响应成功、JSON可解析且至少包含一把签名密钥;方舟和RTC只执行DNS、TCP及证书校验TLS握手,不发送模型或语音业务请求,不消耗额度。V35把每个进程和依赖的最近结果写入平台级状态台账,字段包括最近检查、最近成功/失败、耗时、探针周期、连续失败和有限错误码。台账不保存实际端点、证书正文、原始网络错误或任何凭据。
联调页面把超过三倍探针周期未更新的记录显示为“结果过期”,即使其最后状态为成功也不能作为当前可用证据。进程启动时删除本进程已停用依赖的旧记录,避免从方舟或RTC切回本地实现后继续展示历史探针。探针失败不会让健康API或Worker退出就绪状态,但会进入Prometheus和告警规则,便于区分“平台自身不可用”和“外部联调条件未满足”。
### 2.1 精确检索
| 检索字段 | 输入 | 主要关联路径 | 返回信息 |
|---|---|---|---|
| `device_id` | 设备公开UUID | `device → conversation → rtc_task` | 设备状态、在线状态、固件/配置版本及相关通话 |
| `conversation_id` | 通话公开UUID | `conversation → device/rtc_task/provider_event` | 通话状态、预留期限、房间及供应商任务状态 |
| `room_id` | RTC房间编号 | `rtc_task → conversation → device` | 对应通话、设备和供应商任务 |
| `provider_task_ref` | 供应商任务编号 | `rtc_task → conversation → device` | 供应商、任务状态、启动/停止尝试次数 |
| `request_id` | 接口请求关联编号 | `audit_event → resource → conversation/device` | 该请求涉及的脱敏资源状态 |
查询只支持完整值匹配,不提供字幕、姓名、手机号等个人数据的模糊检索。单次最多返回50条,设备检索可返回该设备的多条历史通话,其余唯一业务编号通常只返回一条。
### 2.2 状态展示
每条记录包含以下诊断字段:
- 设备公开编号、注册/吊销状态、Redis在线状态、固件版本、期望/已应用配置版本和最近归一化错误码;
- 通话公开编号、通话状态、房间资料有效期;
- 短期入房凭证签发时间和过期时间,不返回凭证正文、摘要或可重建凭证的签名材料;
- RTC房间编号、供应商代号、稳定任务编号、任务状态、启动和停止尝试次数;
- 已接收供应商通知数量、最近通知类型与时间;
- 最近一次相关请求编号。
存在通话时,接口同时从`conversation.created_at/ended_at`和`conversation_state_history`的首次状态到达时间计算五类耗时:创建至`ROOM_ISSUED`、房间资料签发至设备确认`STARTING_AGENT`、设备确认至供应商`ACTIVE`、`STOPPING`至最终结束,以及创建至最终结束。尚未到达的阶段返回空值,时间倒置的数据不生成负数;页面以毫秒、秒或分秒展示,不使用浏览器请求时间代替业务阶段耗时。
### 2.3 授权修复动作
| 动作 | 允许状态 | 处理结果 |
|---|---|---|
| 配置刷新 | 设备属于选定客户组织 | 以当前期望版本创建幂等`apply_config`设备命令 |
| 状态对账 | `STARTING_AGENT`或`STOPPING` | 分别创建启动/停止对账Outbox任务;稳定状态返回`queued=false` |
| 安全停止 | `PREPARING/ROOM_ISSUED` | 不调用供应商,直接收敛为`EXPIRED`并释放房间 |
| 安全停止 | `STARTING_AGENT/ACTIVE` | 收敛为`STOPPING`并可靠投递供应商停止任务 |
| 安全停止 | `STOPPING`或最终状态 | 幂等返回当前状态,不重复创建副作用 |
| 设备吊销 | 设备属于选定客户组织 | 写入吊销时间并立即阻断后续设备签名请求;若存在未结束通话,则在同一事务内过期或可靠停止 |
| 死信重试 | 状态为`dead`且事件类型已注册 | 重置为`pending`,保留并递增人工重试次数、操作人和时间;未知事件类型拒绝重试 |
| 诊断包导出 | 五类精确检索条件有效 | 生成`ops-diagnostic-minimal-v1`脱敏JSON快照,不写正文副本 |
| 发布候选验收快照 | 候选版本、SHA-256构建摘要、环境、范围和必需依赖明确 | 在单个事务内冻结迁移、依赖、问题、任务和告警门禁,生成`ops-acceptance-minimal-v1`归档JSON |
| 试点运行日报 | 业务日期、IANA时区和归档原因有效 | 聚合六类运行证据并生成`ops-pilot-daily-minimal-v1`脱敏JSON,不在服务端保存正文 |
| 试点运行周报 | 周一业务日期、IANA时区和归档原因有效 | 聚合内容审核、记忆、隐私、固件和配置差异,并明确要求外部投诉证据 |
配置刷新、状态对账、安全停止和设备吊销均从具体诊断结果卡片打开页面内“受控联调操作”确认区,不再通过浏览器弹窗采集原因。确认区固定展示目标设备;状态对账和安全停止还固定展示目标通话,切换客户组织或重新检索会清除旧操作草稿。操作原因必填、最长500字符,空理由不能提交。安全停止与设备吊销还必须勾选对应影响确认,其中设备吊销明确提示后续签名请求将被拒绝,安全停止明确限定为当前通话。取消只清空页面草稿,不调用修改接口。角色、租户、状态、幂等和审计仍由服务端重新校验,前端确认不能替代服务端门禁。
诊断包导出和死信重试同样使用页面内表单。导出表单在打开时冻结检索字段和精确编号,展示中文字段名称、完整目标值、脱敏边界和审计提示;用户随后修改检索框不会改变已经打开的导出目标,重新执行查询则清除旧导出草稿。死信重试表单冻结任务公开编号、注册事件类型、聚合类型、自动/人工尝试次数和最近稳定错误码,并要求填写最长500字符的原因,再勾选“根因或依赖问题已经处理”的影响确认。刷新任务列表、切换客户组织或取消会清除旧任务草稿。页面不展示Outbox正文、内部聚合编号或锁信息。
### 2.4 死信作业摘要
死信列表最多返回100条,只展示作业公开编号、已注册事件类型、聚合类型、自动尝试次数、人工重试次数、稳定错误码和时间。接口不返回Outbox正文、内部聚合编号或锁持有者。人工重试会重新启用一轮自动重试预算,但不清除人工重试历史;若根因未修复,任务仍会按显式重试分类再次进入退避或死信。
### 2.5 联调问题闭环
问题清单按客户组织保存需求P0282规定的可追踪字段:问题现象、严重级别、设备编号、通话编号、发生时间、请求关联编号、责任方、根因、解决结果、修复版本、回归证据和回归结论。设备、通话和请求编号均为诊断引用;记录不得粘贴字幕正文、录音地址、短期凭证、长期密钥或带签名下载地址。设备和通话编号同时填写时,服务端验证二者属于同一关联链,避免建立错误关联。
问题状态为`OPEN → IN_PROGRESS → RESOLVED → VERIFIED → CLOSED`,严重级别为`LOW/MEDIUM/HIGH/CRITICAL`。重新处理时可以回到`IN_PROGRESS`;进入`RESOLVED`及后续状态必须填写解决结果、根因和修复版本,进入`VERIFIED`或`CLOSED`还必须填写回归结论和脱敏回归证据。V36汇总当前客户组织全部问题,而不是只统计页面最近100条;任何未关闭的HIGH或CRITICAL问题均计入高严重度发布阻断。该布尔结果只证明此项门禁,不替代供应商、真机、安全、恢复和合规门禁。创建请求使用幂等键,后续维护使用强ETag,防止两名联调人员静默覆盖彼此结论。只读运营角色可以查看,`ops_operator`和`ops_admin`可以登记和维护,每次写入均记录独立审计。
问题维护不再使用连续浏览器弹窗,而是从具体问题卡片打开统一的页面内流转编辑区。编辑区固定问题公开编号、当前状态、当前责任方、现象和ETag版本,再根据动作只显示当前必需字段:调整级别要求选择不同值;接手或重新打开要求最长120字符的责任方;登记解决同时要求根因、最长128字符的修复版本及解决结果;登记回归同时要求脱敏回归证据和结论;关闭要求显式确认已经复核根因、修复版本、解决结果和回归证据。正文类字段最长4000字符。切换筛选、刷新清单、切换客户组织或取消会清除草稿;并发更新仍由服务端`If-Match`校验,冲突时保留页面输入和错误提示供人工复核。
背景资料准备失败是无需供应商对账的本地终态。精确查询该通话时,诊断记录返回`conversation_status=FAILED`和`conversation_failure_code=context_preparation_failed`,RTC状态、房间和供应商任务均为空;工作台与设备HTTP 422响应使用同一个失败码,联调人员可直接登记问题,不得把空RTC任务误判为任务丢失。
### 2.6 发布候选验收快照
验收快照用于阶段8每次真实联调、试点扩量或生产发布前的技术状态冻结。操作人必须填写候选版本、`sha256:`构建摘要、`LAB/STAGING/PRODUCTION`环境、验收类型、覆盖范围、必需外部依赖和最长255字符的归档原因。服务端在一个数据库事务内读取当前迁移版本及脏状态、当前客户组织的联调问题全量汇总、租户及平台级Outbox积压、未解决HIGH/CRITICAL告警,以及所选外部依赖的最新探针状态。
六项门禁分别为候选构建可识别、数据库迁移清洁、必需依赖当前可用、无未关闭高严重度联调问题、无待处理/处理中/死信可靠任务、无未解决高严重度告警。探针记录超过三倍探针周期即视为过期;缺少记录不能按成功处理。实验室环境允许运行版本为`dev`,便于本地准备;预发布和生产环境要求运行版本等于候选版本,同时提交号和构建时间不能为`unknown`。`overall_gate_satisfied`是六项技术条件的合取结果,不包含真机音频体验、真实供应商协议、灰度观察、容量、安全、合规或人工审批,快照正文固定携带该免责声明。
OpenAPI 0.51.0在告警计数之外加入最多100条阻断告警定位项,只包含告警公开编号、规则码、级别、处置状态、关联资源类型/公开编号、首次/最近命中时间和资源核对结果。`PRESENT`表示关联资源仍存在,`MISSING`表示快照时已找不到关联公开资源,`AGGREGATE`表示队列类聚合告警,`UNKNOWN`表示该资源类型没有自动存在性判断。该字段不返回告警标题、摘要、接收组或原始错误;`MISSING`只能作为运营复核证据,不能自动关闭告警。总数超过100时`blocking_items_truncated=true`,必须转到告警工作台查看完整队列。
快照不建立服务端历史表,返回后由浏览器下载归档。页面同时保留本次响应,直接展示六项门禁和阻断告警定位,切换客户组织时立即清除,避免把上一组织的结果误作当前状态。幂等记录保证网络重试返回同一编号与生成时间;审计只保存候选标识、环境、范围、所选依赖、结果和脱敏策略。输出不包含探针地址、凭据、原始错误、告警正文、Outbox正文、字幕或录音。
### 2.7 试点运行日报
OpenAPI 0.55.0要求日报绑定候选版本与不可变构建摘要,再按指定IANA时区的自然日生成设备、通话、供应商、上传、告警和费用快照。自然日活动和生成时刻积压采用不同字段,通话始终返回8个状态,费用按币种分列且不做汇率换算。日报只保存幂等和审计元数据,不保存服务端正文;详细口径、接口和数据来源见[试点运行日报开发说明](pilot-daily-operations.md)。
### 2.8 试点运行周报
OpenAPI 0.55.0要求周报绑定候选版本与不可变构建摘要,再按周一开始的七个业务自然日生成内容审核、正式记忆、隐私请求、固件分布和配置差异快照。人工修订率和故事确认率同时返回样本数及可计算标识;没有分母时不能解释为0%。平台没有投诉权威表,因此周报固定保留外部投诉证据缺口。详细设计见[试点运行周报开发说明](pilot-weekly-review.md)。
## 3. 接口设计
所有接口要求`Authorization: Bearer ...`和`X-Tenant-Id`。修改接口还要求`Idempotency-Key`,请求体为`{"reason":"..."}`。
| 方法与路径 | 最低角色 | 说明 |
|---|---|---|
| `GET /api/v1/ops/diagnostics/search?field={field}&value={value}` | `ops_viewer` | 按五类字段精确查询脱敏状态 |
| `GET /api/v1/ops/diagnostics/external-dependencies` | `ops_viewer` | 查询API/Worker外部依赖预检台账 |
| `POST /api/v1/ops/diagnostics/conversations/{id}/stop` | `ops_operator` | 按当前状态执行本地收敛或可靠停止 |
| `POST /api/v1/ops/diagnostics/conversations/{id}/reconcile` | `ops_operator` | 对启动/停止中间态创建幂等对账任务 |
| `POST /api/v1/ops/diagnostics/devices/{id}/config-refresh` | `ops_operator` | 创建当前配置重新应用命令 |
| `GET /api/v1/ops/diagnostics/jobs` | `ops_viewer` | 查询当前客户组织的脱敏死信摘要 |
| `POST /api/v1/ops/diagnostics/jobs/{id}/retry` | `ops_operator` | 对已注册类型的死信作业执行受控重试 |
| `POST /api/v1/ops/diagnostics/bundles` | `ops_operator` | 按精确检索条件生成脱敏JSON诊断包 |
| `POST /api/v1/ops/diagnostics/acceptance-snapshots` | `ops_operator` | 幂等生成并下载发布候选技术验收快照 |
| `POST /api/v1/ops/diagnostics/pilot-daily-snapshots` | `ops_operator` | 按业务自然日幂等生成并下载脱敏试点运行日报 |
| `POST /api/v1/ops/diagnostics/pilot-weekly-snapshots` | `ops_operator` | 按周一开始的业务自然周幂等生成并下载脱敏试点运行周报 |
| `POST /api/v1/ops/diagnostics/devices/{id}/revoke` | `ops_admin` | 吊销设备,并联动收敛该设备未结束通话 |
| `GET /api/v1/ops/integration-issues` | `ops_viewer` | 查询最近100条问题及全量高严重度发布阻断汇总,可按状态筛选 |
| `POST /api/v1/ops/integration-issues` | `ops_operator` | 幂等登记问题现象、严重级别、关联编号、发生时间和责任方 |
| `PATCH /api/v1/ops/integration-issues/{id}` | `ops_operator` | 使用If-Match维护严重级别、责任方、根因、修复版本和回归证据 |
成功响应返回动作、最新状态、是否排队和相关命令编号。跨客户组织、只读角色或不存在资源不会泄露其他租户信息;响应统一带`Cache-Control: no-store`。稳定错误包括`OPS_ACCESS_DENIED`、`DIAGNOSTIC_NOT_FOUND`、`DIAGNOSTIC_STATE_CONFLICT`和`IDEMPOTENCY_KEY_CONFLICT`。
## 4. 业务流程设计
### 4.1 联调定位流程
```mermaid
sequenceDiagram
participant O as 联调/运营人员
participant API as 诊断API
participant DB as PostgreSQL
participant R as Redis
O->>API: OIDC令牌 + 客户组织 + 精确编号
API->>DB: 校验运营账号与租户关系
API->>DB: 查询设备/通话/RTC任务/通知/审计
API->>R: 查询设备在线状态
API-->>O: 返回脱敏统一诊断记录
O->>API: 选择修复动作并填写原因、幂等键
API->>DB: 锁定资源并检查当前状态
API->>DB: 写状态/命令/Outbox与审计
API-->>O: 返回最新状态、是否排队和请求编号
```
### 4.2 安全停止流程
系统先锁定目标通话并重新读取权威状态。尚未启动供应商任务时,只执行本地过期收敛;供应商任务可能存在时,事务内把通话置为`STOPPING`并写可靠停止任务;已在停止或最终状态时直接幂等返回。这样可避免运营页面重复点击产生第二个供应商任务。
### 4.3 状态对账流程
只有启动中和停止中状态需要向后台恢复链路排队。任务使用通话内部编号和操作幂等键形成去重边界,Worker继续遵守固定供应商任务编号、未知启动先查询、停止不受启动熔断影响等规则。稳定状态不伪造对账任务。
### 4.4 设备吊销流程
系统先锁定设备,再锁定该设备唯一未结束通话。设备状态、`revoked_at`和版本号在事务内更新;`PREPARING/ROOM_ISSUED`通话直接进入`EXPIRED`且RTC任务进入`COMPLETED`,`STARTING_AGENT/ACTIVE`通话进入`STOPPING`并写入可靠停止任务,已经`STOPPING`则不重复投递。状态历史、吊销审计和停止意图与设备吊销一起提交,避免出现“身份已失效但云端任务继续运行”的分裂状态。
### 4.5 死信恢复与诊断包流程
运营人员先根据稳定错误码修复依赖,再选择死信任务、填写原因并提交。服务端锁定任务,确认租户、`dead`状态和已注册类型后重置待执行字段并记录人工重试元数据。诊断包则复用同一套精确查询和租户边界,把当前脱敏状态序列化为JSON;导出编号、条件、条数和脱敏策略进入审计,但服务端不建立新的内容副本。
### 4.6 联调问题处理流程
联调人员先通过精确诊断检索确认设备、通话和请求关联编号,可从诊断结果一键带入三个编号,再补充问题现象、严重级别和责任方。责任方接手后把状态置为处理中,完成修复后填写根因、修复版本和解决结果并置为待回归;回归人员按可复现步骤验证,登记请求关联号、诊断包编号、重复次数等脱敏证据和结论,最后关闭问题。若回归失败或问题复现,记录回到处理中,原解决结果、回归证据和审计历史继续保留。HIGH或CRITICAL问题在关闭前持续阻断对应发布门禁。服务端通过版本条件更新保证并发修改可见,不以最后写入者静默覆盖前一人的结论。
### 4.7 发布候选技术预检流程
运营人员先冻结候选版本和构建摘要,再按本次联调或发布范围选择真实必需依赖。API完成角色和客户组织校验后,在幂等事务中依次读取迁移、问题、探针、可靠任务和告警状态,计算六项独立门禁及总结果,同时写入最小审计。浏览器下载JSON后交由阶段8证据库归档;任一门禁失败时以`gates`中的稳定代码定位处理项,修复后使用新的幂等键重新生成,不修改旧快照。技术门禁全部通过后仍需继续执行真实设备、供应商、灰度和人工审批清单。
## 5. 数据库设计
诊断模块不建立正文副本或独立影子库,复用以下权威表:
| 表 | 用途 |
|---|---|
| `device`、`device_twin`、`device_presence_state` | 设备身份、吊销时间、固件、期望/已应用配置和在线状态 |
| `conversation`、`conversation_state_history` | 通话权威状态和状态变更依据 |
| `rtc_task`、`provider_event` | RTC房间、短期凭证签发/过期时间、供应商稳定任务编号、尝试次数及通知摘要;V28只新增凭证时间元数据 |
| `device_command` | 配置刷新命令及设备回执 |
| `outbox_event` | 启动/停止对账和可靠停止任务;V26增加公开编号、人工重试次数、时间和操作人 |
| `audit_event` | 操作人、原因、请求编号、资源和结果 |
| `idempotency_record` | 修改动作的重放、并发和正文冲突保护 |
| `integration_issue` | V30联调问题权威记录;V36增加严重级别、根因、修复版本、脱敏回归证据和高严重度汇总索引 |
| `external_dependency_probe_state` | V35平台级探针当前状态、周期、时间、有限错误码和连续失败次数;不保存端点或凭据 |
| `schema_migrations`、`alert_incident` | 验收快照读取迁移脏状态和未解决HIGH/CRITICAL告警;不复制告警正文 |
| `upload_batch`、`media_segment`、`conversation_usage_event`、`conversation_cost_line` | 试点日报读取上传活动/积压与按币种费用,不建立日报副本 |
查询所有连接条件同时包含或继承客户组织边界;HTTP只暴露公开UUID。运营修改在同一数据库事务内完成状态、可靠任务和审计写入,任一步失败则整体回滚。验收快照和试点日/周报复用权威表及`idempotency_record/audit_event`,OpenAPI 0.50.0—0.55.0不新增数据库迁移或快照持久化表。
## 6. 验证
```powershell
pwsh -NoProfile -File .\scripts\smoke-diagnostics.ps1
pwsh -NoProfile -File .\scripts\smoke-acceptance-snapshot.ps1
powershell -ExecutionPolicy Bypass -File .\scripts\smoke-pilot-daily-snapshot.ps1
powershell -ExecutionPolicy Bypass -File .\scripts\smoke-pilot-weekly-snapshot.ps1
```
烟测使用真实本地OIDC令牌和设备HMAC签名,验证五类查询、跨租户403、稳定状态对账、配置刷新、安全停止、死信重试、脱敏诊断包、设备吊销、请求编号回查和精确数据清理。联调问题采用定向数据库与HTTP验证,覆盖创建重放、租户隔离、严重级别阻断、根因/修复版本/回归证据门禁、关闭后汇总恢复和旧ETag冲突。服务集成测试另覆盖活动通话随设备吊销进入`STOPPING`并只投递一个停止任务。生产验收仍需使用真实设备、真实供应商账号和实际值班角色重复执行。
日常页面可用性采用一条轻量真实OIDC冒烟:`test/e2e/visual-ops-sensitive-access-request.mjs`创建临时通话诊断记录、已注册类型的临时死信任务及覆盖五种状态的临时问题记录,依次打开诊断包导出、配置刷新、状态对账、安全停止、设备吊销、死信重试、六种问题流转和敏感访问申请表单,验证目标绑定、字段门禁、ETag展示、危险操作显式确认、取消关闭以及零修改/导出请求。检查要求零横向溢出、零控制台错误和零失败请求,并在`finally`精确清理夹具。该检查不实际下载诊断包、修改问题、重试任务、停止通话或吊销种子设备,服务端副作用仍由上述HTTP与集成测试证明。
外部预检日常验证只运行V35迁移、`internal/externalprobe`定向用例和真实OIDC纵向查询。页面检查使用:
```powershell
$env:PLAYWRIGHT_REQUIRE_FROM='C:\Users\19926\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\node_modules'
$env:CHROME_PATH='C:\Program Files\Google\Chrome\Application\chrome.exe'
node .\scripts\visual-diagnostics-ui.cjs
```
该检查确认本地Keycloak的`api/oidc_jwks`为当前可用、服务归属和检查时间可见、接口无端点或原始错误字段;同时使用真实登录从页面下载实验室验收快照和试点运行日/周报,核对门禁、固定分布、统计守恒、外部投诉证据缺口与归档JSON后精确清理审计/幂等夹具,并保存`test/e2e/screenshots/operations/ops-diagnostics-workspace.png`。本地未配置方舟、火山RTC或托管内容安全时不生成对应成功记录;真实联调或生产快照必须把实际使用的方舟、RTC和`content_safety_tls`列为必需依赖。
+69
View File
@@ -0,0 +1,69 @@
# 云KMS/HSM密钥轮换一致性证据
## 1. 目标与验收边界
`cmd/kms-rotation-conformance`用于把同一候选构建下的云密钥提供器、工作负载身份、版本切换、新旧凭据认证结果、业务连续性探针和提供器访问审计形成`kms-rotation-conformance-report-v1`。本地环境变量、Docker Secret或普通文件演练只能输出`LOCAL_SECRET/external_validation_required=true`;只有目标预生产或生产环境的云KMS、HSM、Secret Manager或等价受控提供器证据完整时,报告才为`REAL_KMS_HSM`。
报告不包含密钥值、令牌、数据库连接串或可还原的密钥摘要。它使用提供器版本引用、脱敏探针引用和审计引用证明“轮换的是哪一个受控版本、谁读取了它、业务如何验证、旧版本如何失效”。报告通过不等于业务发布批准,也不替代厂商侧权限审计和安全负责人签署。
## 2. 证据格式
输入文件使用`kms-rotation-capture-v1`并严格拒绝未知字段。顶层字段包括:
| 字段 | 说明 |
|---|---|
| `evidence_source` | 本地演练填`LOCAL`,真实云KMS/HSM填`KMS_HSM` |
| `candidate_version/build_digest` | 必须与候选证据包一致 |
| `environment` | 本地为`LOCAL`;真实证据只能是`STAGING`或`PRODUCTION` |
| `provider_ref` | 不含租户凭据的云提供器或HSM稳定引用 |
| `workload_identity_ref` | API、Callbacks、Worker等实际工作负载身份引用 |
| `provider_audit_ref` | 提供器访问审计、版本变更审计或受控工单引用 |
| `attested_by_ref/attested_at` | 安全或平台复核人脱敏引用与复核时间 |
| `rotations` | 一次或多次密钥轮换观测 |
每条`rotations`必须保存密钥和消费者的非秘密引用、消费模式、不同的新旧版本引用、轮换时间,以及以下五类可复核结果:轮换前基线、新凭据成功、旧凭据拒绝、业务连续性、提供器访问审计。`secret_value_not_exported=true`声明采集过程没有导出密钥正文;该字段为false会使报告失败。
## 3. 两种消费模式
真实报告必须同时覆盖:
- `DYNAMIC_READ`:逐设备密钥等按请求从CSI原子挂载目录重新读取的凭据;新版本生效后下一次请求使用新值,过渡窗口结束后旧值被拒绝;
- `ROLLING_RESTART`:数据库、Redis、RTC、供应商、对象存储、方舟和进程级签名密钥等启动时读取的凭据;挂载版本更新后执行受控滚动重启,新Pod就绪再终止旧Pod,并确认旧版本失效。
只验证其中一种消费模式的云端报告仍为`FAIL`。本地报告允许只验证一种模式,以便开发环境检查工具链,但始终保留外部验收缺口。
## 4. 自动检查
报告包含11项检查:采集版本、证据来源、候选身份、采集候选绑定、提供器/身份/审计、两种轮换范围、版本转换、新凭据接受、旧凭据拒绝、业务连续性和全部探针/审计引用。任一失败使总结果为`FAIL`。
真实证据满足以下条件后才设置`external_validation_required=false`:来源为`KMS_HSM`、环境是预生产或生产、提供器和工作负载身份完整、至少两条轮换覆盖两种消费模式、新旧版本不同、新凭据成功、旧凭据拒绝、业务连续性通过、密钥正文未导出且所有审计引用存在。
## 5. 命令用法
```powershell
go run ./cmd/kms-rotation-conformance `
-capture E:\evidence\kms-rotation-capture.json `
-candidate-version 0.63.0-rc.1 `
-build-digest sha256:<64位摘要> `
-output E:\evidence\kms-rotation-report.json
```
输出路径必须为绝对路径。通过返回0,不一致时仍写出失败报告并返回1,文件、参数或JSON格式错误返回2。报告保存输入采集文件SHA-256,用于归档后检测替换。
## 6. 候选证据包门禁
`release-evidence-index-v2`中的`KMS_ROTATION`不可豁免。第一个文件必须是`PASS/REAL_KMS_HSM`报告并绑定同一候选;后续可以附SecretProviderClassPodStatus脱敏导出、云审计、滚动发布记录和安全签署材料。没有真实报告时,v3清单固定显示:
- `REAL_KMS_HSM_REPORT:0/1`;
- `DYNAMIC_READ:0/1`;
- `ROLLING_RESTART:0/1`。
本地报告、单一消费模式、跨候选版本、缺旧凭据拒绝、缺业务连续性或缺云审计时,`KMS_ROTATION`必须保持`PENDING`,不能使用`WAIVED`绕过。
## 7. 本地低强度演练
```powershell
pwsh -NoProfile -File .\scripts\verify-local-secret-rotation.ps1
```
脚本沿用现有本地设备HMAC轮换:原密钥请求成功,启动同镜像临时API并注入新密钥,新密钥请求成功、旧密钥返回401、就绪接口持续可用,随后恢复Compose API及原本地密钥。新增步骤把结果输出为`PASS/LOCAL_SECRET`报告,并确认它仍要求外部验证。脚本结束删除临时容器、采集和报告,不改变真实KMS门禁。
+241
View File
@@ -0,0 +1,241 @@
# Kubernetes部署手册
## 1. 适用范围
本手册用于把数据采集终端—云端系统部署到预生产或生产Kubernetes集群。仓库提供Kustomize基线,不在集群内自建PostgreSQL、Redis、对象存储、OIDC、OpenTelemetry Collector或外部模型/语音服务;这些依赖应使用已备份、可监控且具备生产SLA的托管或平台服务。
清单只定义应用工作负载,不包含任何真实Secret。`deploy/kubernetes/base/configmap.yaml`中的`.example.invalid`、24位全零RTC App ID和`replace-with-*`是不可用占位值。生产服务的启动门禁会拒绝这些值,避免占位配置被误发布。
## 2. 目录与发布单元
| 目录 | 用途 | 是否可重复应用 |
|---|---|---|
| `deploy/kubernetes/base` | 四个应用Deployment、Service、Ingress、PDB、HPA和NetworkPolicy | 是 |
| `deploy/kubernetes/overlays/staging` | 预生产环境覆盖层,`APP_ENV=stage` | 是 |
| `deploy/kubernetes/overlays/production` | 生产环境覆盖层,`APP_ENV=prod` | 是 |
| `deploy/kubernetes/overlays/production-csi` | 生产环境覆盖层,并以Secrets Store CSI替代应用Kubernetes Secret卷 | 是 |
| `deploy/kubernetes/migration` | 数据库向前迁移一次性Job | 每次发布前执行并等待完成 |
| `deploy/kubernetes/migration-csi` | 从Secrets Store CSI文件读取数据库连接串的迁移Job | 每次发布前执行并等待完成 |
API、Callbacks和Worker共用一个后端镜像,通过容器命令分别启动`/app/api`、`/app/callbacks`和`/app/worker`。Web使用独立Nginx镜像。迁移镜像只包含固定版本的`migrate`程序和当前仓库`migrations`目录。
## 3. 集群前置条件
部署前应具备:
1. Kubernetes 1.27或更高版本,并启用支持`autoscaling/v2`的Metrics Server。
2. Nginx Ingress Controller;基线假定控制器命名空间为`ingress-nginx`。
3. 证书控制器或人工创建的TLS Secret `voice-platform-tls`。
4. 监控系统;基线假定Prometheus位于`monitoring`命名空间。
5. 支持pgvector的PostgreSQL、启用认证的Redis、TLS对象存储和HTTPS OIDC。
6. 火山RTC/豆包语音、火山方舟内容模型和1536维Embedding模型的真实账号与已开通资源。
7. 推荐使用External Secrets Operator或云KMS Secrets Store CSI Driver。若平台采用不同的Ingress或监控命名空间,必须同步修改NetworkPolicy。
生产数据库连接必须启用TLS,例如`sslmode=verify-full`;对象存储必须启用TLS;OIDC Issuer和JWKS必须为HTTPS。数据库迁移账号可与运行账号分离,但迁移Job使用的账号必须具有执行当前迁移所需的DDL权限。
## 4. 构建和推送镜像
为同一次发布使用不可变版本号或Git提交摘要,不要复用`latest`:
```powershell
$Version = "2026.08.23-<commit>"
$Registry = "registry.company.example/voice-platform"
docker build -f deploy/backend.Dockerfile -t "$Registry/backend:$Version" .
docker build -f deploy/web.Dockerfile -t "$Registry/web:$Version" .
docker build -f deploy/migrate.Dockerfile -t "$Registry/migrate:$Version" .
docker push "$Registry/backend:$Version"
docker push "$Registry/web:$Version"
docker push "$Registry/migrate:$Version"
```
在目标覆盖层的`kustomization.yaml`中把三个镜像名称和版本改成上述不可变地址。建议由流水线写入镜像摘要,并在发布记录中保存后端、Web和迁移镜像的对应关系。
## 5. 非敏感环境配置
不要直接修改`base`作为某个现场的长期配置。复制环境覆盖层或增加一个现场补丁,至少替换下列字段:
| 配置 | 说明 |
|---|---|
| `REDIS_ADDR` | Redis主机与端口,不含密码 |
| `AUTH_ISSUER_URL`、`AUTH_JWKS_URL` | HTTPS OIDC地址 |
| `AUTH_AUDIENCE` | Web/API共同使用的Audience |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP gRPC地址 |
| `EXTERNAL_PROBE_INTERVAL` | OIDC、方舟和RTC外部探针周期,默认30秒 |
| `RTC_APP_ID` | 真实24字符火山RTC App ID |
| `OBJECT_STORE_ENDPOINT` | 应用访问对象存储的TLS端点 |
| `OBJECT_STORE_PUBLIC_ENDPOINT` | 浏览器短期下载地址使用的公网TLS端点 |
| `OBJECT_STORE_EXPORT_BUCKET`、`OBJECT_STORE_REGION` | 私有导出桶和区域 |
| `ARK_CONTENT_MODEL` | 已开通的方舟内容模型Endpoint ID |
| `ARK_EMBEDDING_MODEL` | 已开通且输出1536维向量的Endpoint ID |
| `CONTENT_SAFETY_PROVIDER`、`CONTENT_SAFETY_ENDPOINT` | `managed_http`和正式HTTPS分类端点 |
| `CONTENT_SAFETY_POLICY_REF`、`CONTENT_SAFETY_TIMEOUT` | 已批准且不可变的策略版本和调用超时 |
| `WEB_OIDC_AUTHORITY`、`WEB_OIDC_CLIENT_ID` | 浏览器可见的OIDC公开配置 |
| Ingress `host`和TLS Secret | 正式域名与证书Secret |
`APP_ENV=prod`会启用严格生产门禁。不要通过改回`stage`绕过配置错误;应修正真实配置后再发布。
## 6. Secret清单
下列Secret必须在`voice-platform`命名空间预先存在。文件名就是Secret的key,Secret内容不得提交到Git或写入普通ConfigMap。
| Secret名称 | 必需key | 使用者 |
|---|---|---|
| `voice-platform-common-secrets` | `database-url`、`redis-password` | API、Callbacks、Worker、迁移Job |
| `voice-platform-api-secrets` | `rtc-app-key`、`cursor-signing-secret`、`share-token-signing-secret` | API |
| `voice-platform-callbacks-secrets` | `provider-callback-secret` | Callbacks |
| `voice-platform-worker-secrets` | `volc-access-key-id`、`volc-secret-access-key`、`voice-chat-template.json` | Worker |
| `voice-platform-model-storage-secrets` | `object-store-access-key`、`object-store-secret-key`、`ark-api-key`、`content-safety-api-key` | API、Worker;内容安全Key仅由Worker引用 |
| `voice-platform-device-secrets` | 每台设备一个key,例如`terminal-001` | API |
| `voice-platform-tls` | `tls.crt`、`tls.key` | Ingress |
游标签名密钥和分享签名密钥必须各不少于32字节且互不相同。供应商回调密钥不少于32字节。数据库连接串和Redis密码由应用通过`*_FILE`读取;应用错误日志不会输出Secret内容。
每台量产设备在数据库中的`credential_ref`应写为`device-secret://terminal-001`,对应`voice-platform-device-secrets`中的`terminal-001`文件。名称只允许安全的单层文件名。Kubernetes Secret或CSI卷原子更新后,API会在下一次设备验签时读取新值,无需重启。
`voice-chat-template.json`可从`deploy/volcengine/voice-chat-template.example.json`复制结构。生产文件必须使用`voice-chat-template-bundle-v1`并同时包含`s2s_pure`、`s2s_hybrid`和`cascade`三个模板;示例中的`TemplateBodyMustBeReplaced`表示整个`Config`对象都应按现场已开通的官方语音能力替换,不是一个可直接提交给供应商的字段。平台按通话冻结模式选中模板后覆盖AppId、RoomId、TaskId、设备用户、Agent用户和授权背景,模板中不要固定这些请求级字段。若现场模型或音色字段需要由已发布Agent组合动态提供,应在官方字段对应位置填写`{{MODEL_RESOURCE_REF}}`或`{{VOICE_RESOURCE_REF}}`;还可用`{{AGENT_BUNDLE_VERSION}}`、`{{PERSONA_VERSION}}`、`{{VOICE_VERSION}}`、`{{MODEL_VERSION}}`和`{{ROUTING_VERSION}}`写入供应商允许的追踪字段。未使用的占位符无需添加。
如使用火山临时凭据,可在Worker Secret增加`volc-session-token`,并通过现场补丁增加`VOLC_SESSION_TOKEN_FILE=/var/run/secrets/voice-platform/worker/volc-session-token`。如启用告警Webhook,同样应以现场补丁配置HTTPS `ALERT_WEBHOOK_URL`,并通过Secret文件注入`ALERT_WEBHOOK_SECRET_FILE`。
首次创建命名空间后再由密钥平台同步Secret:
```powershell
kubectl apply -f deploy/kubernetes/base/namespace.yaml
kubectl -n voice-platform get secret
```
不建议在命令行直接传入密钥明文。短期人工联调只能使用`kubectl create secret generic --from-file=...`从受控临时文件创建,并在完成后安全清理临时文件和终端历史。
### 6.1 Secrets Store CSI模式
生产环境推荐使用`deploy/kubernetes/overlays/production-csi`和`deploy/kubernetes/migration-csi`。这两个覆盖层保持原有容器内文件路径不变,只把卷来源从Kubernetes Secret替换为`secrets-store.csi.k8s.io`内联卷。
集群平台需先安装Secrets Store CSI Driver和对应云密钥提供器,并启用工作负载身份。`SecretProviderClass`必须创建在`voice-platform`命名空间;仓库提供[参数化示例](../deploy/kubernetes/examples/secret-provider-classes.example.yaml),但`provider`和`parameters`必须严格按现场云厂商或Vault提供器文档填写,不能直接应用示例占位值。
必须创建以下六个`SecretProviderClass`:
| SecretProviderClass | 必须挂载的文件名 |
|---|---|
| `voice-platform-common` | `database-url`、`redis-password` |
| `voice-platform-api` | `rtc-app-key`、`cursor-signing-secret`、`share-token-signing-secret` |
| `voice-platform-callbacks` | `provider-callback-secret` |
| `voice-platform-worker` | `volc-access-key-id`、`volc-secret-access-key`、`voice-chat-template.json` |
| `voice-platform-model-storage` | `object-store-access-key`、`object-store-secret-key`、`ark-api-key`、`content-safety-api-key` |
| `voice-platform-devices` | 每台设备一个文件,例如`terminal-001` |
提供器对象应通过`objectAlias`或等价字段映射为上述准确文件名。Deployment挂载整个目录,不使用`subPath`,以便CSI原子轮换能够更新容器看到的符号链接。ServiceAccount已按API、Callbacks、Worker和Web拆分;现场应只给前三个需要密钥的ServiceAccount附加相应工作负载身份,并按最小权限授予密钥读取能力。
自动轮换必须在CSI Driver和所选提供器均支持时启用。轮换后:
- `voice-platform-devices`中的逐设备密钥由API每次请求重新读取,原子更新后下一次验签即可使用新值;轮换协议仍应保留受控双密钥过渡或设备同步窗口。
- 数据库、Redis、RTC、供应商、对象存储、方舟和签名密钥在进程启动时读取。CSI可以更新文件,但不会主动重启Pod;这些密钥轮换后必须由部署流水线执行受控滚动重启,并验证新Pod就绪后再终止旧Pod。
- 不应依赖`secretObjects`同步为Kubernetes Secret。CSI迁移Job直接读取`voice-platform-common/database-url`文件,避免数据库连接串落入额外Secret副本。
检查CSI对象和已挂载版本:
```powershell
kubectl get secretproviderclass -n voice-platform
kubectl get secretproviderclasspodstatus -n voice-platform
kubectl describe pod -n voice-platform -l app.kubernetes.io/name=voice-platform-api
```
不要通过`kubectl exec ... cat`打印Secret内容。验收只检查文件存在、权限、版本编号、Pod就绪和实际认证结果。
目标环境完成动态读取和滚动重启两类轮换后,按[云KMS/HSM密钥轮换一致性证据](kms-hsm-rotation-evidence.md)导出不含密钥正文的版本、探针和审计引用,并使用`cmd/kms-rotation-conformance`生成候选报告。仅检查SecretProviderClass存在或本地文件更新不能满足`KMS_ROTATION`发布门禁。
## 7. 发布前静态检查
静态渲染不连接集群,适合本地和CI的轻量门禁:
```powershell
kubectl kustomize deploy/kubernetes/overlays/production > $null
kubectl kustomize deploy/kubernetes/migration > $null
kubectl kustomize deploy/kubernetes/overlays/production-csi > $null
kubectl kustomize deploy/kubernetes/migration-csi > $null
```
连接目标集群后执行服务端预检和差异检查:
```powershell
kubectl apply --server-side --dry-run=server -k deploy/kubernetes/overlays/production
kubectl diff -k deploy/kubernetes/overlays/production
```
重点确认镜像摘要、生产域名、OIDC、RTC App ID、方舟模型、对象存储、Secret齐备情况和NetworkPolicy命名空间。此处只做结构和部署配置检查,不替代真实供应商、真机和恢复演练。
七类生产基础设施完成现场核对后,按[生产基础设施一致性证据](production-infrastructure-evidence.md)保存脱敏探针、配置版本和审计引用,并使用`cmd/infrastructure-conformance`生成同候选报告。静态Kustomize渲染或本地Compose报告不能满足`PRODUCTION_INFRASTRUCTURE`发布门禁。
## 8. 数据库迁移
应用发布前先执行向前迁移,并等待Job成功:
```powershell
kubectl delete job voice-platform-migrate -n voice-platform --ignore-not-found
kubectl apply -k deploy/kubernetes/migration
kubectl wait --for=condition=complete job/voice-platform-migrate -n voice-platform --timeout=10m
kubectl logs job/voice-platform-migrate -n voice-platform
```
使用CSI模式时,把迁移命令中的目录替换为`deploy/kubernetes/migration-csi`。该Job直接挂载`voice-platform-common`,不会读取`voice-platform-common-secrets`。
迁移失败时停止应用发布,检查Job日志、数据库TLS、权限和当前schema版本。不要自动执行`down`;若迁移包含不兼容变更,应按发布方案先完成备份和向前修复脚本评审。
## 9. 应用发布
迁移成功后应用覆盖层:
```powershell
kubectl apply -k deploy/kubernetes/overlays/production
kubectl rollout status deployment/api -n voice-platform --timeout=10m
kubectl rollout status deployment/callbacks -n voice-platform --timeout=10m
kubectl rollout status deployment/worker -n voice-platform --timeout=10m
kubectl rollout status deployment/web -n voice-platform --timeout=10m
```
使用CSI模式时,应用目录替换为`deploy/kubernetes/overlays/production-csi`。如果Pod停留在`ContainerCreating`,优先检查CSI Driver、提供器DaemonSet、工作负载身份、`SecretProviderClassPodStatus`和对象别名,不要通过放宽应用权限绕过挂载错误。
Deployment使用`maxUnavailable=0`、`maxSurge=1`滚动更新;每类服务默认至少两个副本,并受PDB保护。HPA根据CPU在2—10个副本范围内扩缩,真实生产应在观察一段时间的CPU、内存、请求率、Outbox积压和供应商配额后调整阈值。
## 10. 发布后轻量验收
按照“功能可用优先、避免重复高强度测试”的原则,生产发布只执行下列关键检查:
1. 四个Deployment全部Available,Pod无重启循环。
2. API、Callbacks和Worker的`/health/ready`在集群内返回200。
3. Web的`/runtime-config.js`包含当前OIDC公开配置,登录和家庭导航可用。
4. 使用专用验收设备完成一次签名握手、配置读取、通话启动、入房确认、心跳和停止。
5. 确认火山任务进入ACTIVE后正常完成,最终字幕可见,内容候选生成并可人工确认。
6. 确认一条已确认记忆能在下一次通话中按授权召回。
7. 检查最近错误日志、Prometheus目标、Outbox open/dead数量和告警接收器。
8. 确认`oidc_jwks`、`ark_tls`、`volc_rtc_tls`和`content_safety_tls`外部依赖指标为1;该检查只证明网络、证书及JWKS结构,不替代真实账号业务调用或内容安全六类质量评测。
不在每次小版本发布后重复全量压测、全库恢复或完整浏览器矩阵;这些演练按重大变更、基础设施变更或既定周期执行。若上述关键链路任一失败,停止放量并回滚应用镜像。
## 11. 网络与观测说明
API、Callbacks和Worker Pod均带有`prometheus.io/scrape`、`path`和`port`发现注解。若集群Prometheus启用了基于Pod注解的服务发现,可直接抓取;使用Prometheus Operator时应创建等价的PodMonitor/ServiceMonitor,并保留相同指标路径和端口。
基线NetworkPolicy默认拒绝命名空间内所有Pod的入口流量,仅允许:
- `ingress-nginx`命名空间访问API、Callbacks和Web业务端口;
- `monitoring`命名空间访问API、Callbacks和Worker指标端口。
基线暂不限制出口,因为数据库、Redis、OIDC、对象存储、OTLP、方舟和火山RTC可能位于不同网络。正式环境应根据实际CNI、域名解析和固定网段逐步增加出口白名单。`/metrics`不通过Ingress暴露,只允许监控网络访问。
## 12. 回滚
优先回滚应用镜像,不回滚已经成功执行的数据库迁移:
```powershell
kubectl rollout undo deployment/api -n voice-platform
kubectl rollout undo deployment/callbacks -n voice-platform
kubectl rollout undo deployment/worker -n voice-platform
kubectl rollout undo deployment/web -n voice-platform
```
回滚后再次执行第10节的关键链路检查。若新迁移与旧应用不兼容,应使用事先评审的向前兼容修复版本,而不是在生产自动降级schema。
## 13. 尚需现场完成的事项
仓库清单完成的是可部署结构,不代表已经通过目标集群验收。上线前仍需业务方或平台方提供并验证:真实域名和证书、镜像仓库、托管数据库/Redis/对象存储、OIDC客户端、KMS/CSI工作负载身份、火山RTC/豆包语音、方舟模型、量产设备密钥、告警接收器,以及真机通话、外部故障、备份恢复和灰度回滚结果。
@@ -0,0 +1,103 @@
# 压测、告警与生产安全门禁
## 1. 生产配置启动门禁
当`APP_ENV=prod`时,进程在连接外部依赖前执行配置校验,任一条件不满足即拒绝启动:
- OIDC签发方和JWKS地址必须使用HTTPS;
- PostgreSQL连接不能使用`sslmode=disable`;
- Redis必须配置非占位密码;
- API设备凭据必须按设备从受控Secrets/KMS CSI目录解析,生产禁止单一静态开发密钥;
- RTC应用必须使用正式AppID/AppKey和火山正式Token格式;
- Worker必须使用火山RTC HTTPS签名VoiceChat接口以及非占位AK/SK,禁止本地模拟供应商;
- 分页游标和分享签名密钥必须至少32字符、不得使用本地/示例值且彼此不同;
- 对象存储必须启用TLS,内外端点不能指向本机,访问凭据不得使用本地/示例值;
- Callback签名密钥不得使用本地/示例值。
生产密钥不得写入Git、镜像、Compose文件或普通配置文件。部署平台应通过Secret Manager/KMS、短期工作负载身份或只读内存挂载注入,并建立轮换、吊销、双密钥过渡和访问审计。当前仓库只提供启动门禁,不宣称已经接入任何特定云厂商的密钥托管。
所有后端长期敏感配置均支持`NAME_FILE`约定,包括数据库连接串、Redis密码、RTC AppKey、火山AK/SK/临时Token、方舟API Key、对象存储凭据、设备本地开发密钥以及游标、分享、供应商回调和告警签名密钥。文件必须使用容器内绝对路径并小于等于64KiB;同一密钥的`NAME`与`NAME_FILE`同时配置时服务拒绝启动。该能力用于直接消费Docker/Kubernetes Secret或KMS CSI挂载文件,不等同于已经完成某个云厂商的身份授权与轮换验收。
本地开发已通过目录解析器测试验证不同设备密钥隔离、越界引用拒绝、短密钥拒绝,以及原子替换文件后无需重启即可读取新密钥。`scripts/verify-local-secret-rotation.ps1`保留用于验证旧的进程级环境注入路径;两者都不能替代真实KMS/HSM、量产设备注入、双密钥过渡和吊销审计。
## 2. 压测入口
`test/load/api-readiness.js`提供固定到达率压测。`acceptance`配置为50 RPS持续30分钟,门禁为失败率低于0.1%、p95低于500毫秒、p99低于1秒;`smoke`配置用于提交前短测。该数值是研发压测门禁,不是未经真实环境验证的产品SLA。
```powershell
docker run --rm -v "${PWD}/test/load:/scripts:ro" grafana/k6 run `
-e BASE_URL=http://host.docker.internal:58080 `
-e PROFILE=acceptance /scripts/api-readiness.js
# 500次/秒供应商回调,持续10分钟,并对账30万唯一事件
pwsh -NoProfile -File .\scripts\load-provider-callbacks.ps1 `
-Profile acceptance -ConversationPool 100
# 1000活跃通话、500条SSE、10000媒体分片、100并发隐私删除
pwsh -NoProfile -File .\scripts\load-conversation-state.ps1
pwsh -NoProfile -File .\scripts\load-sse-connections.ps1 -Connections 500
pwsh -NoProfile -File .\scripts\load-media-manifest.ps1
pwsh -NoProfile -File .\scripts\load-privacy-delete.ps1
```
如需叠加真实OIDC授权读取,将短期测试令牌通过进程环境传入`ACCESS_TOKEN`。禁止把令牌写进脚本或提交仓库。负载脚本必须使用隔离租户和合成数据;带夹具的包装脚本会在结束后执行数据库对账和精确清理。压测应在与生产容量相近的测试环境执行;本地结果只能形成研发回归基线,不能替代生产容量结论。完整本地结果、夹具边界和失败修正记录见[性能、恢复、安全与兼容性专项验证报告](performance-recovery-security-compatibility.md)。
## 3. Prometheus告警
`deploy/prometheus/rules/platform-alerts.yml`定义以下基础规则:
| 告警 | 条件 | 建议动作 |
|---|---|---|
| `PlatformServiceDown` | 连续2分钟无法抓取服务 | 检查部署、健康依赖和网络 |
| `PlatformHighHTTP5xxRate` | 5分钟窗口5xx高于5%,持续5分钟 | 按服务、路由和trace定位错误 |
| `PlatformHighP95Latency` | 路由p95高于1.5秒,持续10分钟 | 检查数据库、Redis、对象存储和下游供应商 |
| `OutboxDeadLetterPresent` | 存在死信 | 核对错误码,修复后人工重放 |
| `OutboxPendingBacklog` | pending超过100且持续10分钟 | 检查Worker容量及下游可用性 |
| `PrivacyDeletionPartialFailure` | 隐私请求存在`PARTIAL_FAILED` | 立即对账六子系统并保留处置证据 |
| `FirmwareAssignmentFailure` | 存在失败设备 | 暂停批次,分析设备结果,必要时回滚 |
| `FirmwareRolloutStalled` | 运行批次15分钟无更新 | 检查命令投递、设备在线和下载权限 |
业务告警扫描器还会从`provider_dependency_state`生成`PROVIDER_UNAVAILABLE`事件。连续临时失败达到部署阈值后只暂停新启动,不阻断停止与回收;历史网页和普通查询不依赖供应商在线状态。恢复演练必须证明外呼调用数在熔断窗口内不再因新启动增加、停止任务仍有尝试、告警出现,并在供应商恢复后最终收敛。
本地Compose已挂载规则目录,并自动配置Grafana业务、技术、安全三类仪表盘;Prometheus采集API、Callbacks和Worker三类目标。正式环境还需对接告警接收器、值班分组、抑制规则和升级策略,并基于真实容量重新校准阈值。
业务扫描器还生成供应商通知积压和证书到期信号。证书通过`ops_resource_reference`登记为受控引用,只保存引用、核验时间和到期时间,不保存私钥或证书正文;30天内到期为高等级,7天内提升为严重等级。
## 4. 恢复与安全专项入口
```powershell
# 真实pg_dump/pg_restore到隔离数据库并核对迁移、表、外键和关键计数
pwsh -NoProfile -File .\scripts\verify-postgres-restore.ps1
# API/Callbacks/Worker重启、停机Outbox恢复和Redis热状态重建
pwsh -NoProfile -File .\scripts\verify-runtime-recovery.ps1
# 本地长期设备签名密钥轮换,不重建镜像
pwsh -NoProfile -File .\scripts\verify-local-secret-rotation.ps1
# 认证、授权、输入、签名、防重放、安全响应头和日志正文过滤
pwsh -NoProfile -File .\scripts\security-http-smoke.ps1
# 后端依赖可达漏洞、前端依赖、仓库密钥和最终镜像
docker run --rm -v "${PWD}:/src" -w /src golang:1.26.6-alpine `
sh -c "go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./..."
npm --prefix web audit --audit-level=high
docker run --rm -v "${PWD}:/src" zricethezav/gitleaks:v8.30.1 `
detect --source=/src --config=/src/.gitleaks.toml --no-banner
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock `
-v data-terminal-trivy-cache:/root/.cache/trivy aquasec/trivy:0.74.0 `
image --scanners vuln --ignore-unfixed --severity HIGH,CRITICAL `
data-terminal-cloud-api:latest
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock `
-v data-terminal-trivy-cache:/root/.cache/trivy aquasec/trivy:0.74.0 `
image --scanners vuln --ignore-unfixed --severity HIGH,CRITICAL `
data-terminal-cloud-web:latest
```
Web最终镜像使用非特权UID 101和8080监听;构建基线为Nginx 1.30.4 unprivileged Alpine并在镜像构建时应用当前系统安全更新。配置扫描必须确认Dockerfile不存在root运行高危项,镜像扫描必须同时覆盖API和Web最终运行层。
CI进一步使用镜像摘要固定Gitleaks、Trivy和Syft,并输出CycloneDX SBOM。浏览器矩阵由`node test/e2e/browser-matrix.mjs`执行,Safari必须在macOS真浏览器补测。宿主Go版本可能落后于镜像构建器,因此最终`govulncheck`证据必须在与生产构建一致的Go版本中生成。
## 5. 上线前证据
生产候选版本至少应保存:构建提交号、SBOM和镜像摘要,单元/集成/迁移/接口契约结果,依赖与镜像漏洞报告,密钥扫描报告,压测报告,故障恢复演练,固件暂停/回滚演练,隐私删除六子系统对账,以及真实供应商和真机连续通话记录。缺少外部账号、硬件或生产基础设施时,应明确标为“待外部验证”,不能用本地模拟结果替代。
+84
View File
@@ -0,0 +1,84 @@
# 双轨录音上传、校验与访问开发说明
## 1. 目标与边界
本模块实现长者声音`near`和智能体声音`far`两条录音轨道的分片声明、私有对象直传、可靠校验、播放/下载独立授权和访问审计。实时语音仍由终端、RTC和语音智能体直接传输;录音字节不经过业务HTTP接口,也不写入应用服务器系统盘。业务API只处理元数据和短期单对象地址,Worker从私有对象存储流式核验文件。
终端在通话心跳或离线批量遥测中上报`recording_queue.pending_segments`、`failed_segments`以及可选的脱敏`last_failure_code/last_failure_at`。家庭端设备卡片从设备影子选择时间最新的队列报告,显示待上传数量、失败数量和最近失败原因;失败码缺失时明确提示终端尚未上报,不把未知原因伪装成成功。云端完成对象完整性与WAV校验前,页面不会提示终端删除本地密文。
录音上传同时受两层授权约束:创建通话时冻结的授权必须允许`transcript_and_audio`,并且当前`raw_audio_storage`授权仍然有效。授权撤回后,不再创建新上传批次。家庭所有者、管理员、编辑者和查看者角色均不会自动获得录音播放或下载权。
## 2. 数据模型
| 对象 | 用途 | 状态/关键约束 |
|---|---|---|
| `upload_batch` | 设备一次分片清单及校验汇总 | `PENDING → UPLOADING → VERIFYING → COMPLETED/FAILED/EXPIRED`;设备、通话和幂等键唯一 |
| `media_segment` | 通话内单轨单序号录音对象 | 云端生命周期为`DECLARED → AUTHORIZED → VERIFYING → VERIFIED/REJECTED → DELETED`;另存设备本地密文删除状态、回报时间和失败码 |
| `upload_batch_segment` | 连接重试批次与稳定分片记录 | 同一分片可在失败后进入新批次重试,不生成重复媒体记录 |
| `recording_access_grant` | 指定用户的显式访问授权 | `PLAY`与`DOWNLOAD`分别授权,带用途、授予人、到期和撤销时间 |
| `media_access_log` | 每次播放/下载判定记录 | 保存用户、资源、动作、用途、成功/拒绝/失败、原因和请求编号 |
对象路径由云端按租户摘要、通话编号、轨道、序号和摘要前缀生成,不含长者姓名,也不接受设备自定义路径。数据库只保存对象引用,不保存临时上传或下载URL。
## 3. 设备上传流程
1. 通话结束后,设备为`near`和`far`分片计算SHA-256,提交轨道、序号、编码、内容类型、时长、大小和摘要,并提供16—128字符幂等键。
2. 云端检查设备与通话归属、通话终态、冻结录音策略、当前原录音授权、轨道/序号唯一性和清单结构。
3. 相同幂等键和相同清单返回原批次;同键不同清单或相同轨道序号对应不同元数据返回冲突。
4. 云端只为未验证分片签发15分钟、只能PUT一个私有对象的地址。录音字节由设备直接上传对象存储。
5. 设备调用完成接口后,事务把分片置为`VERIFYING`,并通过Outbox可靠创建`media.verify_requested`任务;重复完成不会重复生成校验任务。
6. Worker流式校验实际大小和SHA-256,再解析音频容器、编码和时长。对象暂时不可见时任务保留`VERIFYING`并按Outbox策略重试;大小、摘要、容器、编码或时长不符时进入`REJECTED`。
7. 全部分片验证通过后批次进入`COMPLETED`。设备查询到分片`VERIFIED`和`local_delete_after`后,才可在该时间之后删除本地加密文件。
8. 设备删除完成后提交`DELETED`回执;删除失败则提交`FAILED`和稳定错误码。云端拒绝过早的成功回执,已成功回执可幂等重放,且回执只表示设备副本已删除,不改变云端已验证对象状态。
`MEDIA_LOCAL_DELETE_GRACE`控制云端校验通过到允许终端删除本地密文之间的保护期,默认1小时,允许范围为大于0且不超过7天。Compose本地联调环境使用1秒以便快速完成闭环;生产环境未显式配置时保持1小时,不能为了缩短联调时间而提前返回删除成功。
当前研发基线实际解析`audio/wav`的PCM 16位格式并核对时长。Opus/Ogg等编码尚未在硬件能力表和分片清单格式中冻结,因此不会被当前API接受,避免把“只相信设备声明”误报为完成编码校验。
## 4. 播放与下载流程
1. 家庭所有者或管理员为家庭内指定用户显式创建`PLAY`或`DOWNLOAD`授权,填写用途和到期时间。编辑者和查看者不能创建授权。管理端可查询最近授权、识别有效/到期/已撤销状态并立即撤销。
2. 查看通话媒体时,服务重新检查账号仍是家庭有效成员、原录音授权仍有效,并确认至少有一种当前有效访问授权。响应仅返回已验证分片元数据,不返回对象地址。
3. 每次播放或下载都调用访问接口,重新检查成员关系、原录音授权、动作对应的独立授权和分片`VERIFIED`状态。
4. 检查成功后签发5分钟单对象地址:播放使用`inline`,下载使用`attachment`;文件名只包含通话编号、轨道和序号。
5. 成功和拒绝都写入`media_access_log`及统一审计。撤销播放授权不会自动撤销下载授权,反之亦然。
## 5. 接口
| 方法与路径 | 用途 |
|---|---|
| `POST /device/v2/conversations/{id}/upload-batches` | 幂等提交分片清单并取得缺失分片上传地址 |
| `POST /device/v2/upload-batches/{id}/complete` | 声明上传完成并可靠触发后台校验 |
| `GET /device/v2/upload-batches/{id}` | 查询批次、分片结果及本地延迟删除时间 |
| `POST /device/v2/media-segments/{id}/local-delete-ack` | 回报设备本地密文删除成功或失败;成功受删除时间门禁约束 |
| `GET /api/v1/subjects/{id}/recording-access-grants` | 所有者/管理员查询最近录音访问授权及状态 |
| `POST /api/v1/subjects/{id}/recording-access-grants` | 显式授予PLAY或DOWNLOAD |
| `POST /api/v1/recording-access-grants/{id}/revoke` | 撤销一条录音访问授权 |
| `GET /api/v1/conversations/{id}/media` | 按授权读取两轨已验证分片元数据 |
| `POST /api/v1/media-segments/{id}/access` | 重新鉴权并取得5分钟播放或下载地址 |
字段、错误码和设备签名头以`openapi/openapi.yaml`为准。
## 6. 删除与验证
主体删除任务的`object_storage`子任务会逐个删除该长者全部媒体对象,并将分片置为`DELETED`、清空对象引用和文件摘要;PostgreSQL子任务同步撤销录音访问授权、脱敏授权用途和访问用途,并清理媒体Outbox正文。任一对象删除失败时隐私请求保持部分失败并继续重试,不会错误显示完成。
本地完整环境运行:
```powershell
pwsh -NoProfile -File .\scripts\smoke-media-upload.ps1
```
脚本验证双轨直传、清单幂等、同键冲突、对象大小/SHA-256、WAV PCM16容器与时长、后台收敛、错误摘要拒绝、本地删除时间门禁、失败回执、成功与幂等回执、角色默认拒绝、授权列表权限、PLAY/DOWNLOAD独立授权、编辑者授予拒绝、撤销立即生效、短期对象访问,以及成功/拒绝访问审计;默认结束后删除全部合成数据库记录和对象。`-KeepFixture`仅供隔离的页面视觉验收,验收结束必须清理夹具。
React通话详情页展示近端/远端已验证分片、播放和下载能力、短期地址说明、按成员/用途/有效期的显式授权、当前有效授权和立即撤销入口。播放地址只保存在当前页面内存中,刷新页面后失效;下载通过一次性页面链接触发,前端不把短期地址写入持久化存储。
录音显式授权创建也使用家庭业务受控操作区。原录音管理表单只负责选择家庭成员、PLAY或DOWNLOAD、1/8/24小时有效期和用途;点击“确认授权”后先冻结长者、接收成员公开编号、单项能力、用途和准确到期时间。页面明确该授权按长者生效,可覆盖有效期内当前及后续可访问的全部已验证原录音,而不是只覆盖当前打开的通话;同一成员原有的同类有效授权会被服务端撤销并替换,家庭角色本身仍不产生录音能力。操作者显式确认后才调用创建接口,取消或切换通话、长者、家庭、工作台不会创建授权。
录音访问授权的“立即撤销”不会再单击即提交。页面先打开家庭业务受控操作区,固定展示长者、被授权成员、授权公开编号、PLAY或DOWNLOAD权限、用途、授予时间和到期时间;提交前必须确认后续不再为该成员签发新地址,并知晓已经签发的地址最长仍可能在5分钟内有效、成员已保存的离线副本无法由平台远程收回。取消只清除当前操作草稿,不调用撤销接口;切换长者或其他家庭业务上下文也会清除旧目标,避免误操作到上一条授权。
已获得独立权限的用户点击具体分片的“播放”或“下载”时,也不会立即签发地址。页面先冻结当前长者、通话公开编号、近端/远端轨道、分片序号和公开编号、时长、大小及用途:播放确认说明5分钟浏览器地址、逐次访问审计和旁听泄露风险;下载确认说明本地声音文件离开平台后的受控保存、转交与销毁责任。只有显式确认后才调用访问接口,服务端仍会重新核验家庭成员关系、动作对应的PLAY或DOWNLOAD授权、当前原录音授权和分片VERIFIED状态。切换通话、长者、家庭或工作台会清除旧分片草稿。
该页面采用低强度真实链路检查:使用真实本地OIDC登录和临时授权记录,只验证目标绑定、影响确认、取消关闭、零撤销或地址签发请求和数据库不变,不实际撤销业务数据或生成可访问地址。其中撤销页面夹具的通话快照未给当前账号原始录音播放权,媒体接口返回403被视为预期权限门禁;地址签发页面则使用最小PLAY/DOWNLOAD授权、当前原录音授权和一条已验证分片,证明按钮确实可达。除此之外的控制台错误、请求失败或页面溢出仍会使检查失败。
模拟终端的`media`场景现在会执行清单声明、对象直传、提交校验、轮询批次、等待删除保护期和`DELETED`回执。它不会删除`SIM_MEDIA_FILE`指向的操作人员文件,只把已上传内容视为内存中的设备密文副本并在回执前释放。生产保护期超过模拟器等待上限时,模拟器只报告计划删除时间,不发送过早成功回执。
+68
View File
@@ -0,0 +1,68 @@
# 可观测性与接口治理设计
## 1. 目标与适用范围
本设计作为 API、Callbacks 与 Worker 三个进程的公共能力基线,统一请求关联、分布式追踪、指标采集、流量保护、列表分页和资源并发更新规则。领域接口不得另行定义不兼容的分页游标或版本标记。
OpenAPI是浏览器、设备和回调接口的事实来源。拉取请求以目标分支契约为基线执行破坏性变更检查;设备接口还由代码测试固定`/device/v2`主版本、禁止以`User-Agent`推测能力,并要求兼容说明与OpenAPI版本同步。设备同主版本的新增行为必须通过可选能力位协商,破坏性变化使用新主版本,具体规则见[设备API兼容性约定](device-api-compatibility.md)。
## 2. 请求关联与分布式追踪
- 每个 HTTP 请求接收或生成 `X-Request-Id`,并在响应、统一错误体和结构化日志中返回。
- `otelhttp` 在最外层创建服务端 Span,接收并传播 W3C `traceparent` 与 `baggage`。
- 访问日志记录 `request_id`、`trace_id`、`span_id`、服务、方法、匹配后的路由模板和耗时;不记录 Authorization、Cookie、录音正文或供应商密钥。
- 本地环境通过 OTLP/gRPC 发送至 OpenTelemetry Collector。Collector 当前使用调试导出器,后续环境可替换为 Tempo、Jaeger或云厂商后端,不改变业务代码。
## 3. Prometheus 指标
| 指标 | 标签 | 用途 |
|---|---|---|
| `voice_platform_http_requests_total` | service、method、route、status | 请求量与错误率 |
| `voice_platform_http_request_duration_seconds` | service、method、route | HTTP延迟分布 |
| `voice_platform_http_inflight_requests` | service | 在途请求数 |
| `voice_platform_dependency_up` | service、dependency | PostgreSQL、Redis最近一次检查状态 |
| `voice_platform_dependency_check_duration_seconds` | service、dependency | 依赖检查耗时 |
| `voice_platform_outbox_events` | status | pending、processing、dead积压 |
`/metrics`与`/health/*`不进入业务限流。Prometheus 每10秒抓取三个进程;本地入口为 `http://localhost:59190`。生产环境应限制指标端点只允许监控网络访问。
API还会按`EXTERNAL_PROBE_INTERVAL`周期验证OIDC JWKS端点可读取、JSON结构有效且至少存在一把公钥。启用方舟的API/Worker会对`ARK_BASE_URL`执行证书校验TLS握手,启用火山RTC的Worker会对`VOLC_RTC_BASE_URL`执行同类握手,启用托管内容安全的Worker会对`CONTENT_SAFETY_ENDPOINT`执行同类握手。TLS探针只做DNS、TCP和TLS协商,不发送供应商业务请求或待分类正文,因此不消耗模型Token、RTC分钟、策略调用或任务配额。
探针结果复用`voice_platform_dependency_up{service,dependency}`,其中外部标签为`oidc_jwks`、`ark_tls`、`volc_rtc_tls`和`content_safety_tls`。失败不会改变服务readiness;模型和内容安全调用本身已有降级或安全重试路径,供应商网络故障不应触发Pod重启风暴。`PlatformExternalDependencyUnavailable`只在指标连续5分钟为0后触发CRITICAL告警。运营验收快照可把四类依赖全部选为必需项,任何记录缺失、不可用或超过三倍探针周期未更新都会阻断技术门禁。
## 4. Redis令牌桶限流
- Key格式:`voice-platform:ratelimit:{scope}:{identifier_hash}`。
- 标识符使用SHA-256截断摘要,Redis中不保存原始IP、用户编号、设备编号或客户组织编号。
- Lua脚本以Redis服务器时间原子完成补充、扣减和过期时间设置,避免多实例竞态及应用服务器时钟偏差。
- HTTP入口当前先启用 `network` 范围;应用服务可复用同一组件增加 `user`、`device`、`tenant` 范围并组合更严格策略。
- 超限返回HTTP 429、错误码 `RATE_LIMITED`、`Retry-After` 和 `X-RateLimit-Remaining`。
- Redis短时故障采用可观测的 fail-open,避免公共缓存故障放大成全站中断;登录、设备注册、密钥操作等高风险接口落地时应叠加本地保护或按接口改为 fail-closed。
本地默认速率为每来源50次/秒、突发100次,可通过 `RATE_LIMIT_RPS` 和 `RATE_LIMIT_BURST` 调整。生产值必须依据容量测试冻结。
## 5. 游标分页规范
- 查询参数 `limit` 缺省为20,只允许1至100。
- `cursor` 是HMAC-SHA256签名的不透明Base64URL值,载荷只含版本、稳定排序键和公开资源ID。
- 服务端按“排序键 + ID”组成确定性顺序,查询下一页时使用严格大于或小于条件,禁止把数据库偏移量作为游标。
- 响应中的 `next_cursor` 仅在确有下一页时返回;客户端不得解析、修改或长期持久化游标。
- 签名失败、版本不支持或字段缺失统一作为无效游标处理,不把解析细节暴露给调用方。
## 6. ETag与乐观并发规范
- 可变资源读取成功时返回强ETag,格式为 `"v{version}"`,例如 `"v3"`。
- PATCH、PUT和状态变更请求必须携带 `If-Match`;缺失时映射HTTP 428,格式无效映射HTTP 400,版本不一致映射HTTP 412。
- 不接受弱ETag `W/` 或通配符 `*`。
- 数据表须包含从1开始的 `version` 列;更新SQL必须带 `WHERE id=? AND version=?`,成功后原子执行 `version=version+1`。
- ETag只解决并发覆盖,不替代幂等键。会产生外部副作用的写请求仍须使用幂等执行器和outbox。
## 7. 验证门禁
1. 日常开发运行变更包测试、OpenAPI类型生成和前端构建;`go test ./...`、`go vet ./...`保留给CI或发布候选。
2. 游标篡改、非法limit、弱ETag、缺失If-Match和版本冲突必须有自动化测试。
3. Redis集成测试必须证明突发上限和令牌恢复。
4. 三个后端 `/health/ready` 返回200,Prometheus三个target均为 `up=1`。
5. Collector日志出现三个服务上报的Span,访问日志出现对应 `trace_id` 与 `span_id`。
6. Worker指标能区分待处理、处理中和死信事件。
7. OpenAPI破坏性变更检查和设备协议兼容门禁必须通过。
+172
View File
@@ -0,0 +1,172 @@
# 内部运营配置、发布门禁与灰度控制开发说明
## 1. 实现范围
V19—V20及V34已经形成内部运营配置的本地纵向闭环:运营身份、五类运行配置、不可变草稿、结构校验、离线评测证据、受控资源引用、双人审批、按范围灰度、确定性分桶、并发影响上限、投放台账、带版本校验的暂停恢复、历史差异、不可变回滚和新通话配置冻结。
本批次覆盖的配置类型为`PERSONA`、`VOICE`、`MODEL`、`ROUTING`和`AGENT_BUNDLE`。灰度范围支持整个客户组织、单个试验对象和设备组;运行时命中优先级固定为“试验对象 > 设备组 > 客户组织”。
以下能力仍不冒充已完成:真实KMS取密、供应商在线资源探测、真实模型兼容性调用和成本预测尚未接入外部服务;当前资源引用的“已核验”状态由受控运营流程登记,并由类型、状态及180天有效期门禁复核。费用、额度和告警处置属于后续批次。
## 2. 运营角色与租户边界
| 角色 | 权限 |
|---|---|
| `customer` | 无内部运营接口权限 |
| `ops_viewer` | 查看配置、评测证据、资源引用、设备组和投放台账 |
| `ops_operator` | 查看、创建草稿、提交审批、登记评测/资源/设备组、撤销资源引用 |
| `ops_approver` | 具备操作员能力,并可审批发布、回滚、暂停和恢复投放 |
| `ops_admin` | 平台管理员能力;仍受创建人与审批人分离约束 |
运营角色保存在`user_account.platform_role`,不会信任OIDC令牌中的自报角色。每次运营请求用OIDC签名身份与`X-Tenant-Id`共同解析当前客户组织的活动账号;家庭角色不会隐式获得运营权限,跨客户组织的试验对象和设备编号均由服务端拒绝。生产环境仍需由身份系统强制MFA和受控授权流程。
## 3. 配置与投放状态
```text
DRAFT ──结构校验/提交──> PENDING_APPROVAL ──评测与资源门禁+另一审批人──> PUBLISHED
│
激活一个或多个灰度投放范围
↓
ACTIVE ──暂停──> PAUSED
↑ │
└────恢复─────┘
│
同范围新版本替代ACTIVE或PAUSED
↓
ENDED
│
无任何活动投放仍引用该配置版本 ───────────┘
↓
RETIRED
```
- `tenant_id + config_key`内的`version_no`严格递增,事务级咨询锁防止并发分配重复版本。
- 草稿和历史版本不可覆盖;提交、发布、资源撤销及投放暂停恢复均使用强`If-Match`并发控制。
- 配置创建人不得审批自己的变更,违反时返回`SELF_APPROVAL_FORBIDDEN`。
- 同一配置键可以同时存在多个已发布版本,但每个具体投放目标只能有一个活动版本。
- 租户、每个试验对象、每个设备组分别由数据库部分唯一索引保护活动投放唯一性。
- 替换某一目标时只结束该目标的旧投放;旧版本仍被其他活动目标使用时保持`PUBLISHED`。
- 暂停只阻止后续新通话命中,不改变已经建立通话的冻结配置;恢复要求配置仍为`PUBLISHED`且同一键和范围没有其他活动投放。
- 新版本会同时结束同范围的`ACTIVE`和`PAUSED`旧投放;被替代的暂停记录不能恢复,避免旧灰度意外复活。
- 当旧版本不再被任何活动投放使用时才转为`RETIRED`。
- 回滚复制历史正文创建新的已发布版本,不复活或修改历史行;`rollback_of_id`记录内容来源,`supersedes_id`记录被该目标替换的版本。
- 创建草稿、评测、设备组、资源引用和回滚要求`Idempotency-Key`,正文摘要冲突返回HTTP 409。
## 4. 发布门禁
### 4.1 结构与敏感信息
配置正文最大64KiB,拒绝任意层级包含`secret`、`password`、`token`、`private_key`、`api_key`或`credential`含义的键。供应商凭据只通过`secret_refs`保存引用,接口、数据库、页面和日志均不保存密钥明文。
`runtime-config-v1`本地结构检查如下:
- 人设要求`system_prompt`和`tone`。
- 音色要求`provider`、`voice_resource_ref`及`credential_ref`。
- 模型要求`provider`、`model_id`及`credential_ref`。
- 路由要求`default_engine_mode`属于`s2s_pure`、`s2s_hybrid`或`cascade`,且存在`routes`。
- Agent组合要求人设、音色、模型和路由四个版本引用。
Agent组合的四个字段不是任意字符串:`persona_version_id`、`voice_version_id`、`model_version_id`和`routing_version_id`必须是UUID,并分别指向当前客户组织内对应`PERSONA`、`VOICE`、`MODEL`和`ROUTING`类型的不可变配置版本。组件必须已经发布过;当前为`PUBLISHED`或因后续版本替换而成为`RETIRED`都可复用,`DRAFT`、`PENDING_APPROVAL`、跨客户、错误类型和不存在的编号均会被拒绝。提交审批时把四个组件的编号、类型和状态写入结构校验结果,发布及回滚事务再次复核,避免审批期间引用失效。
### 4.2 离线评测
每个待发布版本至少需要一条`PASSED`评测记录。评测通过`evidence_type`区分通用证据`GENERIC`和标准Agent质量证据`AGENT_QUALITY_V1`,并保存套件版本、结果、指标快照、报告引用、操作者和时间。除Agent组合外的配置继续接受通用证据;`AGENT_BUNDLE`发布必须存在一条`PASSED`的`AGENT_QUALITY_V1`证据,手工登记的通用`PASSED`不能放行。
标准Agent质量证据只能登记到`AGENT_BUNDLE`。服务端校验报告结构版本、评测集SHA-256、Agent和模型配置引用、执行环境与时间、十个以上用例、通过/失败计数以及全部强制质量指标,并校验结论与失败计数一致。登记器注入的`config_version_id`必须等于目标组合编号,`model_config_ref`必须等于`runtime-config-version:<model_version_id>`,另一组合或另一模型的报告不能复用。页面不向Agent组合提供手工通用评测按钮;标准证据应由`cmd/agent-eval-register`在重新计算报告并完成防篡改检查后登记。失败记录不会被删除,也不能单独放行发布。所有评测都只允许在`DRAFT`或`PENDING_APPROVAL`状态登记,防止发布后补写证据。
非Agent组合的通用评测从具体配置版本卡片打开页面内登记表单,不再连续调用浏览器输入和确认弹窗。表单固定配置公开编号、类型、版本族、版本号及行版本;套件版本必填且最长100字符,报告引用必填且最长300字符,`PASSED/FAILED`均必须由操作者明确选择,默认不预选结论。选择`PASSED`后还必须确认报告内容和指标支持通过结论,并理解该记录可能参与发布门禁;选择`FAILED`不会沿用该确认。取消、切换客户组织或配置列表刷新会清除草稿,不创建评测。页面仍不允许为`AGENT_BUNDLE`手工登记通用证据,服务端继续校验配置状态、租户、幂等和证据类型。
### 4.3 资源引用
门禁按用途匹配资源类型:
| 配置字段 | 必需资源类型 |
|---|---|
| `secret_refs.*` | `PROVIDER_CREDENTIAL` |
| `VOICE.voice_resource_ref` | `VOICE_RESOURCE` |
| `MODEL.model_id` | `MODEL_RESOURCE` |
引用必须属于当前客户组织、状态为`ACTIVE`,且`verified_at`在180天内。仅名称相同但类型错误、已撤销或过期的引用均不能放行。资源撤销使用`If-Match`并形成事务审计;撤销后后续发布会被阻断。真实KMS读取、供应商在线校验和自动轮换仍需外部系统接入。
资源撤销从资源台账打开页面内受控操作区,不再使用浏览器输入弹窗。确认区固定资源公开编号、显示名称、类型、引用标识、当前状态和行版本;原因必填且最长500字符,并要求操作者确认后续发布门禁影响、替代资源已经核对,以及本操作不会删除外部密钥或改写历史会话。取消、客户组织切换或列表刷新会清除草稿,不发送撤销请求。
## 5. 灰度选择与影响上限
发布或回滚请求必须提供:`scope_type`、可选`scope_id`、`rollout_percent`和`max_concurrent_calls`。
- `TENANT`不允许填写`scope_id`。
- `SUBJECT`要求有效试验对象公开编号且比例固定为100%。
- `DEVICE_GROUP`要求当前客户组织的活动设备组,可设置1%—100%。
- 设备组百分比使用设备公开编号、投放固定种子和PostgreSQL稳定哈希进行确定性分桶;同一投放下设备不会在请求间随机跳组。
- 运行时先尝试试验对象范围,再尝试设备组,最后尝试租户范围。
- 当前冻结该配置版本的未结束通话数达到`max_concurrent_calls`时,该投放不再接纳新通话,继续尝试较低优先级范围或本地开发回退配置。
## 6. 接口
| 接口 | 作用 |
|---|---|
| `GET /api/v1/ops/config-versions` | 查询配置版本历史 |
| `POST /api/v1/ops/config-versions` | 幂等创建不可变草稿 |
| `PATCH /api/v1/ops/config-versions/{id}/submit` | 结构校验并提交审批 |
| `GET /api/v1/ops/config-versions/{id}/evaluations` | 查询离线评测证据 |
| `POST /api/v1/ops/config-versions/{id}/evaluations` | 幂等登记评测证据 |
| `PATCH /api/v1/ops/config-versions/{id}/publish` | 双人审批并激活指定灰度范围 |
| `POST /api/v1/ops/config-versions/{id}/rollback` | 幂等复制历史内容并投放新的回滚版本 |
| `GET /api/v1/ops/config-rollouts` | 查询活动及历史投放台账 |
| `PATCH /api/v1/ops/config-rollouts/{id}/pause` | 使用`If-Match`暂停活动投放,仅影响后续新通话 |
| `PATCH /api/v1/ops/config-rollouts/{id}/resume` | 使用`If-Match`恢复未被替代的暂停投放 |
| `GET/POST /api/v1/ops/resource-references` | 查询或登记受控资源引用 |
| `PATCH /api/v1/ops/resource-references/{id}/revoke` | 使用`If-Match`撤销资源引用 |
| `GET/POST /api/v1/ops/device-groups` | 查询或创建静态设备灰度组 |
所有接口要求OIDC和`X-Tenant-Id`;所有写入形成事务审计,不记录密钥明文。
## 7. 新通话配置冻结
设备创建新通话时,在同一数据库事务内读取设备活动绑定,并执行目标匹配、稳定分桶及并发上限判断。平台优先选择活动投放所绑定的`AGENT_BUNDLE`版本,`config_key`只表示运营定义的版本族,不是运行时保留开关;页面建议的`default-agent-bundle`可以直接使用,也可以按业务命名多个灰度版本族。优先级固定为“试验对象 > 设备组 > 客户组织”,同一范围存在多个可命中组合时选择最近激活的一项。命中后读取组合引用的`ROUTING.default_engine_mode`,并把`cfg:{Agent组合公开编号}`写入`context_snapshot.prompt_bundle_version`。组合所引用的路由版本允许处于`PUBLISHED`或`RETIRED`,因为已发布版本不可变且退役只阻止新的直接发布选择,不破坏已审核组合和回滚的可追溯性。后续发布、暂停、撤销或回滚只改变新通话选择,已创建通话始终使用自己的冻结组合。
Worker启动Agent时按冻结的组合公开编号重新读取同一客户组织内、类型严格匹配的`PERSONA`、`VOICE`和`MODEL`版本,把组合编号、四个组件编号、系统提示、表达风格、音色资源引用和模型资源引用放入统一供应商启动请求。火山适配器会把系统提示、表达风格和最小必要背景按顺序加入系统消息,并支持在正式模板中使用`{{MODEL_RESOURCE_REF}}`、`{{VOICE_RESOURCE_REF}}`、`{{AGENT_BUNDLE_VERSION}}`、`{{PERSONA_VERSION}}`、`{{VOICE_VERSION}}`、`{{MODEL_VERSION}}`和`{{ROUTING_VERSION}}`占位符。模型和音色字段的实际落点由供应商官方模板决定,平台不猜测未确认的专有字段。
为兼容已有数据,未命中Agent组合时继续选择活动投放所绑定的`ROUTING`版本,并冻结该路由公开编号;页面建议键`default-agent-routing`不是强制值。若同一范围存在多个直接路由,也按最近激活时间选择。本地开发环境在两类投放都不可用时回退到`minimal-v2 + s2s_hybrid`;生产环境没有任何可命中的Agent组合或旧路由时拒绝创建并返回`RUNTIME_CONFIG_UNAVAILABLE`,避免静默切换。冻结的三种模式由`voice-chat-template-bundle-v1`选择同名供应商请求模板;真实模板字段、资源兼容性和语音效果仍需供应商账号联调。
## 8. 管理端
非`customer`账号可见“配置发布与版本控制”工作台,支持:
- 切换有运营权限的客户组织。
- 登记并撤销受控资源引用、创建设备灰度组。
- 查看活动/暂停投放的范围、比例、并发上限和行版本,并按权限暂停或恢复。
- 创建五类配置草稿并查看相邻版本差异;创建Agent组合时直接从当前客户组织已经发布过的人设、音色、模型和路由版本中选择,不需要手工复制UUID或编辑组合JSON。
- 登记和查看离线评测证据。
- 配置租户、设备组或试验对象投放参数并审批发布/回滚。
- 根据角色隐藏写入控件,并显示空数据、失败、并发冲突与操作结果。
Agent组合选择器优先预选最近发布的组件版本,并明确标记仍可追溯复用的已退役版本。任一组件类型尚无已发布版本时,页面显示缺失类型并禁用创建按钮;切换客户组织会重置未提交配置和通用评测表单,避免把上一客户组织的组件编号或评测结论带入新草稿。页面校验只用于减少录入错误,提交和发布时服务端仍重新验证客户组织、组件类型以及`PUBLISHED/RETIRED`状态。
投放暂停和恢复同样使用页面内受控操作区,固定投放公开编号、配置键、范围、当前状态、比例、并发上限和行版本。暂停要求原因、观察依据及“只停止后续新通话命中”的影响确认;恢复要求问题消除证据、观察/回退方案,并确认配置仍可发布且同一范围不存在其他活动投放。三类受控操作在页面提交前只编辑本地草稿,实际请求继续由服务端执行角色、租户、状态和`If-Match`校验。
草稿提交审批、待审批版本发布和已退役历史内容回滚也使用具体版本卡片内的全宽受控流转区。确认区固定版本公开编号、类型、配置键、版本号、状态、行版本、创建人和变更说明;提交审批只要求确认目标与内容,不会直接发布。发布与回滚在当前受控区内单独填写客户组织、设备组或试验对象范围、灰度比例、并发影响上限和最长500字符的依据,选择其他版本时不会隐式沿用这些参数。范围或目标变化会重新要求影响确认;发布明确只影响后续新通话,回滚明确创建新的不可变版本而不复活历史行。取消、客户组织切换或列表刷新会清除草稿且不发送流转请求,服务端双人审批、评测、资源、租户、状态、幂等和并发门禁保持不变。
## 9. 验证
```powershell
pwsh -NoProfile -File .\scripts\smoke-ops-config.ps1
```
主烟测使用真实Keycloak账号和真实设备HMAC,覆盖普通账号403、敏感键拒绝、幂等、创建人自批拒绝、无评测发布阻断、评测证据查询、缺少模型/凭据资源阻断、正确类型资源放行、资源撤销、投放台账、租户与设备组并存、设备组优先、回滚版本链、新通话冻结和事务审计,并清理所有合成数据。日常低强度验证另以真实PostgreSQL的定向HTTP用例覆盖暂停、缺少`If-Match`、过期版本冲突、恢复和审计,不要求重复执行整套主烟测。
页面可用性增量采用一次真实OIDC登录的无提交冒烟,不重复整套回归:`test/e2e/visual-ops-config-evaluation.mjs`以临时资源、活动投放和暂停投放为目标,检查三类确认区的目标与行版本绑定、原因门禁、影响确认、取消关闭、零撤销/暂停/恢复PATCH、零横向溢出、零控制台错误和零非预期失败请求;`finally`按公开夹具范围精确清理。该检查证明页面不会误提交且操作前置约束可用,不代替服务端已有状态机烟测或目标环境真实资源验证。
配置版本流转另使用`test/e2e/visual-ops-config-version-transition.mjs`进行单条真实OIDC无提交验证:脚本创建`DRAFT`、`PENDING_APPROVAL`和`RETIRED`三条临时版本,检查提交、发布、回滚目标及行版本绑定,原因/范围/二次确认门禁,试验对象范围强制100%及修改范围后重新确认,取消关闭和数据库状态不变,同时检查零流转请求、零横向溢出、零控制台错误和零失败请求。截图仅用于确认全宽表单可读性,脚本结束后精确清理三条配置夹具。
实际Chrome页面检查:
```powershell
$env:PLAYWRIGHT_REQUIRE_FROM='C:\Users\19926\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\node_modules'
$env:CHROME_PATH='C:\Program Files\Google\Chrome\Application\chrome.exe'
node .\scripts\visual-ops-config-ui.cjs
```
检查覆盖OIDC登录、运营工作台可见性、资源/设备组/投放/版本区布局、接口失败、控制台错误和横向溢出;截图保存到`test/e2e/screenshots/operations/ops-config-workspace.png`。
通用评测表单另使用`test/e2e/visual-ops-config-evaluation.mjs`进行单条轻量验证:脚本创建一条临时路由草稿,经真实OIDC深链进入配置工作台,验证目标/行版本绑定、空字段与未选结论阻断、通过结论二次确认、失败结论显式选择、取消关闭、零评测POST、零控制台错误和零横向溢出,最后精确清理配置及评测夹具。该检查不替代服务端评测登记、发布门禁和标准Agent报告验证。

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