diff --git a/.env.example b/.env.example index c214c41..717940e 100644 --- a/.env.example +++ b/.env.example @@ -72,6 +72,8 @@ 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 +PLATFORM_HEALTH_CALLBACK_SECRET=local-platform-health-callback-secret-32-bytes-minimum +PLATFORM_HEALTH_CALLBACK_CLOCK_SKEW=5m PROVIDER_REQUEST_TIMEOUT=5s PROVIDER_FAILURE_THRESHOLD=3 PROVIDER_CIRCUIT_COOLDOWN=1m diff --git a/compose.yaml b/compose.yaml index a0a89a3..00484b6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -220,6 +220,7 @@ services: <<: *backend-environment HTTP_ADDR: 0.0.0.0:8081 PROVIDER_CALLBACK_SECRET: local-provider-callback-secret-32-bytes-minimum + PLATFORM_HEALTH_CALLBACK_SECRET: local-platform-health-callback-secret-32-bytes-minimum RATE_LIMIT_RPS: "1000" RATE_LIMIT_BURST: "1500" ports: diff --git a/deploy/kubernetes/base/deployments.yaml b/deploy/kubernetes/base/deployments.yaml index e84d31a..606c423 100644 --- a/deploy/kubernetes/base/deployments.yaml +++ b/deploy/kubernetes/base/deployments.yaml @@ -190,6 +190,8 @@ spec: value: /var/run/secrets/voice-platform/common/redis-password - name: PROVIDER_CALLBACK_SECRET_FILE value: /var/run/secrets/voice-platform/callbacks/provider-callback-secret + - name: PLATFORM_HEALTH_CALLBACK_SECRET_FILE + value: /var/run/secrets/voice-platform/callbacks/platform-health-callback-secret ports: - name: http containerPort: 8081 diff --git a/deploy/kubernetes/examples/secret-provider-classes.example.yaml b/deploy/kubernetes/examples/secret-provider-classes.example.yaml index 016c95e..d37254d 100644 --- a/deploy/kubernetes/examples/secret-provider-classes.example.yaml +++ b/deploy/kubernetes/examples/secret-provider-classes.example.yaml @@ -29,7 +29,7 @@ metadata: spec: provider: replace-with-installed-provider parameters: - objects: replace-with-provider-specific-callback-secret + objects: replace-with-provider-specific-provider-and-platform-health-callback-secrets --- apiVersion: secrets-store.csi.x-k8s.io/v1 kind: SecretProviderClass diff --git a/deploy/server/bootstrap-secrets.sh b/deploy/server/bootstrap-secrets.sh index fdb9fd9..deb425e 100644 --- a/deploy/server/bootstrap-secrets.sh +++ b/deploy/server/bootstrap-secrets.sh @@ -37,10 +37,10 @@ umask 077 printf 'CURSOR_SIGNING_SECRET=%s\n' "$(openssl rand -hex 32)" printf 'SHARE_TOKEN_SIGNING_SECRET=%s\n' "$(openssl rand -hex 32)" printf 'PROVIDER_CALLBACK_SECRET=%s\n' "$(openssl rand -hex 32)" + printf 'PLATFORM_HEALTH_CALLBACK_SECRET=%s\n' "$(openssl rand -hex 32)" printf 'MOCK_PROVIDER_API_KEY=%s\n' "$(openssl rand -hex 24)" printf 'ALERT_WEBHOOK_SECRET=%s\n' "$(openssl rand -hex 32)" } > "$output_path" chmod 600 "$output_path" echo "created $output_path with mode 600" - diff --git a/deploy/server/compose.override.yaml b/deploy/server/compose.override.yaml index a55c229..d4ac897 100644 --- a/deploy/server/compose.override.yaml +++ b/deploy/server/compose.override.yaml @@ -106,6 +106,7 @@ services: <<: *server-backend-environment HTTP_ADDR: 0.0.0.0:8081 PROVIDER_CALLBACK_SECRET: ${PROVIDER_CALLBACK_SECRET} + PLATFORM_HEALTH_CALLBACK_SECRET: ${PLATFORM_HEALTH_CALLBACK_SECRET} RATE_LIMIT_RPS: "1000" RATE_LIMIT_BURST: "1500" restart: unless-stopped diff --git a/deploy/server/health-monitor.sh b/deploy/server/health-monitor.sh index c7d594d..7f8d24a 100755 --- a/deploy/server/health-monitor.sh +++ b/deploy/server/health-monitor.sh @@ -18,6 +18,8 @@ if [[ -f "$monitor_config" ]]; then fi alert_webhook_url=${ALERT_WEBHOOK_URL:-} alert_webhook_secret_file=${ALERT_WEBHOOK_SECRET_FILE:-/etc/data-terminal-cloud/monitor-webhook-secret} +platform_health_report_url=${PLATFORM_HEALTH_REPORT_URL:-} +platform_health_report_secret_file=${PLATFORM_HEALTH_REPORT_SECRET_FILE:-/etc/data-terminal-cloud/platform-health-callback-secret} if [[ -f "$offsite_config_file" ]]; then # shellcheck disable=SC1090 source "$offsite_config_file" @@ -232,14 +234,12 @@ PY chmod 0644 "$status_tmp" mv -f "$status_tmp" "$status_file" -if [[ -n "$alert_webhook_url" && "$previous_status" != "$overall" ]]; then - if [[ ! -r "$alert_webhook_secret_file" ]]; then - record_check alert_delivery critical secret_missing - echo "Alert webhook is configured but its HMAC secret is unreadable" >&2 - exit 5 - fi +post_signed_status() { + local target_url=$1 + local secret_file=$2 + local timestamp signature timestamp=$(date +%s) - signature=$(python3 - "$alert_webhook_secret_file" "$status_file" "$timestamp" <<'PY' + signature=$(python3 - "$secret_file" "$status_file" "$timestamp" <<'PY' import hashlib import hmac import sys @@ -249,7 +249,7 @@ body = open(sys.argv[2], "rb").read() print(hmac.new(secret, sys.argv[3].encode() + b"." + body, hashlib.sha256).hexdigest()) PY ) - python3 - "$alert_webhook_url" "$curl_config" <<'PY' + python3 - "$target_url" "$curl_config" <<'PY' import sys url = sys.argv[1].replace("\\", "\\\\").replace('"', '\\"') @@ -260,6 +260,23 @@ PY -H "X-Platform-Timestamp: $timestamp" \ -H "X-Platform-Signature: sha256=$signature" \ --data-binary "@$status_file" >/dev/null +} + +if [[ -n "$platform_health_report_url" ]]; then + if [[ ! -r "$platform_health_report_secret_file" ]]; then + echo "Platform health reporting is configured but its HMAC secret is unreadable" >&2 + exit 6 + fi + post_signed_status "$platform_health_report_url" "$platform_health_report_secret_file" +fi + +if [[ -n "$alert_webhook_url" && "$previous_status" != "$overall" ]]; then + if [[ ! -r "$alert_webhook_secret_file" ]]; then + record_check alert_delivery critical secret_missing + echo "Alert webhook is configured but its HMAC secret is unreadable" >&2 + exit 5 + fi + post_signed_status "$alert_webhook_url" "$alert_webhook_secret_file" fi printf 'platform_health status=%s critical=%s warning=%s status_file=%s\n' \ diff --git a/deploy/server/install-monitoring.sh b/deploy/server/install-monitoring.sh index 1fbff24..ddab760 100755 --- a/deploy/server/install-monitoring.sh +++ b/deploy/server/install-monitoring.sh @@ -9,13 +9,31 @@ fi project_root=${PLATFORM_ROOT:-/opt/data-terminal-cloud/current} install -d -m 0700 /etc/data-terminal-cloud install -d -m 0750 /var/lib/data-terminal-cloud-monitor +platform_env="$project_root/deploy/server/.env" +if [[ -r "$platform_env" && ! -s /etc/data-terminal-cloud/platform-health-callback-secret ]]; then + platform_health_secret=$(sed -n 's/^PLATFORM_HEALTH_CALLBACK_SECRET=//p' "$platform_env" | head -n1) + if (( ${#platform_health_secret} >= 32 )); then + umask 077 + printf '%s\n' "$platform_health_secret" >/etc/data-terminal-cloud/platform-health-callback-secret + fi +fi +if [[ -s /etc/data-terminal-cloud/platform-health-callback-secret ]]; then + chmod 0600 /etc/data-terminal-cloud/platform-health-callback-secret +fi if [[ ! -s /etc/data-terminal-cloud/monitor-webhook-secret ]]; then umask 077 openssl rand -base64 48 >/etc/data-terminal-cloud/monitor-webhook-secret fi chmod 0600 /etc/data-terminal-cloud/monitor-webhook-secret if [[ ! -e /etc/data-terminal-cloud/monitor.env ]]; then - cat >/etc/data-terminal-cloud/monitor.env <<'EOF' + public_origin=$(sed -n 's/^PUBLIC_ORIGIN=//p' "$platform_env" 2>/dev/null | head -n1) + report_url= + if [[ "$public_origin" == https://* && -s /etc/data-terminal-cloud/platform-health-callback-secret ]]; then + report_url="$public_origin/callbacks/v1/platform-health" + fi + cat >/etc/data-terminal-cloud/monitor.env < 更新日期:2026-08-26 -> 当前接口契约版本:0.64.0 +> 当前接口契约版本:0.65.0 > 当前主里程碑:M8目标环境联调、试点与上线验收(外部条件待提供) ## 阶段状态 @@ -249,6 +249,7 @@ | 真实终端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规则校验。 | +| 云端主机健康状态接入 | OpenAPI 0.65.0新增独立HMAC签名的主机健康回调、30天脱敏快照表和运营只读状态页面。五分钟周期上报覆盖公网入口、容器、备份、证书、磁盘和定时器;无签名、过期、重复检查及综合状态不一致报告被拒绝,页面超过15分钟未更新明确标记过期。该能力不替代整机、Callbacks或数据库失联时必须使用的外部探针。 | | 阶段8外部执行轻量预检 | 新增`scripts/stage8-preflight.ps1`,以单一只读入口核对候选Git提交、远程仓库、Compose本地基线、外部配置变量名称和十类证据索引结构/状态;报告明确不读取密钥值,也不把环境变量或本地服务可用性当作验收证据。默认仅报告缺口,`-Strict`可供自动化阻断未就绪交接;当前已建立本地`main`候选提交,本地运行基线就绪且索引结构有效,仍缺远程仓库,样例构建摘要为占位值,十类外部证据均为`PENDING`。 | | 候选构建身份贯通 | Compose新增统一候选构建参数,后端二进制和后端/Web镜像OCI标签共享版本、完整Git提交及UTC构建时间;远程CI从OpenAPI版本和运行编号形成候选版本,构建后重新读取两个镜像标签并与`GITHUB_SHA`和构建时间逐项比对,再把候选版本、提交、时间和双镜像摘要写入构建元数据。修复了此前Dockerfile虽支持注入但Compose/CI未传参、运行版本仍为`dev/unknown`的问题。 | diff --git a/docs/device-api-compatibility.md b/docs/device-api-compatibility.md index 351ca06..19e1f24 100644 --- a/docs/device-api-compatibility.md +++ b/docs/device-api-compatibility.md @@ -1,7 +1,7 @@ # 设备 API 兼容性约定 > 当前设备接口主版本:`/device/v2` -> 当前接口契约:OpenAPI 0.64.0 +> 当前接口契约:OpenAPI 0.65.0 > 适用对象:云端 API、终端固件、模拟终端和设备联调工具 ## 1. 兼容目标 @@ -43,6 +43,7 @@ CI在拉取请求中以目标分支OpenAPI为基线执行破坏性变更检查 | OpenAPI版本 | 设备兼容变更 | 兼容结论 | |---|---|---| | 0.64.0 | 运营外部预检增加`content_safety_tls`枚举和候选快照第四类可选必需依赖;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 | +| 0.65.0 | 新增主机健康签名回调和运营只读查询;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 | | 0.63.0 | 增加设备固件阶段事件查询及云端证据字段;设备已有命令回执仅记录同一结果的不可变事件,未增加旧终端必填字段 | v2兼容,旧终端行为不变 | | 0.62.0 | 命令结果增加A/B写入、待重启、启动健康和回退可选字段,省略时保持原有单分区结果语义 | v2兼容,旧终端可继续省略 | | 0.61.0 | 新增家庭侧固件版本影响只读接口,未修改设备协议 | v2完全兼容 | diff --git a/docs/kubernetes-deployment.md b/docs/kubernetes-deployment.md index e263239..3d77a35 100644 --- a/docs/kubernetes-deployment.md +++ b/docs/kubernetes-deployment.md @@ -84,7 +84,7 @@ docker push "$Registry/migrate:$Version" |---|---|---| | `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-callbacks-secrets` | `provider-callback-secret`、`platform-health-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 | @@ -119,7 +119,7 @@ kubectl -n voice-platform get secret |---|---| | `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-callbacks` | `provider-callback-secret`、`platform-health-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` | diff --git a/docs/platform-health-reporting.md b/docs/platform-health-reporting.md new file mode 100644 index 0000000..f076198 --- /dev/null +++ b/docs/platform-health-reporting.md @@ -0,0 +1,56 @@ +# 云端主机健康状态接入开发说明 + +## 1. 目标与边界 + +单机健康监测原先只把结果保存在主机`/var/lib/data-terminal-cloud-monitor/status.json`。本功能把同一份脱敏状态每五分钟签名上报到Callbacks进程,并在运营“联调诊断”工作台展示最新结果,覆盖公网HTTPS、OIDC签发地址、核心容器、本地及异地备份、证书、磁盘和systemd定时器。 + +该链路用于当前状态查看和排障,不替代外部告警网关:Callbacks、数据库或整机同时不可用时,平台无法把自身故障写入自身数据库,仍需阿里云云监控、独立探针或真实值班网关从系统外发现。 + +## 2. 业务流程 + +1. `platform-health-monitor.timer`每五分钟运行`deploy/server/health-monitor.sh`。 +2. 脚本执行全部轻量检查,先原子更新主机状态文件。 +3. 配置`PLATFORM_HEALTH_REPORT_URL`后,脚本使用独立密钥对`Unix时间戳 + "." + 原始JSON正文`计算HMAC-SHA256,发送至`POST /callbacks/v1/platform-health`。 +4. Callbacks校验64KiB正文上限、五分钟签名时间窗、JSON严格字段、15分钟观测时间窗、检查名唯一性及综合状态一致性。 +5. PostgreSQL按正文SHA-256幂等保存快照;完全相同的重放返回原公开编号。数据保留30天。 +6. 具备`ops_viewer`、`ops_operator`、`ops_approver`或`ops_admin`角色的账号通过`GET /api/v1/ops/diagnostics/platform-health`读取最新快照。 +7. 前端超过15分钟未收到新快照时明确显示“上报过期”,不继续把旧的`healthy`当作当前状态。 + +## 3. 数据与安全设计 + +`platform_health_snapshot`保存公开编号、模式版本、综合状态、观测/接收时间、检查数组及正文摘要。检查值限制为256字符,拒绝换行、NUL、重复名称和未知状态。报告不包含数据库连接串、Redis密码、Webhook地址、证书正文、访问令牌、业务记录或个人数据。 + +主机上报密钥与供应商回调密钥、告警投递密钥相互独立: + +- 容器环境:`PLATFORM_HEALTH_CALLBACK_SECRET`或`PLATFORM_HEALTH_CALLBACK_SECRET_FILE`; +- 主机文件:`/etc/data-terminal-cloud/platform-health-callback-secret`,权限`0600 root:root`; +- 主机配置:`PLATFORM_HEALTH_REPORT_SECRET_FILE`指向该文件。 + +密钥至少32字符。生产Kubernetes通过`voice-platform-callbacks-secrets/platform-health-callback-secret`挂载,不把正文写进ConfigMap或镜像。 + +## 4. 接口摘要 + +| 接口 | 身份 | 结果 | +|---|---|---| +| `POST /callbacks/v1/platform-health` | 独立HMAC签名 | 新快照返回201;完全重复返回200 | +| `GET /api/v1/ops/diagnostics/platform-health` | OIDC + 运营角色 + 客户组织选择 | 返回全平台最新脱敏快照 | + +接口契约见`openapi/openapi.yaml` 0.65.0。 + +## 5. 单机配置 + +首次部署由`bootstrap-secrets.sh`生成独立密钥,`install-monitoring.sh`把同一值写入root只读文件并自动设置当前公网回调地址。已有环境升级时需把`deploy/server/.env`中的`PLATFORM_HEALTH_CALLBACK_SECRET`与主机密钥文件保持一致,并在`/etc/data-terminal-cloud/monitor.env`增加: + +```dotenv +PLATFORM_HEALTH_REPORT_URL=https://121.41.46.238/callbacks/v1/platform-health +PLATFORM_HEALTH_REPORT_SECRET_FILE=/etc/data-terminal-cloud/platform-health-callback-secret +``` + +启用后执行: + +```bash +systemctl start platform-health-monitor.service +systemctl show platform-health-monitor.service -p Result -p ExecMainStatus +``` + +验收至少确认:回调返回201/200;数据库迁移版本为39且`dirty=false`;运营查询返回最新观测时间;无签名、旧时间戳、聚合状态不一致均被拒绝;下一周期再次上报后快照时间前进。 diff --git a/docs/server-health-monitoring.md b/docs/server-health-monitoring.md index e03eb47..9470cdd 100644 --- a/docs/server-health-monitoring.md +++ b/docs/server-health-monitoring.md @@ -20,7 +20,7 @@ | 证书 | 本地活动证书剩余时间不少于36小时 | `missing`或`remaining_seconds=...` | | 磁盘 | 低于80%为健康,80%—89%为warning,90%及以上为critical | `used_percent=...` | -状态文件写入`/var/lib/data-terminal-cloud-monitor/status.json`,格式为`platform-server-health-v1`。每个检查只有稳定名称、状态和脱敏值,不包含数据库密码、Webhook地址、证书正文、业务数据或令牌。 +状态文件写入`/var/lib/data-terminal-cloud-monitor/status.json`,格式为`platform-server-health-v1`。每个检查只有稳定名称、状态和脱敏值,不包含数据库密码、Webhook地址、证书正文、业务数据或令牌。配置平台健康上报后,同一状态会每五分钟通过独立HMAC密钥发送到Callbacks并在运营联调诊断页面展示,详细协议见[云端主机健康状态接入开发说明](platform-health-reporting.md)。 ## 3. 状态模型 @@ -65,6 +65,13 @@ ALERT_WEBHOOK_URL=https://receiver.example.com/platform-health ALERT_WEBHOOK_SECRET_FILE=/etc/data-terminal-cloud/monitor-webhook-secret ``` +平台内部状态上报与外部告警分开配置;内部上报每个周期发送,外部告警仍只在综合状态变化时发送: + +```dotenv +PLATFORM_HEALTH_REPORT_URL=https://121.41.46.238/callbacks/v1/platform-health +PLATFORM_HEALTH_REPORT_SECRET_FILE=/etc/data-terminal-cloud/platform-health-callback-secret +``` + 只有总体状态发生变化时才发送JSON状态文件。请求包含: - `Content-Type: application/json`; diff --git a/internal/app/app.go b/internal/app/app.go index 676537e..f77442a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -124,6 +124,12 @@ func Run(ctx context.Context, serviceName string) error { var alertService *alerts.Service var diagnosticService *diagnostics.Service var opsAccessService *opsaccess.Service + if serviceName == "api" || serviceName == "callbacks" { + diagnosticService, err = diagnostics.NewService(databasePool, idempotency.NewExecutor(databasePool), audit.NewRepository()) + if err != nil { + return fmt.Errorf("create diagnostic service: %w", err) + } + } if serviceName == "api" { webEventService = webevents.NewService(databasePool) cursorCodec, cursorErr := pagination.NewCodec([]byte(cfg.CursorSigningSecret)) @@ -143,10 +149,6 @@ func Run(ctx context.Context, serviceName string) error { if err != nil { return fmt.Errorf("create alert operations service: %w", err) } - diagnosticService, err = diagnostics.NewService(databasePool, idempotency.NewExecutor(databasePool), audit.NewRepository()) - if err != nil { - return fmt.Errorf("create diagnostic service: %w", err) - } opsAccessService, err = opsaccess.NewService(databasePool, idempotency.NewExecutor(databasePool), audit.NewRepository(), exportStore, 5*time.Minute) if err != nil { return fmt.Errorf("create temporary sensitive access service: %w", err) @@ -435,6 +437,9 @@ func Run(ctx context.Context, serviceName string) error { ProviderCallbackSecret: []byte(cfg.ProviderCallbackSecret), ProviderCallbackSkew: cfg.ProviderCallbackSkew, ProviderCallbackProcessor: providerEventProcessor, + PlatformHealthCallbackSecret: []byte(cfg.PlatformHealthSecret), + PlatformHealthCallbackSkew: cfg.PlatformHealthSkew, + PlatformHealthProcessor: diagnosticService, }) server := &http.Server{ Addr: cfg.HTTPAddr, diff --git a/internal/app/app_integration_test.go b/internal/app/app_integration_test.go index 4f6690e..6263f20 100644 --- a/internal/app/app_integration_test.go +++ b/internal/app/app_integration_test.go @@ -18,6 +18,7 @@ func TestCallbacksApplicationStartsAndShutsDown(t *testing.T) { t.Setenv("REDIS_ADDR", "localhost:56379") t.Setenv("SHUTDOWN_TIMEOUT", "1s") t.Setenv("DEPENDENCY_TIMEOUT", "1s") + t.Setenv("PLATFORM_HEALTH_CALLBACK_SECRET", strings.Repeat("h", 32)) ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) defer cancel() if err := Run(ctx, "callbacks"); err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index e77d7dc..b4e6fd6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -59,6 +59,8 @@ type Config struct { HeartbeatInterval int ProviderCallbackSecret string ProviderCallbackSkew time.Duration + PlatformHealthSecret string + PlatformHealthSkew time.Duration MockProviderBaseURL string MockProviderAPIKey string VoiceProvider string @@ -160,6 +162,10 @@ func Load(serviceName string) (Config, error) { if err != nil { return Config{}, err } + platformHealthSkew, err := durationEnv("PLATFORM_HEALTH_CALLBACK_CLOCK_SKEW", 5*time.Minute) + if err != nil { + return Config{}, err + } providerRequestTimeout, err := durationEnv("PROVIDER_REQUEST_TIMEOUT", 5*time.Second) if err != nil { return Config{}, err @@ -203,7 +209,7 @@ func Load(serviceName string) (Config, error) { environment := stringEnv("APP_ENV", EnvironmentLocal) secretValues, err := loadSecretValues( "DATABASE_URL", "REDIS_PASSWORD", "DEVICE_LOCAL_SECRET", "RTC_APP_KEY", "RTC_TOKEN_SIGNING_SECRET", - "CURSOR_SIGNING_SECRET", "SHARE_TOKEN_SIGNING_SECRET", "PROVIDER_CALLBACK_SECRET", + "CURSOR_SIGNING_SECRET", "SHARE_TOKEN_SIGNING_SECRET", "PROVIDER_CALLBACK_SECRET", "PLATFORM_HEALTH_CALLBACK_SECRET", "MOCK_PROVIDER_API_KEY", "VOLC_ACCESS_KEY_ID", "VOLC_SECRET_ACCESS_KEY", "VOLC_SESSION_TOKEN", "OBJECT_STORE_ACCESS_KEY", "OBJECT_STORE_SECRET_KEY", "ALERT_WEBHOOK_SECRET", "ARK_API_KEY", "CONTENT_SAFETY_API_KEY", ) @@ -290,6 +296,8 @@ func Load(serviceName string) (Config, error) { AllocationTTL: allocationTTL, ProviderCallbackSecret: providerCallbackSecret, ProviderCallbackSkew: providerCallbackSkew, + PlatformHealthSecret: secretValues["PLATFORM_HEALTH_CALLBACK_SECRET"], + PlatformHealthSkew: platformHealthSkew, MockProviderBaseURL: stringEnv("MOCK_PROVIDER_BASE_URL", "http://localhost:59200"), MockProviderAPIKey: secretDefault(secretValues["MOCK_PROVIDER_API_KEY"], "local-mock-provider-key"), VoiceProvider: stringEnv("VOICE_PROVIDER", "mock"), @@ -457,6 +465,9 @@ func (c Config) Validate() error { if c.ServiceName == "callbacks" && len(c.ProviderCallbackSecret) < 32 { return errors.New("PROVIDER_CALLBACK_SECRET must contain at least 32 characters") } + if c.ServiceName == "callbacks" && len(c.PlatformHealthSecret) < 32 { + return errors.New("PLATFORM_HEALTH_CALLBACK_SECRET must contain at least 32 characters") + } if c.ServiceName == "worker" { if c.VoiceProvider != "mock" && c.VoiceProvider != "volcengine" { return errors.New("VOICE_PROVIDER must be mock or volcengine") @@ -470,7 +481,7 @@ func (c Config) Validate() error { } } } - if c.ProviderCallbackSkew <= 0 || c.ProviderRequestTimeout <= 0 || c.ProviderFailureThreshold < 1 || c.ProviderCircuitCooldown <= 0 { + if c.ProviderCallbackSkew <= 0 || c.PlatformHealthSkew <= 0 || c.ProviderRequestTimeout <= 0 || c.ProviderFailureThreshold < 1 || c.ProviderCircuitCooldown <= 0 { return errors.New("provider callback skew and request timeout must be positive") } if c.AlertWebhookTimeout <= 0 { @@ -578,6 +589,9 @@ func (c Config) validateProduction() error { if c.ServiceName == "callbacks" && isPlaceholderSecret(c.ProviderCallbackSecret) { return errors.New("production PROVIDER_CALLBACK_SECRET must not use a local or example value") } + if c.ServiceName == "callbacks" && isPlaceholderSecret(c.PlatformHealthSecret) { + return errors.New("production PLATFORM_HEALTH_CALLBACK_SECRET must not use a local or example value") + } if c.ServiceName == "worker" { if c.VoiceProvider != "volcengine" || len(c.RTCAppID) != 24 || isLocalValue(c.RTCAppID) || !isHTTPSURL(c.VolcRTCBaseURL) || isLocalValue(c.VolcRTCBaseURL) || isPlaceholderSecret(c.VolcAccessKeyID) || isPlaceholderSecret(c.VolcSecretAccessKey) { return errors.New("production voice provider must use signed Volcengine RTC HTTPS with non-placeholder AK/SK") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index cb42998..4b756a6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -10,6 +10,7 @@ import ( func TestLoadUsesServicePortDefaults(t *testing.T) { t.Setenv("HTTP_ADDR", "") t.Setenv("APP_ENV", "test") + t.Setenv("PLATFORM_HEALTH_CALLBACK_SECRET", strings.Repeat("h", 32)) tests := map[string]string{ "api": "0.0.0.0:58080", diff --git a/internal/diagnostics/platform_health.go b/internal/diagnostics/platform_health.go new file mode 100644 index 0000000..51d1489 --- /dev/null +++ b/internal/diagnostics/platform_health.go @@ -0,0 +1,135 @@ +package diagnostics + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "regexp" + "strings" + "time" + + "data-terminal-cloud/internal/identity" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +const ( + PlatformHealthSchema = "platform-server-health-v1" + platformHealthMaxAge = 15 * time.Minute + platformHealthFuture = 2 * time.Minute +) + +var platformHealthCheckName = regexp.MustCompile(`^[a-z][a-z0-9_.-]{1,79}$`) + +type PlatformHealthCheck struct { + Name string `json:"name"` + Status string `json:"status"` + Value string `json:"value"` +} + +type PlatformHealthInput struct { + Schema string `json:"schema"` + Status string `json:"status"` + ObservedAt time.Time `json:"observed_at"` + Checks []PlatformHealthCheck `json:"checks"` +} + +type PlatformHealthSnapshot struct { + ID string `json:"id"` + Schema string `json:"schema"` + Status string `json:"status"` + ObservedAt time.Time `json:"observed_at"` + ReceivedAt time.Time `json:"received_at"` + Checks []PlatformHealthCheck `json:"checks"` +} + +type PlatformHealthReceipt struct { + ID string `json:"id"` + Status string `json:"status"` + Duplicate bool `json:"duplicate"` +} + +func (s *Service) IngestPlatformHealth(ctx context.Context, input PlatformHealthInput, rawBody []byte, now time.Time) (PlatformHealthReceipt, error) { + if !validPlatformHealth(input, now) || len(rawBody) == 0 || len(rawBody) > 64*1024 { + return PlatformHealthReceipt{}, ErrInvalid + } + checks, err := json.Marshal(input.Checks) + if err != nil { + return PlatformHealthReceipt{}, err + } + digestBytes := sha256.Sum256(rawBody) + digest := hex.EncodeToString(digestBytes[:]) + publicID := uuid.NewString() + var storedID, storedStatus string + err = s.pool.QueryRow(ctx, ` + INSERT INTO platform_health_snapshot(public_id,schema_name,status,observed_at,checks,body_digest) + VALUES($1::uuid,$2,$3,$4,$5::jsonb,$6) + ON CONFLICT(body_digest) DO UPDATE SET body_digest=EXCLUDED.body_digest + RETURNING public_id::text,status`, publicID, input.Schema, input.Status, input.ObservedAt, checks, digest).Scan(&storedID, &storedStatus) + if err != nil { + return PlatformHealthReceipt{}, err + } + _, _ = s.pool.Exec(ctx, `DELETE FROM platform_health_snapshot WHERE received_at < now()-interval '30 days'`) + return PlatformHealthReceipt{ID: storedID, Status: storedStatus, Duplicate: storedID != publicID}, nil +} + +func (s *Service) GetLatestPlatformHealth(ctx context.Context, principal identity.Principal, tenantPublicID string) (PlatformHealthSnapshot, error) { + if _, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_viewer", "ops_operator", "ops_approver", "ops_admin"); err != nil { + return PlatformHealthSnapshot{}, err + } + var item PlatformHealthSnapshot + var checks []byte + err := s.pool.QueryRow(ctx, ` + SELECT public_id::text,schema_name,status,observed_at,received_at,checks + FROM platform_health_snapshot ORDER BY observed_at DESC,id DESC LIMIT 1`).Scan( + &item.ID, &item.Schema, &item.Status, &item.ObservedAt, &item.ReceivedAt, &checks, + ) + if errors.Is(err, pgx.ErrNoRows) { + return PlatformHealthSnapshot{}, ErrNotFound + } + if err != nil { + return PlatformHealthSnapshot{}, err + } + if err = json.Unmarshal(checks, &item.Checks); err != nil { + return PlatformHealthSnapshot{}, err + } + return item, nil +} + +func validPlatformHealth(input PlatformHealthInput, now time.Time) bool { + if input.Schema != PlatformHealthSchema || !platformHealthStatus(input.Status) || input.ObservedAt.IsZero() || + input.ObservedAt.Before(now.Add(-platformHealthMaxAge)) || input.ObservedAt.After(now.Add(platformHealthFuture)) || + len(input.Checks) == 0 || len(input.Checks) > 64 { + return false + } + seen := make(map[string]struct{}, len(input.Checks)) + critical, warning := 0, 0 + for _, check := range input.Checks { + if !platformHealthCheckName.MatchString(check.Name) || !platformHealthStatus(check.Status) || len([]rune(check.Value)) > 256 || strings.ContainsAny(check.Value, "\r\n\x00") { + return false + } + if _, exists := seen[check.Name]; exists { + return false + } + seen[check.Name] = struct{}{} + if check.Status == "critical" { + critical++ + } else if check.Status == "warning" { + warning++ + } + } + expected := "healthy" + if critical > 0 { + expected = "critical" + } else if warning > 0 { + expected = "warning" + } + return input.Status == expected +} + +func platformHealthStatus(value string) bool { + return value == "healthy" || value == "warning" || value == "critical" +} diff --git a/internal/diagnostics/platform_health_test.go b/internal/diagnostics/platform_health_test.go new file mode 100644 index 0000000..6ed7e6d --- /dev/null +++ b/internal/diagnostics/platform_health_test.go @@ -0,0 +1,35 @@ +package diagnostics + +import ( + "testing" + "time" +) + +func TestValidPlatformHealthRequiresConsistentAggregateAndUniqueChecks(t *testing.T) { + now := time.Now().UTC() + valid := PlatformHealthInput{ + Schema: PlatformHealthSchema, Status: "warning", ObservedAt: now, + Checks: []PlatformHealthCheck{ + {Name: "public_https", Status: "healthy", Value: "http_200"}, + {Name: "disk_usage", Status: "warning", Value: "used_percent=81"}, + }, + } + if !validPlatformHealth(valid, now) { + t.Fatal("valid report rejected") + } + inconsistent := valid + inconsistent.Status = "healthy" + if validPlatformHealth(inconsistent, now) { + t.Fatal("inconsistent aggregate accepted") + } + duplicate := valid + duplicate.Checks = append(duplicate.Checks, duplicate.Checks[0]) + if validPlatformHealth(duplicate, now) { + t.Fatal("duplicate check accepted") + } + stale := valid + stale.ObservedAt = now.Add(-16 * time.Minute) + if validPlatformHealth(stale, now) { + t.Fatal("stale report accepted") + } +} diff --git a/internal/httpapi/diagnostic_handlers.go b/internal/httpapi/diagnostic_handlers.go index b381f9a..267ed01 100644 --- a/internal/httpapi/diagnostic_handlers.go +++ b/internal/httpapi/diagnostic_handlers.go @@ -10,6 +10,26 @@ import ( "data-terminal-cloud/internal/idempotency" ) +func GetOpsPlatformHealthHandler(service *diagnostics.Service) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + principal, ok := webPrincipal(w, r) + if !ok { + return + } + tenantID, ok := opsTenantHeader(w, r) + if !ok { + return + } + item, err := service.GetLatestPlatformHealth(r.Context(), principal, tenantID) + if err != nil { + writeDiagnosticError(w, r, err) + return + } + w.Header().Set("Cache-Control", "no-store") + WriteJSON(w, r, http.StatusOK, item) + }) +} + func ListOpsIntegrationIssuesHandler(service *diagnostics.Service) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { principal, ok := webPrincipal(w, r) diff --git a/internal/httpapi/platform_health_callbacks.go b/internal/httpapi/platform_health_callbacks.go new file mode 100644 index 0000000..5e41a8e --- /dev/null +++ b/internal/httpapi/platform_health_callbacks.go @@ -0,0 +1,93 @@ +package httpapi + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "data-terminal-cloud/internal/diagnostics" +) + +const ( + HeaderPlatformTimestamp = "X-Platform-Timestamp" + HeaderPlatformSignature = "X-Platform-Signature" +) + +type PlatformHealthProcessor interface { + IngestPlatformHealth(context.Context, diagnostics.PlatformHealthInput, []byte, time.Time) (diagnostics.PlatformHealthReceipt, error) +} + +func PlatformHealthCallbackHandler(secret []byte, maxSkew time.Duration, processor PlatformHealthProcessor) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 64*1024) + rawBody, err := io.ReadAll(r.Body) + if err != nil { + WriteError(w, r, http.StatusBadRequest, "INVALID_PLATFORM_HEALTH_REPORT", "服务器健康报告无法读取") + return + } + now := time.Now().UTC() + if !verifyPlatformHealthSignature(secret, r.Header.Get(HeaderPlatformTimestamp), r.Header.Get(HeaderPlatformSignature), rawBody, now, maxSkew) { + WriteError(w, r, http.StatusUnauthorized, "PLATFORM_HEALTH_AUTHENTICATION_FAILED", "服务器健康报告身份验证失败") + return + } + decoder := json.NewDecoder(bytes.NewReader(rawBody)) + decoder.DisallowUnknownFields() + var input diagnostics.PlatformHealthInput + if err = decoder.Decode(&input); err != nil || ensureJSONEOF(decoder) != nil { + WriteError(w, r, http.StatusBadRequest, "INVALID_PLATFORM_HEALTH_REPORT", "服务器健康报告字段不正确") + return + } + receipt, err := processor.IngestPlatformHealth(r.Context(), input, rawBody, now) + if err != nil { + if errors.Is(err, diagnostics.ErrInvalid) { + WriteError(w, r, http.StatusBadRequest, "INVALID_PLATFORM_HEALTH_REPORT", "服务器健康报告字段不正确") + return + } + slog.ErrorContext(r.Context(), "platform health callback processing failed", "error", err) + WriteAPIError(w, r, http.StatusServiceUnavailable, Error{Code: "PLATFORM_HEALTH_UNAVAILABLE", Message: "服务器健康报告暂时无法处理", Retryable: true}) + return + } + status := http.StatusCreated + if receipt.Duplicate { + status = http.StatusOK + } + w.Header().Set("Cache-Control", "no-store") + WriteJSON(w, r, status, receipt) + }) +} + +func verifyPlatformHealthSignature(secret []byte, timestamp, signature string, body []byte, now time.Time, maxSkew time.Duration) bool { + if len(secret) < 32 || maxSkew <= 0 || timestamp == "" || !strings.HasPrefix(signature, "sha256=") { + return false + } + epoch, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil || absDuration(now.Sub(time.Unix(epoch, 0))) > maxSkew { + return false + } + provided, err := hex.DecodeString(strings.TrimPrefix(signature, "sha256=")) + if err != nil { + return false + } + mac := hmac.New(sha256.New, secret) + _, _ = mac.Write([]byte(timestamp)) + _, _ = mac.Write([]byte(".")) + _, _ = mac.Write(body) + return hmac.Equal(provided, mac.Sum(nil)) +} + +func absDuration(value time.Duration) time.Duration { + if value < 0 { + return -value + } + return value +} diff --git a/internal/httpapi/platform_health_callbacks_test.go b/internal/httpapi/platform_health_callbacks_test.go new file mode 100644 index 0000000..e79f323 --- /dev/null +++ b/internal/httpapi/platform_health_callbacks_test.go @@ -0,0 +1,78 @@ +package httpapi + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "data-terminal-cloud/internal/diagnostics" +) + +type fakePlatformHealthProcessor struct { + calls int + receipt diagnostics.PlatformHealthReceipt + err error + lastInput diagnostics.PlatformHealthInput +} + +func (p *fakePlatformHealthProcessor) IngestPlatformHealth(_ context.Context, input diagnostics.PlatformHealthInput, _ []byte, _ time.Time) (diagnostics.PlatformHealthReceipt, error) { + p.calls++ + p.lastInput = input + return p.receipt, p.err +} + +func TestPlatformHealthCallbackRequiresSignatureAndAcceptsReport(t *testing.T) { + secret := []byte("01234567890123456789012345678901") + now := time.Now().UTC().Truncate(time.Second) + body, _ := json.Marshal(diagnostics.PlatformHealthInput{ + Schema: diagnostics.PlatformHealthSchema, Status: "healthy", ObservedAt: now, + Checks: []diagnostics.PlatformHealthCheck{{Name: "public_https", Status: "healthy", Value: "http_200"}}, + }) + processor := &fakePlatformHealthProcessor{receipt: diagnostics.PlatformHealthReceipt{ID: "10000000-0000-0000-0000-000000000999", Status: "healthy"}} + handler := PlatformHealthCallbackHandler(secret, time.Minute, processor) + + unsigned := httptest.NewRequest(http.MethodPost, "/callbacks/v1/platform-health", strings.NewReader(string(body))) + unsignedRecorder := httptest.NewRecorder() + handler.ServeHTTP(unsignedRecorder, unsigned) + if unsignedRecorder.Code != http.StatusUnauthorized || processor.calls != 0 { + t.Fatalf("unsigned status=%d calls=%d", unsignedRecorder.Code, processor.calls) + } + + timestamp := strconv.FormatInt(now.Unix(), 10) + signed := httptest.NewRequest(http.MethodPost, "/callbacks/v1/platform-health", strings.NewReader(string(body))) + signed.Header.Set(HeaderPlatformTimestamp, timestamp) + signed.Header.Set(HeaderPlatformSignature, signPlatformHealth(secret, timestamp, body)) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, signed) + if recorder.Code != http.StatusCreated || processor.calls != 1 || processor.lastInput.Schema != diagnostics.PlatformHealthSchema { + t.Fatalf("status=%d calls=%d input=%+v body=%s", recorder.Code, processor.calls, processor.lastInput, recorder.Body.String()) + } +} + +func TestPlatformHealthCallbackRejectsStaleAndMalformedSignature(t *testing.T) { + secret := []byte("01234567890123456789012345678901") + body := []byte(`{"schema":"platform-server-health-v1","status":"healthy","observed_at":"2026-08-26T00:00:00Z","checks":[]}`) + now := time.Now().UTC() + staleTimestamp := strconv.FormatInt(now.Add(-10*time.Minute).Unix(), 10) + if verifyPlatformHealthSignature(secret, staleTimestamp, signPlatformHealth(secret, staleTimestamp, body), body, now, time.Minute) { + t.Fatal("stale signature accepted") + } + if verifyPlatformHealthSignature(secret, strconv.FormatInt(now.Unix(), 10), "sha256=broken", body, now, time.Minute) { + t.Fatal("malformed signature accepted") + } +} + +func signPlatformHealth(secret []byte, timestamp string, body []byte) string { + mac := hmac.New(sha256.New, secret) + _, _ = mac.Write([]byte(timestamp + ".")) + _, _ = mac.Write(body) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +} diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index 79310f1..eccd8d4 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -48,6 +48,9 @@ type RouterOptions struct { ProviderCallbackSecret []byte ProviderCallbackSkew time.Duration ProviderCallbackProcessor ProviderCallbackProcessor + PlatformHealthCallbackSecret []byte + PlatformHealthCallbackSkew time.Duration + PlatformHealthProcessor PlatformHealthProcessor } func NewRouter(options RouterOptions) http.Handler { @@ -117,6 +120,7 @@ func NewRouter(options RouterOptions) http.Handler { mux.Handle("GET /api/v1/ops/cost-summary", authenticated(GetOpsCostSummaryHandler(options.BillingService))) } if options.DiagnosticService != nil { + mux.Handle("GET /api/v1/ops/diagnostics/platform-health", authenticated(GetOpsPlatformHealthHandler(options.DiagnosticService))) mux.Handle("GET /api/v1/ops/integration-issues", authenticated(ListOpsIntegrationIssuesHandler(options.DiagnosticService))) mux.Handle("POST /api/v1/ops/integration-issues", authenticated(CreateOpsIntegrationIssueHandler(options.DiagnosticService))) mux.Handle("PATCH /api/v1/ops/integration-issues/{id}", authenticated(UpdateOpsIntegrationIssueHandler(options.DiagnosticService))) @@ -278,6 +282,11 @@ func NewRouter(options RouterOptions) http.Handler { options.ProviderCallbackSecret, options.ProviderCallbackSkew, options.ProviderCallbackProcessor, )) } + if options.ServiceName == "callbacks" && len(options.PlatformHealthCallbackSecret) > 0 && options.PlatformHealthProcessor != nil { + mux.Handle("POST /callbacks/v1/platform-health", PlatformHealthCallbackHandler( + options.PlatformHealthCallbackSecret, options.PlatformHealthCallbackSkew, options.PlatformHealthProcessor, + )) + } mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) { WriteError(writer, request, http.StatusNotFound, "RESOURCE_NOT_FOUND", "请求的资源不存在") }) diff --git a/migrations/000039_platform_health_snapshot.down.sql b/migrations/000039_platform_health_snapshot.down.sql new file mode 100644 index 0000000..171db32 --- /dev/null +++ b/migrations/000039_platform_health_snapshot.down.sql @@ -0,0 +1,5 @@ +BEGIN; + +DROP TABLE IF EXISTS platform_health_snapshot; + +COMMIT; diff --git a/migrations/000039_platform_health_snapshot.up.sql b/migrations/000039_platform_health_snapshot.up.sql new file mode 100644 index 0000000..d5edd52 --- /dev/null +++ b/migrations/000039_platform_health_snapshot.up.sql @@ -0,0 +1,18 @@ +BEGIN; + +CREATE TABLE platform_health_snapshot ( + id bigserial PRIMARY KEY, + public_id uuid NOT NULL UNIQUE DEFAULT gen_random_uuid(), + schema_name varchar(64) NOT NULL CHECK (schema_name='platform-server-health-v1'), + status varchar(16) NOT NULL CHECK (status IN ('healthy','warning','critical')), + observed_at timestamptz NOT NULL, + received_at timestamptz NOT NULL DEFAULT now(), + checks jsonb NOT NULL, + body_digest char(64) NOT NULL UNIQUE, + CONSTRAINT platform_health_checks_array CHECK (jsonb_typeof(checks)='array') +); + +CREATE INDEX platform_health_snapshot_observed_idx + ON platform_health_snapshot(observed_at DESC, id DESC); + +COMMIT; diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 4244344..80333c2 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: 数据采集终端—云端系统 API - version: 0.64.0 + version: 0.65.0 description: 工程框架、SaaS核心业务、设备v2能力协商与智能体启动状态查询、首次通话就绪与最低固件门禁、背景资料准备失败闭环、活动通话授权撤回安全停止、语音内容闭环、终端终稿补传、批量设备遥测、录音上传队列、隐私对账、固件、双轨录音、状态事件、具体资源分享、受控运营配置、Agent组合组件引用、质量证据门禁、运营自定义版本族投放、新通话运行时冻结与供应商启动传递、带并发版本校验的配置投放暂停恢复、带严重度和验收证据门禁的联调问题闭环、含阻断告警定位证据的发布候选技术验收快照、绑定候选版本与构建摘要并按业务时区生成的可审计试点日/周运行证据、可审计且不生成正式告警的接收器联通测试、外部依赖预检台账、证书到期清单,以及带价格版本冻结、供应商用量去重和硬额度控制的费用治理接口。 servers: - url: http://localhost:58080 @@ -241,6 +241,17 @@ paths: responses: '200': { description: 平台外部依赖预检状态, content: { application/json: { schema: { $ref: '#/components/schemas/OpsExternalDependencyListResponse' } } } } '403': { $ref: '#/components/responses/AccessDeniedError' } + /api/v1/ops/diagnostics/platform-health: + get: + operationId: getOpsPlatformHealth + tags: [operations] + description: 查询云端主机最近一次签名健康快照;仅返回脱敏检查名、状态和短值,不返回密钥、证书正文或业务数据。 + security: [{ oidcBearer: [] }] + parameters: [{ $ref: '#/components/parameters/RequiredTenantSelectionHeader' }] + responses: + '200': { description: 云端主机最新健康快照, content: { application/json: { schema: { $ref: '#/components/schemas/PlatformHealthSnapshotResponse' } } } } + '403': { $ref: '#/components/responses/AccessDeniedError' } + '404': { $ref: '#/components/responses/NotFoundError' } /api/v1/ops/diagnostics/jobs: get: operationId: listOpsDiagnosticJobs @@ -2644,6 +2655,27 @@ paths: content: application/json: schema: { $ref: '#/components/schemas/ErrorResponse' } + /callbacks/v1/platform-health: + post: + operationId: receivePlatformHealthReport + tags: [callback, operations] + description: 接收云端主机五分钟一次的脱敏健康快照;Unix时间戳和原始正文使用独立HMAC-SHA256密钥签名,完全重复的正文幂等返回原记录。 + security: + - platformHealthSignature: [] + parameters: + - $ref: '#/components/parameters/PlatformTimestampHeader' + - $ref: '#/components/parameters/PlatformSignatureHeader' + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/PlatformHealthReport' } + responses: + '201': { description: 新健康快照已保存, content: { application/json: { schema: { $ref: '#/components/schemas/PlatformHealthReceiptResponse' } } } } + '200': { description: 完全重复的健康快照已幂等确认, content: { application/json: { schema: { $ref: '#/components/schemas/PlatformHealthReceiptResponse' } } } } + '400': { $ref: '#/components/responses/ValidationError' } + '401': { description: 签名无效或时间窗已过, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } } + '503': { description: 健康快照暂时无法保存, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } } components: securitySchemes: oidcBearer: @@ -2659,6 +2691,11 @@ components: in: header name: X-Provider-Signature description: 供应商回调正文摘要和时间戳的HMAC-SHA256签名。 + platformHealthSignature: + type: apiKey + in: header + name: X-Platform-Signature + description: 使用独立密钥对Unix时间戳、点号和原始健康报告正文计算的HMAC-SHA256十六进制签名,格式为sha256=摘要。 headers: StrongETag: description: 资源强版本标签,例如"v2"。 @@ -2755,6 +2792,16 @@ components: in: header required: true schema: { type: string } + PlatformTimestampHeader: + name: X-Platform-Timestamp + in: header + required: true + schema: { type: string, pattern: '^[0-9]{10}$' } + PlatformSignatureHeader: + name: X-Platform-Signature + in: header + required: true + schema: { type: string, pattern: '^sha256=[a-f0-9]{64}$' } schemas: CreateOpsConfigVersionRequest: type: object @@ -3086,6 +3133,54 @@ components: properties: request_id: { type: string } data: { $ref: '#/components/schemas/OpsExternalDependencyListData' } + PlatformHealthCheck: + type: object + additionalProperties: false + required: [name, status, value] + properties: + name: { type: string, pattern: '^[a-z][a-z0-9_.-]{1,79}$' } + status: { type: string, enum: [healthy, warning, critical] } + value: { type: string, maxLength: 256 } + PlatformHealthReport: + type: object + additionalProperties: false + required: [schema, status, observed_at, checks] + properties: + schema: { type: string, const: platform-server-health-v1 } + status: { type: string, enum: [healthy, warning, critical] } + observed_at: { type: string, format: date-time } + checks: { type: array, minItems: 1, maxItems: 64, items: { $ref: '#/components/schemas/PlatformHealthCheck' } } + PlatformHealthSnapshot: + type: object + additionalProperties: false + required: [id, schema, status, observed_at, received_at, checks] + properties: + id: { type: string, format: uuid } + schema: { type: string, const: platform-server-health-v1 } + status: { type: string, enum: [healthy, warning, critical] } + observed_at: { type: string, format: date-time } + received_at: { type: string, format: date-time } + checks: { type: array, minItems: 1, maxItems: 64, items: { $ref: '#/components/schemas/PlatformHealthCheck' } } + PlatformHealthSnapshotResponse: + type: object + required: [request_id, data] + properties: + request_id: { type: string } + data: { $ref: '#/components/schemas/PlatformHealthSnapshot' } + PlatformHealthReceipt: + type: object + additionalProperties: false + required: [id, status, duplicate] + properties: + id: { type: string, format: uuid } + status: { type: string, enum: [healthy, warning, critical] } + duplicate: { type: boolean } + PlatformHealthReceiptResponse: + type: object + required: [request_id, data] + properties: + request_id: { type: string } + data: { $ref: '#/components/schemas/PlatformHealthReceipt' } OpsDiagnosticAction: type: object additionalProperties: false diff --git a/scripts/smoke-release-evidence.ps1 b/scripts/smoke-release-evidence.ps1 index 15df243..f4fa8a1 100644 --- a/scripts/smoke-release-evidence.ps1 +++ b/scripts/smoke-release-evidence.ps1 @@ -78,7 +78,7 @@ try { if ($result.status -ne 'EXTERNAL_VALIDATION_PENDING' -or $result.pending -ne 10 -or $result.verified -ne 0 -or $result.sha256 -notmatch '^sha256:[a-f0-9]{64}$') { throw '证据包生成结果不正确' } Expand-Archive -LiteralPath $bundlePath -DestinationPath $expandedPath $manifest = Get-Content -Raw -LiteralPath (Join-Path $expandedPath 'release-manifest.json') | ConvertFrom-Json - if (-not $manifest.technical_gate_satisfied -or $manifest.evidence_status -ne 'EXTERNAL_VALIDATION_PENDING' -or $manifest.pending_count -ne 10 -or $manifest.openapi_version -ne '0.64.0' -or $manifest.schema_version -ne 'release-candidate-evidence-bundle-v3' -or -not $manifest.pilot_evidence_included -or $manifest.pilot_daily_count -ne 7 -or $manifest.pilot_week_start -ne $weekStartText -or $manifest.pilot_timezone -ne 'Asia/Shanghai') { throw '证据包清单内容不正确' } + if (-not $manifest.technical_gate_satisfied -or $manifest.evidence_status -ne 'EXTERNAL_VALIDATION_PENDING' -or $manifest.pending_count -ne 10 -or $manifest.openapi_version -ne '0.65.0' -or $manifest.schema_version -ne 'release-candidate-evidence-bundle-v3' -or -not $manifest.pilot_evidence_included -or $manifest.pilot_daily_count -ne 7 -or $manifest.pilot_week_start -ne $weekStartText -or $manifest.pilot_timezone -ne 'Asia/Shanghai') { throw '证据包清单内容不正确' } if ($manifest.firmware_validation.status -ne 'PENDING' -or $manifest.firmware_validation.coverage_complete -or $manifest.firmware_validation.required_device_count -ne 3 -or @($manifest.firmware_validation.required_scenarios).Count -ne 4 -or @($manifest.firmware_validation.gaps).Count -ne 5) { throw '真机固件场景缺口未在证据包中保留' } if ($manifest.alert_receiver_validation.status -ne 'PENDING' -or $manifest.alert_receiver_validation.coverage_complete -or $manifest.alert_receiver_validation.report_present -or @($manifest.alert_receiver_validation.gaps).Count -ne 2) { throw '真实告警接收器与正式演练缺口未在证据包中保留' } if ($manifest.kms_rotation_validation.status -ne 'PENDING' -or $manifest.kms_rotation_validation.coverage_complete -or $manifest.kms_rotation_validation.report_present -or @($manifest.kms_rotation_validation.gaps).Count -ne 3) { throw '真实KMS/HSM两类轮换缺口未在证据包中保留' } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c53df39..6dc90bb 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -52,6 +52,7 @@ export type AlertIncident = components["schemas"]["AlertIncident"]; export type AlertSilence = components["schemas"]["AlertSilence"]; export type OpsDiagnosticRecord = components["schemas"]["OpsDiagnosticRecord"]; export type OpsExternalDependency = components["schemas"]["OpsExternalDependency"]; +export type PlatformHealthSnapshot = components["schemas"]["PlatformHealthSnapshot"]; export type OpsDiagnosticAction = components["schemas"]["OpsDiagnosticAction"]; export type OpsDiagnosticJob = components["schemas"]["OpsDiagnosticJob"]; export type OpsDiagnosticBundle = components["schemas"]["OpsDiagnosticBundle"]; @@ -206,6 +207,8 @@ export const platformApi = { authenticatedJSON>("GET", `/api/v1/ops/diagnostics/search?field=${encodeURIComponent(field)}&value=${encodeURIComponent(value)}`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID }), opsExternalDependencies: (tenantID: string, accessToken: string, signal?: AbortSignal) => authenticatedJSON>("GET", "/api/v1/ops/diagnostics/external-dependencies", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }), + opsPlatformHealth: (tenantID: string, accessToken: string, signal?: AbortSignal) => + authenticatedJSON>("GET", "/api/v1/ops/diagnostics/platform-health", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }), stopOpsDiagnosticConversation: (tenantID: string, conversationID: string, reason: string, accessToken: string) => authenticatedJSON>("POST", `/api/v1/ops/diagnostics/conversations/${conversationID}/stop`, accessToken, undefined, { reason }, { "X-Tenant-Id": tenantID, "Idempotency-Key": `ops-diagnostic-stop-${crypto.randomUUID()}` }), reconcileOpsDiagnosticConversation: (tenantID: string, conversationID: string, reason: string, accessToken: string) => diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index 4b756ea..c08f0c3 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -189,6 +189,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/ops/diagnostics/platform-health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description 查询云端主机最近一次签名健康快照;仅返回脱敏检查名、状态和短值,不返回密钥、证书正文或业务数据。 */ + get: operations["getOpsPlatformHealth"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/ops/diagnostics/jobs": { parameters: { query?: never; @@ -2145,6 +2162,23 @@ export interface paths { patch?: never; trace?: never; }; + "/callbacks/v1/platform-health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description 接收云端主机五分钟一次的脱敏健康快照;Unix时间戳和原始正文使用独立HMAC-SHA256密钥签名,完全重复的正文幂等返回原记录。 */ + post: operations["receivePlatformHealthReport"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -2535,6 +2569,49 @@ export interface components { request_id: string; data: components["schemas"]["OpsExternalDependencyListData"]; }; + PlatformHealthCheck: { + name: string; + /** @enum {string} */ + status: "healthy" | "warning" | "critical"; + value: string; + }; + PlatformHealthReport: { + /** @constant */ + schema: "platform-server-health-v1"; + /** @enum {string} */ + status: "healthy" | "warning" | "critical"; + /** Format: date-time */ + observed_at: string; + checks: components["schemas"]["PlatformHealthCheck"][]; + }; + PlatformHealthSnapshot: { + /** Format: uuid */ + id: string; + /** @constant */ + schema: "platform-server-health-v1"; + /** @enum {string} */ + status: "healthy" | "warning" | "critical"; + /** Format: date-time */ + observed_at: string; + /** Format: date-time */ + received_at: string; + checks: components["schemas"]["PlatformHealthCheck"][]; + }; + PlatformHealthSnapshotResponse: { + request_id: string; + data: components["schemas"]["PlatformHealthSnapshot"]; + }; + PlatformHealthReceipt: { + /** Format: uuid */ + id: string; + /** @enum {string} */ + status: "healthy" | "warning" | "critical"; + duplicate: boolean; + }; + PlatformHealthReceiptResponse: { + request_id: string; + data: components["schemas"]["PlatformHealthReceipt"]; + }; OpsDiagnosticAction: { /** @enum {string} */ action: "stop_conversation" | "reconcile_conversation" | "refresh_config" | "revoke_device" | "retry_job"; @@ -5585,6 +5662,8 @@ export interface components { OpsAccessGrantHeader: string; ProviderTimestampHeader: string; ProviderSignatureHeader: string; + PlatformTimestampHeader: string; + PlatformSignatureHeader: string; }; requestBodies: never; headers: { @@ -6084,6 +6163,31 @@ export interface operations { 403: components["responses"]["AccessDeniedError"]; }; }; + getOpsPlatformHealth: { + parameters: { + query?: never; + header: { + /** @description 待创建资源所属的公开客户组织编号,不能是数据库内部主键。 */ + "X-Tenant-Id": components["parameters"]["RequiredTenantSelectionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 云端主机最新健康快照 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PlatformHealthSnapshotResponse"]; + }; + }; + 403: components["responses"]["AccessDeniedError"]; + 404: components["responses"]["NotFoundError"]; + }; + }; listOpsDiagnosticJobs: { parameters: { query?: never; @@ -11004,4 +11108,59 @@ export interface operations { }; }; }; + receivePlatformHealthReport: { + parameters: { + query?: never; + header: { + "X-Platform-Timestamp": components["parameters"]["PlatformTimestampHeader"]; + "X-Platform-Signature": components["parameters"]["PlatformSignatureHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PlatformHealthReport"]; + }; + }; + responses: { + /** @description 完全重复的健康快照已幂等确认 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PlatformHealthReceiptResponse"]; + }; + }; + /** @description 新健康快照已保存 */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PlatformHealthReceiptResponse"]; + }; + }; + 400: components["responses"]["ValidationError"]; + /** @description 签名无效或时间窗已过 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description 健康快照暂时无法保存 */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; } diff --git a/web/src/pages/OpsDiagnosticsWorkspace.tsx b/web/src/pages/OpsDiagnosticsWorkspace.tsx index 925d724..c3cabe5 100644 --- a/web/src/pages/OpsDiagnosticsWorkspace.tsx +++ b/web/src/pages/OpsDiagnosticsWorkspace.tsx @@ -1,5 +1,5 @@ import { type FormEvent, useEffect, useState } from "react"; -import { APIRequestError, platformApi, type CurrentUser, type IntegrationIssue, type IntegrationIssueSummary, type OpsAcceptanceSnapshot, type OpsAcceptanceSnapshotRequest, type OpsDiagnosticJob, type OpsDiagnosticRecord, type OpsExternalDependency, type OpsPilotDailySnapshot, type OpsPilotDailySnapshotRequest, type OpsPilotWeeklySnapshot, type OpsPilotWeeklySnapshotRequest, type OpsSensitiveAccessRequest, type OpsSensitiveConversation } from "../api/client"; +import { APIRequestError, platformApi, type CurrentUser, type IntegrationIssue, type IntegrationIssueSummary, type OpsAcceptanceSnapshot, type OpsAcceptanceSnapshotRequest, type OpsDiagnosticJob, type OpsDiagnosticRecord, type OpsExternalDependency, type OpsPilotDailySnapshot, type OpsPilotDailySnapshotRequest, type OpsPilotWeeklySnapshot, type OpsPilotWeeklySnapshotRequest, type OpsSensitiveAccessRequest, type OpsSensitiveConversation, type PlatformHealthSnapshot } from "../api/client"; const fields = [ ["device_id", "设备编号"], ["conversation_id", "通话编号"], ["room_id", "房间编号"], @@ -23,6 +23,7 @@ export function OpsDiagnosticsWorkspace({ accessToken, accounts }: { accessToken const [value,setValue]=useState(""); const [items,setItems]=useState([]); const [jobs,setJobs]=useState([]); const [externalDependencies,setExternalDependencies]=useState([]); + const [platformHealth,setPlatformHealth]=useState(); const [issues,setIssues]=useState([]); const [issueStatus,setIssueStatus]=useState("OPEN"); const [issueSummary,setIssueSummary]=useState({total:0,open:0,critical_open:0,high_open:0,release_blocking:0,release_gate_satisfied:true}); const [issueDraft,setIssueDraft]=useState({phenomenon:"",severity:"MEDIUM" as IntegrationIssue["severity"],responsible_party:"",device_id:"",conversation_id:"",request_id:"",occurred_at:localDateTimeInput()}); @@ -45,12 +46,14 @@ export function OpsDiagnosticsWorkspace({ accessToken, accounts }: { accessToken const canApprove=Boolean(account&&["ops_approver","ops_admin"].includes(account.platform_role)); useEffect(()=>{const controller=new AbortController();void loadExternalDependencies(controller.signal);return()=>controller.abort()},[tenantID,accessToken]); + useEffect(()=>{const controller=new AbortController();void loadPlatformHealth(controller.signal);return()=>controller.abort()},[tenantID,accessToken]); useEffect(()=>{const controller=new AbortController();void loadIssues(controller.signal);return()=>controller.abort()},[tenantID,accessToken]); useEffect(()=>{setPilotDailyDraft((current)=>({...current,candidate_version:acceptanceDraft.candidate_version,build_digest:acceptanceDraft.build_digest}));setPilotWeeklyDraft((current)=>({...current,candidate_version:acceptanceDraft.candidate_version,build_digest:acceptanceDraft.build_digest}))},[acceptanceDraft.candidate_version,acceptanceDraft.build_digest]); if(!opsAccounts.length)return null; async function search(event?:FormEvent){event?.preventDefault();if(!value.trim())return;setDiagnosticControlDraft(undefined);setDiagnosticBundleDraft(undefined);setLoading(true);setError(undefined);setMessage(undefined);try{const result=await platformApi.searchOpsDiagnostics(tenantID,field,value.trim(),accessToken);setItems(result.data.items)}catch(cause){setError(errorText(cause))}finally{setLoading(false)}} async function loadExternalDependencies(signal?:AbortSignal){if(!tenantID)return;setBusy(true);setError(undefined);try{const result=await platformApi.opsExternalDependencies(tenantID,accessToken,signal);setExternalDependencies(result.data.items)}catch(cause){if(!(cause instanceof DOMException&&cause.name==="AbortError"))setError(errorText(cause))}finally{if(!signal?.aborted)setBusy(false)}} + async function loadPlatformHealth(signal?:AbortSignal){if(!tenantID)return;setBusy(true);setError(undefined);try{const result=await platformApi.opsPlatformHealth(tenantID,accessToken,signal);setPlatformHealth(result.data)}catch(cause){if(!(cause instanceof DOMException&&cause.name==="AbortError"))setError(errorText(cause))}finally{if(!signal?.aborted)setBusy(false)}} function openDiagnosticControl(kind:DiagnosticControlKind,item:OpsDiagnosticRecord){setDiagnosticControlDraft({kind,item,reason:"",acknowledged:false});setError(undefined);setMessage(undefined)} async function executeDiagnosticControl(event:FormEvent){ event.preventDefault();const draft=diagnosticControlDraft;if(!draft||!draft.reason.trim()||(diagnosticControlNeedsAcknowledgement(draft.kind)&&!draft.acknowledged))return; @@ -94,10 +97,11 @@ export function OpsDiagnosticsWorkspace({ accessToken, accounts }: { accessToken async function readSensitive(item:OpsSensitiveAccessRequest){setBusy(true);setError(undefined);try{const result=await platformApi.opsSensitiveConversation(tenantID,item.conversation_id,item.id,accessToken);setSensitive(result.data);setMessage("敏感内容已按批准范围读取,本次访问已写入独立审计。") }catch(cause){setError(errorText(cause))}finally{setBusy(false)}} async function playSensitive(segmentID:string){if(!sensitive)return;setBusy(true);setError(undefined);try{const result=await platformApi.createOpsSensitiveRecordingAccess(tenantID,segmentID,sensitive.grant_id,accessToken);window.open(result.data.access_url,"_blank","noopener,noreferrer");setMessage("已签发5分钟录音播放地址,本次访问已审计。") }catch(cause){setError(errorText(cause))}finally{setBusy(false)}} return
-

INTEGRATION DIAGNOSTICS

设备与通话联调

只展示脱敏运行状态,不展示完整短期凭证、字幕正文或原录音。

+

INTEGRATION DIAGNOSTICS

设备与通话联调

只展示脱敏运行状态,不展示完整短期凭证、字幕正文或原录音。

{canOperate?

RELEASE CANDIDATE EVIDENCE

发布候选验收快照

一次冻结候选构建、迁移、必需依赖、联调问题、可靠任务和严重告警;输出仅是技术预检证据,不代替发布批准。

void exportAcceptanceSnapshot(event)}>