From e4f770143934ed5a0fe417c19fe97034ddc5504a Mon Sep 17 00:00:00 2001 From: Codex Local Date: Wed, 26 Aug 2026 22:04:38 +0800 Subject: [PATCH] feat: gate releases on platform health --- docs/development-status.md | 3 +- docs/device-api-compatibility.md | 3 +- docs/platform-health-reporting.md | 2 +- docs/release-evidence-bundle.md | 2 +- docs/requirements-traceability.md | 2 +- internal/diagnostics/acceptance_snapshot.go | 110 +++++++++++++++--- .../acceptance_snapshot_integration_test.go | 5 +- .../diagnostics/acceptance_snapshot_test.go | 47 ++++++++ openapi/openapi.yaml | 26 ++++- scripts/smoke-release-evidence.ps1 | 2 +- web/src/api/schema.d.ts | 18 ++- web/src/pages/OpsDiagnosticsWorkspace.tsx | 2 +- 12 files changed, 189 insertions(+), 33 deletions(-) diff --git a/docs/development-status.md b/docs/development-status.md index 9cc6969..7974b1b 100644 --- a/docs/development-status.md +++ b/docs/development-status.md @@ -1,7 +1,7 @@ # 开发状态与门禁记录 > 更新日期:2026-08-26 -> 当前接口契约版本:0.65.0 +> 当前接口契约版本:0.66.0 > 当前主里程碑:M8目标环境联调、试点与上线验收(外部条件待提供) ## 阶段状态 @@ -250,6 +250,7 @@ | 真实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或数据库失联时必须使用的外部探针。 | +| 云端主机健康发布门禁 | OpenAPI 0.66.0把最新主机健康快照纳入发布候选技术验收:无上报、超过15分钟、综合预警或严重异常均阻断,只有当前有效且22项检查全部正常才通过。快照仅归档状态、时间和检查数量,不复制磁盘、证书或备份明细值。 | | 阶段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 19e1f24..4191aaf 100644 --- a/docs/device-api-compatibility.md +++ b/docs/device-api-compatibility.md @@ -1,7 +1,7 @@ # 设备 API 兼容性约定 > 当前设备接口主版本:`/device/v2` -> 当前接口契约:OpenAPI 0.65.0 +> 当前接口契约:OpenAPI 0.66.0 > 适用对象:云端 API、终端固件、模拟终端和设备联调工具 ## 1. 兼容目标 @@ -44,6 +44,7 @@ CI在拉取请求中以目标分支OpenAPI为基线执行破坏性变更检查 |---|---|---| | 0.64.0 | 运营外部预检增加`content_safety_tls`枚举和候选快照第四类可选必需依赖;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 | | 0.65.0 | 新增主机健康签名回调和运营只读查询;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 | +| 0.66.0 | 发布候选技术验收快照增加主机健康当前性门禁;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 | | 0.63.0 | 增加设备固件阶段事件查询及云端证据字段;设备已有命令回执仅记录同一结果的不可变事件,未增加旧终端必填字段 | v2兼容,旧终端行为不变 | | 0.62.0 | 命令结果增加A/B写入、待重启、启动健康和回退可选字段,省略时保持原有单分区结果语义 | v2兼容,旧终端可继续省略 | | 0.61.0 | 新增家庭侧固件版本影响只读接口,未修改设备协议 | v2完全兼容 | diff --git a/docs/platform-health-reporting.md b/docs/platform-health-reporting.md index f076198..1f9bb3c 100644 --- a/docs/platform-health-reporting.md +++ b/docs/platform-health-reporting.md @@ -35,7 +35,7 @@ | `POST /callbacks/v1/platform-health` | 独立HMAC签名 | 新快照返回201;完全重复返回200 | | `GET /api/v1/ops/diagnostics/platform-health` | OIDC + 运营角色 + 客户组织选择 | 返回全平台最新脱敏快照 | -接口契约见`openapi/openapi.yaml` 0.65.0。 +接口契约见`openapi/openapi.yaml` 0.66.0。最新快照同时进入发布候选技术验收门禁:缺失、超过15分钟、warning或critical都会阻断,只有当前healthy快照通过。 ## 5. 单机配置 diff --git a/docs/release-evidence-bundle.md b/docs/release-evidence-bundle.md index 58b7253..e6c38c3 100644 --- a/docs/release-evidence-bundle.md +++ b/docs/release-evidence-bundle.md @@ -143,4 +143,4 @@ go test ./internal/releaseevidence ./cmd/release-evidence .\scripts\smoke-release-evidence.ps1 ``` -烟测使用真实OIDC生成绑定同一候选版本和API镜像摘要的`PILOT`技术验收快照、上一完整自然周的7份日报和1份周报,构造十项全部`PENDING`的外部索引,生成并解包临时ZIP,核对OpenAPI 0.64.0、v3清单、候选一致性、周期证据、摘要、待验计数和真机固件四场景缺口,随后清理临时文件、审计和幂等记录。该结果只证明打包链路可用,不证明十项外部验收、真机固件或投诉证据已经完成。 +烟测使用真实OIDC生成绑定同一候选版本和API镜像摘要的`PILOT`技术验收快照、上一完整自然周的7份日报和1份周报,构造十项全部`PENDING`的外部索引,生成并解包临时ZIP,核对OpenAPI 0.66.0、v3清单、候选一致性、周期证据、摘要、待验计数和真机固件四场景缺口,随后清理临时文件、审计和幂等记录。技术验收快照还要求主机健康上报在15分钟内且没有warning/critical。该结果只证明打包链路可用,不证明十项外部验收、真机固件或投诉证据已经完成。 diff --git a/docs/requirements-traceability.md b/docs/requirements-traceability.md index ad2f731..7c87daa 100644 --- a/docs/requirements-traceability.md +++ b/docs/requirements-traceability.md @@ -87,7 +87,7 @@ | 来源 | 要求 | 当前处理 | |---|---|---| -| P0414 | 首期需求、验收用例、缺陷和风险形成对应关系 | 本文完成首期功能到代码、接口、数据和验证的基线;V30问题清单由V36补充严重级别、根因、修复版本、回归证据及未关闭HIGH/CRITICAL发布阻断汇总;OpenAPI 0.50.0—0.55.0可按候选构建导出六项技术门禁及脱敏阻断告警定位快照,绑定候选版本/构建摘要后按业务时区归档日/周运行证据。`cmd/release-evidence`现使用v3清单,把同一候选的快照、接口、迁移、部署、SBOM和十类外部状态形成逐文件摘要;远程CI供应链、真机固件、真实终端RTC、真实Agent模型、真实告警网关、云KMS/HSM轮换、生产等价恢复、生产基础设施和Safari兼容分别要求结构化报告,并把14项CI检查/镜像/SBOM、设备固件场景、语音方式与恢复场景、生成/Embedding/内容安全质量、路由演练、轮换模式、恢复类型、基础设施控制项及Safari矩阵覆盖与缺口写入清单。OpenAPI 0.64.0、V38及九个离线校验器把云端状态与远程构建、外部设备、RTC/语音供应商、模型提供器、网关、密钥提供器、目标环境和浏览器证据绑定;`content_safety_tls`把策略服务网络/证书状态纳入指标、告警、运营页面和候选快照。缺检查、缺制品、缺场景、缺演练、缺质量类别、缺控制项、`PENDING`和本地模拟证据均不会被误报为外部验收完成,整体发布仍必须基于真实联调、灰度和审批记录确认。 | +| P0414 | 首期需求、验收用例、缺陷和风险形成对应关系 | 本文完成首期功能到代码、接口、数据和验证的基线;V30问题清单由V36补充严重级别、根因、修复版本、回归证据及未关闭HIGH/CRITICAL发布阻断汇总;OpenAPI 0.50.0—0.66.0可按候选构建导出七项技术门禁及脱敏阻断告警定位快照,绑定候选版本/构建摘要后按业务时区归档日/周运行证据。第七项门禁要求主机健康快照在15分钟内且全部检查正常。`cmd/release-evidence`现使用v3清单,把同一候选的快照、接口、迁移、部署、SBOM和十类外部状态形成逐文件摘要;远程CI供应链、真机固件、真实终端RTC、真实Agent模型、真实告警网关、云KMS/HSM轮换、生产等价恢复、生产基础设施和Safari兼容分别要求结构化报告,并把14项CI检查/镜像/SBOM、设备固件场景、语音方式与恢复场景、生成/Embedding/内容安全质量、路由演练、轮换模式、恢复类型、基础设施控制项及Safari矩阵覆盖与缺口写入清单。V38及九个离线校验器把云端状态与远程构建、外部设备、RTC/语音供应商、模型提供器、网关、密钥提供器、目标环境和浏览器证据绑定;`content_safety_tls`把策略服务网络/证书状态纳入指标、告警、运营页面和候选快照。缺检查、缺制品、缺场景、缺演练、缺质量类别、缺控制项、`PENDING`和本地模拟证据均不会被误报为外部验收完成,整体发布仍必须基于真实联调、灰度和审批记录确认。 | | P0445 | 需求编号关联代码、测试、接口或配置变更 | 本文逐项建立关联;后续功能变更必须同步更新本矩阵和`development-status.md`。 | 当前仍不能宣告完成的部分集中为:真实火山RTC与豆包三模式联调、真实方舟内容与Embedding质量、真实听筒的长者交流体验、量产设备/KMS注入、跨故障域生产对象存储与备份、真机异常恢复、真实账单和生产级内容安全。阿里云单机环境已经形成加密全组件归档、数据库隔离恢复和Redis RDB校验,但同机归档不能替代托管PITR、异地副本和生产恢复演练。这些剩余项属于阶段8外部验收,不应通过增加本地低价值测试伪造完成证据。 diff --git a/internal/diagnostics/acceptance_snapshot.go b/internal/diagnostics/acceptance_snapshot.go index 49ea7b3..5f056c9 100644 --- a/internal/diagnostics/acceptance_snapshot.go +++ b/internal/diagnostics/acceptance_snapshot.go @@ -3,6 +3,8 @@ package diagnostics import ( "context" "encoding/json" + "errors" + "fmt" "regexp" "sort" "strings" @@ -69,6 +71,17 @@ type AcceptanceAlertState struct { BlockingItemsTruncated bool `json:"blocking_items_truncated"` } +type AcceptancePlatformHealthState struct { + State string `json:"state"` + Status string `json:"status,omitempty"` + ObservedAt *time.Time `json:"observed_at,omitempty"` + ReceivedAt *time.Time `json:"received_at,omitempty"` + CheckCount int `json:"check_count"` + WarningCount int `json:"warning_count"` + CriticalCount int `json:"critical_count"` + GateSatisfied bool `json:"gate_satisfied"` +} + type AcceptanceBlockingAlert struct { ID string `json:"id"` RuleCode string `json:"rule_code"` @@ -88,24 +101,25 @@ type AcceptanceGate struct { } type AcceptanceSnapshot struct { - ID string `json:"id"` - GeneratedAt time.Time `json:"generated_at"` - TenantID string `json:"tenant_id"` - CandidateVersion string `json:"candidate_version"` - BuildDigest string `json:"build_digest"` - Environment string `json:"environment"` - RunType string `json:"run_type"` - Scope string `json:"scope"` - RedactionPolicy string `json:"redaction_policy"` - RuntimeBuild buildinfo.Info `json:"runtime_build"` - Database AcceptanceDatabaseState `json:"database"` - IntegrationIssues IntegrationIssueSummary `json:"integration_issues"` - ExternalDependencies []AcceptanceDependencyState `json:"external_dependencies"` - Outbox AcceptanceOutboxState `json:"outbox"` - Alerts AcceptanceAlertState `json:"alerts"` - Gates []AcceptanceGate `json:"gates"` - OverallGateSatisfied bool `json:"overall_gate_satisfied"` - Disclaimer string `json:"disclaimer"` + ID string `json:"id"` + GeneratedAt time.Time `json:"generated_at"` + TenantID string `json:"tenant_id"` + CandidateVersion string `json:"candidate_version"` + BuildDigest string `json:"build_digest"` + Environment string `json:"environment"` + RunType string `json:"run_type"` + Scope string `json:"scope"` + RedactionPolicy string `json:"redaction_policy"` + RuntimeBuild buildinfo.Info `json:"runtime_build"` + Database AcceptanceDatabaseState `json:"database"` + IntegrationIssues IntegrationIssueSummary `json:"integration_issues"` + ExternalDependencies []AcceptanceDependencyState `json:"external_dependencies"` + Outbox AcceptanceOutboxState `json:"outbox"` + Alerts AcceptanceAlertState `json:"alerts"` + PlatformHealth AcceptancePlatformHealthState `json:"platform_health"` + Gates []AcceptanceGate `json:"gates"` + OverallGateSatisfied bool `json:"overall_gate_satisfied"` + Disclaimer string `json:"disclaimer"` } func (s *Service) ExportAcceptanceSnapshot(ctx context.Context, principal identity.Principal, tenantPublicID, key, requestID string, input AcceptanceSnapshotInput) (AcceptanceSnapshot, bool, error) { @@ -275,6 +289,11 @@ func buildAcceptanceSnapshot(ctx context.Context, tx pgx.Tx, tenantPublicID, ten ); err != nil { return AcceptanceSnapshot{}, err } + platformHealth, err := loadAcceptancePlatformHealth(ctx, tx, now) + if err != nil { + return AcceptanceSnapshot{}, err + } + snapshot.PlatformHealth = platformHealth runtimeSatisfied := input.Environment == "LAB" || (snapshot.RuntimeBuild.Version == input.CandidateVersion && snapshot.RuntimeBuild.Commit != "unknown" && snapshot.RuntimeBuild.BuildTime != "unknown") dependenciesSatisfied := true @@ -290,6 +309,7 @@ func buildAcceptanceSnapshot(ctx context.Context, tx pgx.Tx, tenantPublicID, ten {Code: "integration_issues_clear", Satisfied: snapshot.IntegrationIssues.ReleaseGateSatisfied, Summary: "不得存在未关闭的高或严重级联调问题"}, {Code: "outbox_clear", Satisfied: outboxSatisfied, Summary: "租户及平台级待处理、处理中和死信任务必须清零"}, {Code: "critical_alerts_clear", Satisfied: alertsSatisfied, Summary: "不得存在未解决的高或严重级告警"}, + {Code: "platform_health_current", Satisfied: snapshot.PlatformHealth.GateSatisfied, Summary: acceptancePlatformHealthSummary(snapshot.PlatformHealth)}, } snapshot.OverallGateSatisfied = true for _, gate := range snapshot.Gates { @@ -298,6 +318,60 @@ func buildAcceptanceSnapshot(ctx context.Context, tx pgx.Tx, tenantPublicID, ten return snapshot, nil } +func loadAcceptancePlatformHealth(ctx context.Context, tx pgx.Tx, now time.Time) (AcceptancePlatformHealthState, error) { + state := AcceptancePlatformHealthState{State: "MISSING"} + var observedAt, receivedAt time.Time + var checksJSON []byte + err := tx.QueryRow(ctx, ` + SELECT status,observed_at,received_at,checks + FROM platform_health_snapshot ORDER BY observed_at DESC,id DESC LIMIT 1`).Scan( + &state.Status, &observedAt, &receivedAt, &checksJSON, + ) + if errors.Is(err, pgx.ErrNoRows) { + return state, nil + } + if err != nil { + return AcceptancePlatformHealthState{}, err + } + var checks []PlatformHealthCheck + if err = json.Unmarshal(checksJSON, &checks); err != nil { + return AcceptancePlatformHealthState{}, err + } + return evaluateAcceptancePlatformHealth(state.Status, observedAt, receivedAt, checks, now), nil +} + +func evaluateAcceptancePlatformHealth(status string, observedAt, receivedAt time.Time, checks []PlatformHealthCheck, now time.Time) AcceptancePlatformHealthState { + state := AcceptancePlatformHealthState{ + State: "CURRENT", Status: status, ObservedAt: &observedAt, ReceivedAt: &receivedAt, CheckCount: len(checks), + } + for _, check := range checks { + switch check.Status { + case "warning": + state.WarningCount++ + case "critical": + state.CriticalCount++ + } + } + if observedAt.Before(now.Add(-platformHealthMaxAge)) || observedAt.After(now.Add(platformHealthFuture)) { + state.State = "STALE" + } + state.GateSatisfied = state.State == "CURRENT" && state.Status == "healthy" && state.WarningCount == 0 && state.CriticalCount == 0 + return state +} + +func acceptancePlatformHealthSummary(state AcceptancePlatformHealthState) string { + switch state.State { + case "MISSING": + return "尚未收到云端主机健康快照" + case "STALE": + return "云端主机健康快照必须在15分钟内更新" + } + if state.Status != "healthy" || state.WarningCount > 0 || state.CriticalCount > 0 { + return fmt.Sprintf("云端主机存在%d项预警和%d项严重异常", state.WarningCount, state.CriticalCount) + } + return fmt.Sprintf("云端主机健康快照当前有效,共%d项检查", state.CheckCount) +} + func acceptanceRuntimeSummary(environment string) string { if environment == "LAB" { return "实验室环境允许开发构建,但候选版本与镜像摘要必须明确" diff --git a/internal/diagnostics/acceptance_snapshot_integration_test.go b/internal/diagnostics/acceptance_snapshot_integration_test.go index c55d218..ed2f439 100644 --- a/internal/diagnostics/acceptance_snapshot_integration_test.go +++ b/internal/diagnostics/acceptance_snapshot_integration_test.go @@ -59,7 +59,7 @@ func TestAcceptanceSnapshotIsAuditedIdempotentAndTenantScoped(t *testing.T) { if err != nil || !created { t.Fatalf("first=%+v created=%t err=%v", first, created, err) } - if first.ID == "" || first.Database.MigrationVersion < 36 || first.Database.Dirty || first.RedactionPolicy != "ops-acceptance-minimal-v1" || len(first.ExternalDependencies) != 1 || len(first.Gates) != 6 { + if first.ID == "" || first.Database.MigrationVersion < 39 || first.Database.Dirty || first.RedactionPolicy != "ops-acceptance-minimal-v1" || len(first.ExternalDependencies) != 1 || len(first.Gates) != 7 { t.Fatalf("unexpected snapshot=%+v", first) } if first.ExternalDependencies[0].Dependency != "oidc_jwks" || first.ExternalDependencies[0].ServiceName != "api" { @@ -74,6 +74,9 @@ func TestAcceptanceSnapshotIsAuditedIdempotentAndTenantScoped(t *testing.T) { if !foundBlockingAlert || first.Alerts.Blocking == 0 || first.Gates[5].Code != "critical_alerts_clear" || first.Gates[5].Satisfied { t.Fatalf("blocking alert evidence=%+v gates=%+v", first.Alerts, first.Gates) } + if first.Gates[6].Code != "platform_health_current" || first.PlatformHealth.State == "" { + t.Fatalf("platform health=%+v gates=%+v", first.PlatformHealth, first.Gates) + } replayed, createdAgain, err := service.ExportAcceptanceSnapshot(ctx, admin, tenantPublicID, key, requestID, input) if err != nil || createdAgain || replayed.ID != first.ID || !replayed.GeneratedAt.Equal(first.GeneratedAt) { t.Fatalf("replayed=%+v created=%t err=%v", replayed, createdAgain, err) diff --git a/internal/diagnostics/acceptance_snapshot_test.go b/internal/diagnostics/acceptance_snapshot_test.go index 99be27b..2470a79 100644 --- a/internal/diagnostics/acceptance_snapshot_test.go +++ b/internal/diagnostics/acceptance_snapshot_test.go @@ -3,6 +3,7 @@ package diagnostics import ( "strings" "testing" + "time" ) func TestValidAcceptanceSnapshotInputLimitsAuditReason(t *testing.T) { @@ -24,6 +25,52 @@ func TestValidAcceptanceSnapshotInputLimitsAuditReason(t *testing.T) { } } +func TestEvaluateAcceptancePlatformHealthBlocksStaleAndUnhealthyReports(t *testing.T) { + now := time.Date(2026, 8, 26, 14, 0, 0, 0, time.UTC) + tests := []struct { + name string + status string + observedAt time.Time + checks []PlatformHealthCheck + state string + satisfied bool + }{ + { + name: "current healthy", status: "healthy", observedAt: now.Add(-5 * time.Minute), + checks: []PlatformHealthCheck{{Name: "public_https", Status: "healthy", Value: "http_200"}}, state: "CURRENT", satisfied: true, + }, + { + name: "stale healthy", status: "healthy", observedAt: now.Add(-16 * time.Minute), + checks: []PlatformHealthCheck{{Name: "public_https", Status: "healthy", Value: "http_200"}}, state: "STALE", satisfied: false, + }, + { + name: "current warning", status: "warning", observedAt: now.Add(-5 * time.Minute), + checks: []PlatformHealthCheck{{Name: "backup", Status: "warning", Value: "late"}}, state: "CURRENT", satisfied: false, + }, + { + name: "current critical", status: "critical", observedAt: now.Add(-5 * time.Minute), + checks: []PlatformHealthCheck{{Name: "disk", Status: "critical", Value: "full"}}, state: "CURRENT", satisfied: false, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + state := evaluateAcceptancePlatformHealth(test.status, test.observedAt, now, test.checks, now) + if state.State != test.state || state.GateSatisfied != test.satisfied { + t.Fatalf("state=%+v", state) + } + }) + } +} + +func TestAcceptancePlatformHealthSummaryCoversMissingAndCounts(t *testing.T) { + if summary := acceptancePlatformHealthSummary(AcceptancePlatformHealthState{State: "MISSING"}); !strings.Contains(summary, "尚未收到") { + t.Fatalf("missing summary=%q", summary) + } + if summary := acceptancePlatformHealthSummary(AcceptancePlatformHealthState{State: "CURRENT", Status: "warning", WarningCount: 2, CriticalCount: 1}); !strings.Contains(summary, "2项预警") || !strings.Contains(summary, "1项严重异常") { + t.Fatalf("unhealthy summary=%q", summary) + } +} + func TestAcceptanceSnapshotAllowsAllProductionExternalDependencies(t *testing.T) { input := AcceptanceSnapshotInput{ CandidateVersion: "0.64.0-rc.1", BuildDigest: "sha256:" + strings.Repeat("b", 64), diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 80333c2..1d5bfd7 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -1,8 +1,8 @@ openapi: 3.1.0 info: title: 数据采集终端—云端系统 API - version: 0.65.0 - description: 工程框架、SaaS核心业务、设备v2能力协商与智能体启动状态查询、首次通话就绪与最低固件门禁、背景资料准备失败闭环、活动通话授权撤回安全停止、语音内容闭环、终端终稿补传、批量设备遥测、录音上传队列、隐私对账、固件、双轨录音、状态事件、具体资源分享、受控运营配置、Agent组合组件引用、质量证据门禁、运营自定义版本族投放、新通话运行时冻结与供应商启动传递、带并发版本校验的配置投放暂停恢复、带严重度和验收证据门禁的联调问题闭环、含阻断告警定位证据的发布候选技术验收快照、绑定候选版本与构建摘要并按业务时区生成的可审计试点日/周运行证据、可审计且不生成正式告警的接收器联通测试、外部依赖预检台账、证书到期清单,以及带价格版本冻结、供应商用量去重和硬额度控制的费用治理接口。 + version: 0.66.0 + description: 工程框架、SaaS核心业务、设备v2能力协商与智能体启动状态查询、首次通话就绪与最低固件门禁、背景资料准备失败闭环、活动通话授权撤回安全停止、语音内容闭环、终端终稿补传、批量设备遥测、录音上传队列、隐私对账、固件、双轨录音、状态事件、具体资源分享、受控运营配置、Agent组合组件引用、质量证据门禁、运营自定义版本族投放、新通话运行时冻结与供应商启动传递、带并发版本校验的配置投放暂停恢复、带严重度和验收证据门禁的联调问题闭环、含阻断告警定位及当前主机健康证据的发布候选技术验收快照、绑定候选版本与构建摘要并按业务时区生成的可审计试点日/周运行证据、可审计且不生成正式告警的接收器联通测试、外部依赖预检台账、证书到期清单,以及带价格版本冻结、供应商用量去重和硬额度控制的费用治理接口。 servers: - url: http://localhost:58080 description: 本地开发 @@ -3271,6 +3271,19 @@ components: properties: migration_version: { type: integer, format: int64, minimum: 0 } dirty: { type: boolean } + OpsAcceptancePlatformHealthState: + type: object + additionalProperties: false + required: [state, check_count, warning_count, critical_count, gate_satisfied] + properties: + state: { type: string, enum: [CURRENT, STALE, MISSING] } + status: { type: string, enum: [healthy, warning, critical] } + observed_at: { type: string, format: date-time } + received_at: { type: string, format: date-time } + check_count: { type: integer, minimum: 0, maximum: 64 } + warning_count: { type: integer, minimum: 0, maximum: 64 } + critical_count: { type: integer, minimum: 0, maximum: 64 } + gate_satisfied: { type: boolean, description: 仅当快照未超过15分钟且综合状态及全部检查均为healthy时为true。 } OpsAcceptanceDependencyState: type: object additionalProperties: false @@ -3325,13 +3338,13 @@ components: additionalProperties: false required: [code, satisfied, summary] properties: - code: { type: string, enum: [candidate_build_identified, database_migration_clean, required_dependencies_available, integration_issues_clear, outbox_clear, critical_alerts_clear] } + code: { type: string, enum: [candidate_build_identified, database_migration_clean, required_dependencies_available, integration_issues_clear, outbox_clear, critical_alerts_clear, platform_health_current] } satisfied: { type: boolean } summary: { type: string } OpsAcceptanceSnapshot: type: object additionalProperties: false - required: [id, generated_at, tenant_id, candidate_version, build_digest, environment, run_type, scope, redaction_policy, runtime_build, database, integration_issues, external_dependencies, outbox, alerts, gates, overall_gate_satisfied, disclaimer] + required: [id, generated_at, tenant_id, candidate_version, build_digest, environment, run_type, scope, redaction_policy, runtime_build, database, integration_issues, external_dependencies, outbox, alerts, platform_health, gates, overall_gate_satisfied, disclaimer] properties: id: { type: string, format: uuid } generated_at: { type: string, format: date-time } @@ -3345,10 +3358,11 @@ components: runtime_build: { $ref: '#/components/schemas/BuildInfo' } database: { $ref: '#/components/schemas/OpsAcceptanceDatabaseState' } integration_issues: { $ref: '#/components/schemas/IntegrationIssueSummary' } - external_dependencies: { type: array, minItems: 1, maxItems: 3, items: { $ref: '#/components/schemas/OpsAcceptanceDependencyState' } } + external_dependencies: { type: array, minItems: 1, maxItems: 4, items: { $ref: '#/components/schemas/OpsAcceptanceDependencyState' } } outbox: { $ref: '#/components/schemas/OpsAcceptanceOutboxState' } alerts: { $ref: '#/components/schemas/OpsAcceptanceAlertState' } - gates: { type: array, minItems: 6, maxItems: 6, items: { $ref: '#/components/schemas/OpsAcceptanceGate' } } + platform_health: { $ref: '#/components/schemas/OpsAcceptancePlatformHealthState' } + gates: { type: array, minItems: 7, maxItems: 7, items: { $ref: '#/components/schemas/OpsAcceptanceGate' } } overall_gate_satisfied: { type: boolean } disclaimer: { type: string } OpsAcceptanceSnapshotResponse: diff --git a/scripts/smoke-release-evidence.ps1 b/scripts/smoke-release-evidence.ps1 index f4fa8a1..073305a 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.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 (-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.66.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/schema.d.ts b/web/src/api/schema.d.ts index c08f0c3..7e6bfeb 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -2694,6 +2694,21 @@ export interface components { migration_version: number; dirty: boolean; }; + OpsAcceptancePlatformHealthState: { + /** @enum {string} */ + state: "CURRENT" | "STALE" | "MISSING"; + /** @enum {string} */ + status?: "healthy" | "warning" | "critical"; + /** Format: date-time */ + observed_at?: string; + /** Format: date-time */ + received_at?: string; + check_count: number; + warning_count: number; + critical_count: number; + /** @description 仅当快照未超过15分钟且综合状态及全部检查均为healthy时为true。 */ + gate_satisfied: boolean; + }; OpsAcceptanceDependencyState: { /** @enum {string} */ service_name: "api" | "worker"; @@ -2758,7 +2773,7 @@ export interface components { }; OpsAcceptanceGate: { /** @enum {string} */ - code: "candidate_build_identified" | "database_migration_clean" | "required_dependencies_available" | "integration_issues_clear" | "outbox_clear" | "critical_alerts_clear"; + code: "candidate_build_identified" | "database_migration_clean" | "required_dependencies_available" | "integration_issues_clear" | "outbox_clear" | "critical_alerts_clear" | "platform_health_current"; satisfied: boolean; summary: string; }; @@ -2784,6 +2799,7 @@ export interface components { external_dependencies: components["schemas"]["OpsAcceptanceDependencyState"][]; outbox: components["schemas"]["OpsAcceptanceOutboxState"]; alerts: components["schemas"]["OpsAcceptanceAlertState"]; + platform_health: components["schemas"]["OpsAcceptancePlatformHealthState"]; gates: components["schemas"]["OpsAcceptanceGate"][]; overall_gate_satisfied: boolean; disclaimer: string; diff --git a/web/src/pages/OpsDiagnosticsWorkspace.tsx b/web/src/pages/OpsDiagnosticsWorkspace.tsx index c3cabe5..8722266 100644 --- a/web/src/pages/OpsDiagnosticsWorkspace.tsx +++ b/web/src/pages/OpsDiagnosticsWorkspace.tsx @@ -152,5 +152,5 @@ function platformHealthCheckStatusLabel(value:PlatformHealthSnapshot["checks"][n function platformHealthCheckLabel(value:string){const labels:Record={public_origin:"公网地址",public_https:"HTTPS入口",oidc_issuer:"身份签发地址",backup_freshness:"本地备份",offsite_backup:"异地备份",backup_service:"备份服务","platform-backup.timer":"备份定时器","platform-offsite-backup.timer":"异地备份定时器","certbot-ip-renew.timer":"证书续期定时器",certificate:"HTTPS证书",disk_usage:"磁盘空间",container_postgres:"PostgreSQL",container_redis:"Redis",container_minio:"对象存储",container_keycloak:"身份服务",container_api:"业务API",container_callbacks:"回调服务",container_worker:"后台任务",container_web:"前端页面",container_gateway:"HTTPS网关","container_mock-provider":"模拟语音提供器","container_mock-alert-receiver":"模拟告警接收器"};return labels[value]??value} function platformHealthValueLabel(_name:string,value:string){if(value==="not_required")return "当前未要求启用";if(value==="issuer_matches")return "签发地址一致";if(value==="http_200")return "公网访问成功";if(value==="enabled/active")return "已启用并运行";if(value==="success")return "最近执行成功";if(value.startsWith("age_seconds=")){const seconds=Number(value.slice(12));return Number.isFinite(seconds)?`距最近成功 ${formatDuration(seconds*1000)}`:value}if(value.startsWith("remaining_seconds=")){const seconds=Number(value.slice(18));return Number.isFinite(seconds)?`剩余 ${Math.floor(seconds/86400)} 天` : value}if(value.startsWith("used_percent="))return `已使用 ${value.slice(13)}%`;if(value==="running/healthy"||value==="running/none")return "容器正在运行";return value} function downloadJSON(value:unknown,filename:string){const blob=new Blob([JSON.stringify(value,null,2)],{type:"application/json"});const url=URL.createObjectURL(blob);const anchor=document.createElement("a");anchor.href=url;anchor.download=filename;anchor.click();URL.revokeObjectURL(url)} -function acceptanceGateLabel(value:OpsAcceptanceSnapshot["gates"][number]["code"]){return ({candidate_build_identified:"候选构建",database_migration_clean:"数据库迁移",required_dependencies_available:"必需外部依赖",integration_issues_clear:"高严重度问题",outbox_clear:"可靠任务",critical_alerts_clear:"高严重度告警"} as Record)[value]} +function acceptanceGateLabel(value:OpsAcceptanceSnapshot["gates"][number]["code"]){return ({candidate_build_identified:"候选构建",database_migration_clean:"数据库迁移",required_dependencies_available:"必需外部依赖",integration_issues_clear:"高严重度问题",outbox_clear:"可靠任务",critical_alerts_clear:"高严重度告警",platform_health_current:"云端主机健康"} as Record)[value]} function acceptanceResourceStateLabel(value:OpsAcceptanceSnapshot["alerts"]["blocking_items"][number]["resource_state"]){return ({PRESENT:"资源存在",MISSING:"资源已不存在,仍需人工复核关闭",AGGREGATE:"聚合队列",UNKNOWN:"未提供自动核对规则"} as Record)[value]}