Files
story/internal/diagnostics/acceptance_snapshot.go
T

461 lines
22 KiB
Go
Raw Normal View History

2026-08-26 06:41:07 +08:00
package diagnostics
import (
"context"
"encoding/json"
2026-08-26 22:04:38 +08:00
"errors"
"fmt"
2026-08-26 06:41:07 +08:00
"regexp"
"sort"
"strings"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/buildinfo"
"data-terminal-cloud/internal/idempotency"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
var (
acceptanceCandidatePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
acceptanceDigestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
acceptanceEnvironments = map[string]bool{"LAB": true, "STAGING": true, "PRODUCTION": true}
acceptanceRunTypes = map[string]bool{"REAL_DEVICE_PROVIDER": true, "PILOT": true, "PRODUCTION_RELEASE": true}
acceptanceDependencies = map[string]string{"oidc_jwks": "api", "ark_tls": "worker", "volc_rtc_tls": "worker", "content_safety_tls": "worker"}
)
type AcceptanceSnapshotInput struct {
CandidateVersion string `json:"candidate_version"`
BuildDigest string `json:"build_digest"`
Environment string `json:"environment"`
RunType string `json:"run_type"`
Scope string `json:"scope"`
RequiredDependencies []string `json:"required_dependencies"`
Reason string `json:"reason"`
}
type AcceptanceDatabaseState struct {
MigrationVersion int64 `json:"migration_version"`
Dirty bool `json:"dirty"`
}
type AcceptanceDependencyState struct {
ServiceName string `json:"service_name"`
Dependency string `json:"dependency"`
Status string `json:"status"`
CheckedAt *time.Time `json:"checked_at,omitempty"`
LastSuccessAt *time.Time `json:"last_success_at,omitempty"`
LastFailureAt *time.Time `json:"last_failure_at,omitempty"`
LastErrorCode string `json:"last_error_code,omitempty"`
LatencyMS int64 `json:"latency_ms"`
ProbeIntervalMS int64 `json:"probe_interval_ms"`
ConsecutiveFailures int `json:"consecutive_failures"`
Fresh bool `json:"fresh"`
GateSatisfied bool `json:"gate_satisfied"`
}
type AcceptanceOutboxState struct {
Pending int64 `json:"pending"`
Processing int64 `json:"processing"`
Dead int64 `json:"dead"`
}
type AcceptanceAlertState struct {
CriticalOpen int64 `json:"critical_open"`
HighOpen int64 `json:"high_open"`
Blocking int64 `json:"blocking"`
BlockingItems []AcceptanceBlockingAlert `json:"blocking_items"`
BlockingItemsTruncated bool `json:"blocking_items_truncated"`
}
2026-08-26 22:04:38 +08:00
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 AcceptanceProductionInfrastructureState struct {
Applicable bool `json:"applicable"`
DeploymentProfile string `json:"deployment_profile"`
2026-08-26 23:30:59 +08:00
ApplicationEnvironment string `json:"application_environment"`
IdentityServiceMode string `json:"identity_service_mode"`
ApplicationRuntimeReady bool `json:"application_runtime_ready"`
IdentityServiceReady bool `json:"identity_service_ready"`
OffsiteBackupReady bool `json:"offsite_backup_ready"`
ExternalAlertReceiverReady bool `json:"external_alert_receiver_ready"`
MockServicesDisabled bool `json:"mock_services_disabled"`
GateSatisfied bool `json:"gate_satisfied"`
}
2026-08-26 06:41:07 +08:00
type AcceptanceBlockingAlert struct {
ID string `json:"id"`
RuleCode string `json:"rule_code"`
Severity string `json:"severity"`
Status string `json:"status"`
ResourceType string `json:"resource_type,omitempty"`
ResourceID string `json:"resource_id,omitempty"`
ResourceState string `json:"resource_state"`
FirstDetectedAt time.Time `json:"first_detected_at"`
LastDetectedAt time.Time `json:"last_detected_at"`
}
type AcceptanceGate struct {
Code string `json:"code"`
Satisfied bool `json:"satisfied"`
Summary string `json:"summary"`
}
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"`
PlatformHealth AcceptancePlatformHealthState `json:"platform_health"`
ProductionInfrastructure AcceptanceProductionInfrastructureState `json:"production_infrastructure"`
Gates []AcceptanceGate `json:"gates"`
OverallGateSatisfied bool `json:"overall_gate_satisfied"`
Disclaimer string `json:"disclaimer"`
2026-08-26 06:41:07 +08:00
}
func (s *Service) ExportAcceptanceSnapshot(ctx context.Context, principal identity.Principal, tenantPublicID, key, requestID string, input AcceptanceSnapshotInput) (AcceptanceSnapshot, bool, error) {
normalizeAcceptanceSnapshotInput(&input)
if !validAcceptanceSnapshotInput(input) {
return AcceptanceSnapshot{}, false, ErrInvalid
}
a, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_operator", "ops_admin")
if err != nil {
return AcceptanceSnapshot{}, false, err
}
requestBody, _ := json.Marshal(input)
result, err := s.idem.Execute(ctx, idempotency.Request{
TenantID: &a.tenantID, ActorKey: "ops:" + a.userPublicID,
Scope: "POST /api/v1/ops/diagnostics/acceptance-snapshots", Key: key,
RequestHash: idempotency.Hash(requestBody), ExpiresAfter: 24 * time.Hour,
}, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
snapshot, snapshotErr := buildAcceptanceSnapshot(operationContext, tx, tenantPublicID, a.tenantID, input)
if snapshotErr != nil {
return idempotency.Result{}, snapshotErr
}
if auditErr := s.audit.Record(operationContext, tx, audit.Event{
TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID,
Action: "ops.diagnostics.acceptance_snapshot.export", ResourceType: "acceptance_snapshot", ResourceID: &snapshot.ID,
RequestID: requestID, Purpose: input.Reason, Result: "success",
Detail: map[string]any{
"candidate_version": input.CandidateVersion, "build_digest": input.BuildDigest,
"environment": input.Environment, "run_type": input.RunType, "scope": input.Scope,
"required_dependencies": input.RequiredDependencies, "overall_gate_satisfied": snapshot.OverallGateSatisfied,
"redaction_policy": snapshot.RedactionPolicy,
},
}); auditErr != nil {
return idempotency.Result{}, auditErr
}
encoded, _ := json.Marshal(snapshot)
return idempotency.Result{StatusCode: 201, Body: encoded, ResourceID: &snapshot.ID}, nil
})
if err != nil {
return AcceptanceSnapshot{}, false, err
}
var snapshot AcceptanceSnapshot
if err = json.Unmarshal(result.Body, &snapshot); err != nil {
return AcceptanceSnapshot{}, false, err
}
return snapshot, !result.Replayed, nil
}
func normalizeAcceptanceSnapshotInput(input *AcceptanceSnapshotInput) {
input.CandidateVersion = strings.TrimSpace(input.CandidateVersion)
input.BuildDigest = strings.ToLower(strings.TrimSpace(input.BuildDigest))
input.Environment = strings.ToUpper(strings.TrimSpace(input.Environment))
input.RunType = strings.ToUpper(strings.TrimSpace(input.RunType))
input.Scope = strings.TrimSpace(input.Scope)
input.Reason = strings.TrimSpace(input.Reason)
for index := range input.RequiredDependencies {
input.RequiredDependencies[index] = strings.ToLower(strings.TrimSpace(input.RequiredDependencies[index]))
}
sort.Strings(input.RequiredDependencies)
}
func validAcceptanceSnapshotInput(input AcceptanceSnapshotInput) bool {
if !acceptanceCandidatePattern.MatchString(input.CandidateVersion) || !acceptanceDigestPattern.MatchString(input.BuildDigest) ||
!acceptanceEnvironments[input.Environment] || !acceptanceRunTypes[input.RunType] ||
input.Scope == "" || len([]rune(input.Scope)) > 500 || input.Reason == "" || len([]rune(input.Reason)) > 255 ||
len(input.RequiredDependencies) == 0 || len(input.RequiredDependencies) > len(acceptanceDependencies) {
return false
}
seen := map[string]bool{}
for _, dependency := range input.RequiredDependencies {
if _, allowed := acceptanceDependencies[dependency]; !allowed || seen[dependency] {
return false
}
seen[dependency] = true
}
return true
}
func buildAcceptanceSnapshot(ctx context.Context, tx pgx.Tx, tenantPublicID, tenantInternalID string, input AcceptanceSnapshotInput) (AcceptanceSnapshot, error) {
now := time.Now().UTC()
snapshot := AcceptanceSnapshot{
ID: uuid.NewString(), GeneratedAt: now, TenantID: tenantPublicID,
CandidateVersion: input.CandidateVersion, BuildDigest: input.BuildDigest, Environment: input.Environment,
RunType: input.RunType, Scope: input.Scope, RedactionPolicy: "ops-acceptance-minimal-v1",
RuntimeBuild: buildinfo.Current(), Alerts: AcceptanceAlertState{BlockingItems: []AcceptanceBlockingAlert{}},
Disclaimer: "本快照仅证明生成时刻的技术预检状态,不替代真实设备、真实供应商、灰度观察或发布审批结论。",
}
if err := tx.QueryRow(ctx, `SELECT version,dirty FROM schema_migrations ORDER BY version DESC LIMIT 1`).Scan(&snapshot.Database.MigrationVersion, &snapshot.Database.Dirty); err != nil {
return AcceptanceSnapshot{}, err
}
if err := tx.QueryRow(ctx, `
SELECT count(*),
count(*) FILTER (WHERE status <> 'CLOSED'),
count(*) FILTER (WHERE severity='CRITICAL' AND status <> 'CLOSED'),
count(*) FILTER (WHERE severity='HIGH' AND status <> 'CLOSED'),
count(*) FILTER (WHERE severity IN ('CRITICAL','HIGH') AND status <> 'CLOSED')
FROM integration_issue WHERE tenant_id=$1::uuid`, tenantInternalID).Scan(
&snapshot.IntegrationIssues.Total, &snapshot.IntegrationIssues.Open, &snapshot.IntegrationIssues.CriticalOpen,
&snapshot.IntegrationIssues.HighOpen, &snapshot.IntegrationIssues.ReleaseBlocking,
); err != nil {
return AcceptanceSnapshot{}, err
}
snapshot.IntegrationIssues.ReleaseGateSatisfied = snapshot.IntegrationIssues.ReleaseBlocking == 0
for _, dependency := range input.RequiredDependencies {
state := AcceptanceDependencyState{ServiceName: acceptanceDependencies[dependency], Dependency: dependency, Status: "MISSING"}
var checkedAt time.Time
err := tx.QueryRow(ctx, `SELECT status,checked_at,last_success_at,last_failure_at,COALESCE(last_error_code,''),latency_ms,probe_interval_ms,consecutive_failures FROM external_dependency_probe_state WHERE service_name=$1 AND dependency=$2`, state.ServiceName, dependency).Scan(
&state.Status, &checkedAt, &state.LastSuccessAt, &state.LastFailureAt, &state.LastErrorCode,
&state.LatencyMS, &state.ProbeIntervalMS, &state.ConsecutiveFailures,
)
if err != nil && err != pgx.ErrNoRows {
return AcceptanceSnapshot{}, err
}
if err == nil {
state.CheckedAt = &checkedAt
state.Fresh = !checkedAt.After(now) && now.Sub(checkedAt) <= 3*time.Duration(state.ProbeIntervalMS)*time.Millisecond
state.GateSatisfied = state.Status == "AVAILABLE" && state.Fresh
}
snapshot.ExternalDependencies = append(snapshot.ExternalDependencies, state)
}
if err := tx.QueryRow(ctx, `SELECT
count(*) FILTER (WHERE status='pending'),
count(*) FILTER (WHERE status='processing'),
count(*) FILTER (WHERE status='dead')
FROM outbox_event WHERE tenant_id=$1::uuid OR tenant_id IS NULL`, tenantInternalID).Scan(
&snapshot.Outbox.Pending, &snapshot.Outbox.Processing, &snapshot.Outbox.Dead,
); err != nil {
return AcceptanceSnapshot{}, err
}
rows, err := tx.Query(ctx, `SELECT public_id::text,rule_code,severity,status,COALESCE(resource_type,''),COALESCE(resource_public_id::text,''),
CASE
WHEN resource_public_id IS NULL THEN 'AGGREGATE'
WHEN resource_type='conversation' THEN CASE WHEN EXISTS(SELECT 1 FROM conversation c WHERE c.public_id=alert_incident.resource_public_id) THEN 'PRESENT' ELSE 'MISSING' END
WHEN resource_type='privacy_request' THEN CASE WHEN EXISTS(SELECT 1 FROM privacy_request pr WHERE pr.public_id=alert_incident.resource_public_id) THEN 'PRESENT' ELSE 'MISSING' END
WHEN resource_type='upload_batch' THEN CASE WHEN EXISTS(SELECT 1 FROM upload_batch ub WHERE ub.public_id=alert_incident.resource_public_id) THEN 'PRESENT' ELSE 'MISSING' END
WHEN resource_type='firmware_rollout' THEN CASE WHEN EXISTS(SELECT 1 FROM firmware_rollout fr WHERE fr.public_id=alert_incident.resource_public_id) THEN 'PRESENT' ELSE 'MISSING' END
WHEN resource_type='ops_resource_reference' THEN CASE WHEN EXISTS(SELECT 1 FROM ops_resource_reference rr WHERE rr.public_id=alert_incident.resource_public_id) THEN 'PRESENT' ELSE 'MISSING' END
ELSE 'UNKNOWN'
END,first_detected_at,last_detected_at
FROM alert_incident
WHERE tenant_id=$1::uuid AND status<>'RESOLVED' AND severity IN ('CRITICAL','HIGH')
ORDER BY CASE severity WHEN 'CRITICAL' THEN 0 ELSE 1 END,first_detected_at,public_id
LIMIT 100`, tenantInternalID)
if err != nil {
return AcceptanceSnapshot{}, err
}
for rows.Next() {
var item AcceptanceBlockingAlert
if err = rows.Scan(&item.ID, &item.RuleCode, &item.Severity, &item.Status, &item.ResourceType, &item.ResourceID, &item.ResourceState, &item.FirstDetectedAt, &item.LastDetectedAt); err != nil {
rows.Close()
return AcceptanceSnapshot{}, err
}
snapshot.Alerts.BlockingItems = append(snapshot.Alerts.BlockingItems, item)
}
if err = rows.Err(); err != nil {
rows.Close()
return AcceptanceSnapshot{}, err
}
rows.Close()
snapshot.Alerts.BlockingItemsTruncated = snapshot.Alerts.Blocking > int64(len(snapshot.Alerts.BlockingItems))
if err := tx.QueryRow(ctx, `SELECT
count(*) FILTER (WHERE severity='CRITICAL'),
count(*) FILTER (WHERE severity='HIGH'),
count(*)
FROM alert_incident WHERE tenant_id=$1::uuid AND status<>'RESOLVED' AND severity IN ('CRITICAL','HIGH')`, tenantInternalID).Scan(
&snapshot.Alerts.CriticalOpen, &snapshot.Alerts.HighOpen, &snapshot.Alerts.Blocking,
); err != nil {
return AcceptanceSnapshot{}, err
}
platformHealth, productionInfrastructure, err := loadAcceptancePlatformHealth(ctx, tx, now, input.Environment)
2026-08-26 22:04:38 +08:00
if err != nil {
return AcceptanceSnapshot{}, err
}
snapshot.PlatformHealth = platformHealth
snapshot.ProductionInfrastructure = productionInfrastructure
2026-08-26 06:41:07 +08:00
runtimeSatisfied := input.Environment == "LAB" || (snapshot.RuntimeBuild.Version == input.CandidateVersion && snapshot.RuntimeBuild.Commit != "unknown" && snapshot.RuntimeBuild.BuildTime != "unknown")
dependenciesSatisfied := true
for _, dependency := range snapshot.ExternalDependencies {
dependenciesSatisfied = dependenciesSatisfied && dependency.GateSatisfied
}
outboxSatisfied := snapshot.Outbox.Pending == 0 && snapshot.Outbox.Processing == 0 && snapshot.Outbox.Dead == 0
alertsSatisfied := snapshot.Alerts.Blocking == 0
snapshot.Gates = []AcceptanceGate{
{Code: "candidate_build_identified", Satisfied: runtimeSatisfied, Summary: acceptanceRuntimeSummary(input.Environment)},
{Code: "database_migration_clean", Satisfied: !snapshot.Database.Dirty, Summary: "数据库迁移必须处于非脏状态"},
{Code: "required_dependencies_available", Satisfied: dependenciesSatisfied, Summary: "所有必需外部依赖探针必须可用且未过期"},
{Code: "integration_issues_clear", Satisfied: snapshot.IntegrationIssues.ReleaseGateSatisfied, Summary: "不得存在未关闭的高或严重级联调问题"},
{Code: "outbox_clear", Satisfied: outboxSatisfied, Summary: "租户及平台级待处理、处理中和死信任务必须清零"},
{Code: "critical_alerts_clear", Satisfied: alertsSatisfied, Summary: "不得存在未解决的高或严重级告警"},
2026-08-26 22:04:38 +08:00
{Code: "platform_health_current", Satisfied: snapshot.PlatformHealth.GateSatisfied, Summary: acceptancePlatformHealthSummary(snapshot.PlatformHealth)},
{Code: "production_infrastructure_ready", Satisfied: snapshot.ProductionInfrastructure.GateSatisfied, Summary: acceptanceProductionInfrastructureSummary(snapshot.ProductionInfrastructure)},
2026-08-26 06:41:07 +08:00
}
snapshot.OverallGateSatisfied = true
for _, gate := range snapshot.Gates {
snapshot.OverallGateSatisfied = snapshot.OverallGateSatisfied && gate.Satisfied
}
return snapshot, nil
}
func loadAcceptancePlatformHealth(ctx context.Context, tx pgx.Tx, now time.Time, environment string) (AcceptancePlatformHealthState, AcceptanceProductionInfrastructureState, error) {
2026-08-26 22:04:38 +08:00
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, evaluateAcceptanceProductionInfrastructure(environment, nil), nil
2026-08-26 22:04:38 +08:00
}
if err != nil {
return AcceptancePlatformHealthState{}, AcceptanceProductionInfrastructureState{}, err
2026-08-26 22:04:38 +08:00
}
var checks []PlatformHealthCheck
if err = json.Unmarshal(checksJSON, &checks); err != nil {
return AcceptancePlatformHealthState{}, AcceptanceProductionInfrastructureState{}, err
2026-08-26 22:04:38 +08:00
}
return evaluateAcceptancePlatformHealth(state.Status, observedAt, receivedAt, checks, now), evaluateAcceptanceProductionInfrastructure(environment, checks), nil
2026-08-26 22:04:38 +08:00
}
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 evaluateAcceptanceProductionInfrastructure(environment string, checks []PlatformHealthCheck) AcceptanceProductionInfrastructureState {
2026-08-26 23:30:59 +08:00
state := AcceptanceProductionInfrastructureState{
Applicable: environment == "PRODUCTION",
DeploymentProfile: "MISSING",
ApplicationEnvironment: "MISSING",
IdentityServiceMode: "MISSING",
}
if !state.Applicable {
state.GateSatisfied = true
return state
}
profileReady := false
for _, check := range checks {
switch check.Name {
case "deployment_profile":
state.DeploymentProfile = check.Value
profileReady = check.Status == "healthy" && check.Value == "production"
2026-08-26 23:30:59 +08:00
case "application_environment":
state.ApplicationEnvironment = check.Value
state.ApplicationRuntimeReady = check.Status == "healthy" && check.Value == "prod"
case "identity_service_mode":
state.IdentityServiceMode = check.Value
state.IdentityServiceReady = check.Status == "healthy" && check.Value == "hardened"
case "offsite_backup":
state.OffsiteBackupReady = check.Status == "healthy" && strings.HasPrefix(check.Value, "age_seconds=")
case "external_alert_receiver":
state.ExternalAlertReceiverReady = check.Status == "healthy" && check.Value == "configured"
case "mock_services":
state.MockServicesDisabled = check.Status == "healthy" && check.Value == "disabled"
}
}
2026-08-26 23:30:59 +08:00
state.GateSatisfied = profileReady && state.ApplicationRuntimeReady && state.IdentityServiceReady && state.OffsiteBackupReady && state.ExternalAlertReceiverReady && state.MockServicesDisabled
return state
}
func acceptanceProductionInfrastructureSummary(state AcceptanceProductionInfrastructureState) string {
if !state.Applicable {
return "非生产候选不适用生产基础设施强制门禁"
}
2026-08-26 23:30:59 +08:00
missing := make([]string, 0, 6)
if state.DeploymentProfile != "production" {
missing = append(missing, "生产部署模式")
}
2026-08-26 23:30:59 +08:00
if !state.ApplicationRuntimeReady {
missing = append(missing, "生产应用运行环境")
}
if !state.IdentityServiceReady {
missing = append(missing, "非开发模式身份服务")
}
if !state.OffsiteBackupReady {
missing = append(missing, "已验证异地备份")
}
if !state.ExternalAlertReceiverReady {
missing = append(missing, "真实外部告警接收器")
}
if !state.MockServicesDisabled {
missing = append(missing, "关闭模拟服务")
}
if len(missing) > 0 {
return "生产基础设施尚缺:" + strings.Join(missing, "、")
}
2026-08-26 23:30:59 +08:00
return "生产部署与应用环境、身份服务、异地备份、外部告警和模拟服务退出均已确认"
}
2026-08-26 06:41:07 +08:00
func acceptanceRuntimeSummary(environment string) string {
if environment == "LAB" {
return "实验室环境允许开发构建,但候选版本与镜像摘要必须明确"
}
return "预发布和生产环境的运行版本必须等于候选版本,并包含提交与构建时间"
}