feat: expose signed platform health status
This commit is contained in:
@@ -72,6 +72,8 @@ VOLC_RTC_API_VERSION=2024-12-01
|
|||||||
VOLC_VOICE_CHAT_TEMPLATE_FILE=
|
VOLC_VOICE_CHAT_TEMPLATE_FILE=
|
||||||
PROVIDER_CALLBACK_SECRET=local-provider-callback-secret-32-bytes-minimum
|
PROVIDER_CALLBACK_SECRET=local-provider-callback-secret-32-bytes-minimum
|
||||||
PROVIDER_CALLBACK_CLOCK_SKEW=5m
|
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_REQUEST_TIMEOUT=5s
|
||||||
PROVIDER_FAILURE_THRESHOLD=3
|
PROVIDER_FAILURE_THRESHOLD=3
|
||||||
PROVIDER_CIRCUIT_COOLDOWN=1m
|
PROVIDER_CIRCUIT_COOLDOWN=1m
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ services:
|
|||||||
<<: *backend-environment
|
<<: *backend-environment
|
||||||
HTTP_ADDR: 0.0.0.0:8081
|
HTTP_ADDR: 0.0.0.0:8081
|
||||||
PROVIDER_CALLBACK_SECRET: local-provider-callback-secret-32-bytes-minimum
|
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_RPS: "1000"
|
||||||
RATE_LIMIT_BURST: "1500"
|
RATE_LIMIT_BURST: "1500"
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ spec:
|
|||||||
value: /var/run/secrets/voice-platform/common/redis-password
|
value: /var/run/secrets/voice-platform/common/redis-password
|
||||||
- name: PROVIDER_CALLBACK_SECRET_FILE
|
- name: PROVIDER_CALLBACK_SECRET_FILE
|
||||||
value: /var/run/secrets/voice-platform/callbacks/provider-callback-secret
|
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:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 8081
|
containerPort: 8081
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
provider: replace-with-installed-provider
|
provider: replace-with-installed-provider
|
||||||
parameters:
|
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
|
apiVersion: secrets-store.csi.x-k8s.io/v1
|
||||||
kind: SecretProviderClass
|
kind: SecretProviderClass
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ umask 077
|
|||||||
printf 'CURSOR_SIGNING_SECRET=%s\n' "$(openssl rand -hex 32)"
|
printf 'CURSOR_SIGNING_SECRET=%s\n' "$(openssl rand -hex 32)"
|
||||||
printf 'SHARE_TOKEN_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 '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 'MOCK_PROVIDER_API_KEY=%s\n' "$(openssl rand -hex 24)"
|
||||||
printf 'ALERT_WEBHOOK_SECRET=%s\n' "$(openssl rand -hex 32)"
|
printf 'ALERT_WEBHOOK_SECRET=%s\n' "$(openssl rand -hex 32)"
|
||||||
} > "$output_path"
|
} > "$output_path"
|
||||||
|
|
||||||
chmod 600 "$output_path"
|
chmod 600 "$output_path"
|
||||||
echo "created $output_path with mode 600"
|
echo "created $output_path with mode 600"
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ services:
|
|||||||
<<: *server-backend-environment
|
<<: *server-backend-environment
|
||||||
HTTP_ADDR: 0.0.0.0:8081
|
HTTP_ADDR: 0.0.0.0:8081
|
||||||
PROVIDER_CALLBACK_SECRET: ${PROVIDER_CALLBACK_SECRET}
|
PROVIDER_CALLBACK_SECRET: ${PROVIDER_CALLBACK_SECRET}
|
||||||
|
PLATFORM_HEALTH_CALLBACK_SECRET: ${PLATFORM_HEALTH_CALLBACK_SECRET}
|
||||||
RATE_LIMIT_RPS: "1000"
|
RATE_LIMIT_RPS: "1000"
|
||||||
RATE_LIMIT_BURST: "1500"
|
RATE_LIMIT_BURST: "1500"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ if [[ -f "$monitor_config" ]]; then
|
|||||||
fi
|
fi
|
||||||
alert_webhook_url=${ALERT_WEBHOOK_URL:-}
|
alert_webhook_url=${ALERT_WEBHOOK_URL:-}
|
||||||
alert_webhook_secret_file=${ALERT_WEBHOOK_SECRET_FILE:-/etc/data-terminal-cloud/monitor-webhook-secret}
|
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
|
if [[ -f "$offsite_config_file" ]]; then
|
||||||
# shellcheck disable=SC1090
|
# shellcheck disable=SC1090
|
||||||
source "$offsite_config_file"
|
source "$offsite_config_file"
|
||||||
@@ -232,14 +234,12 @@ PY
|
|||||||
chmod 0644 "$status_tmp"
|
chmod 0644 "$status_tmp"
|
||||||
mv -f "$status_tmp" "$status_file"
|
mv -f "$status_tmp" "$status_file"
|
||||||
|
|
||||||
if [[ -n "$alert_webhook_url" && "$previous_status" != "$overall" ]]; then
|
post_signed_status() {
|
||||||
if [[ ! -r "$alert_webhook_secret_file" ]]; then
|
local target_url=$1
|
||||||
record_check alert_delivery critical secret_missing
|
local secret_file=$2
|
||||||
echo "Alert webhook is configured but its HMAC secret is unreadable" >&2
|
local timestamp signature
|
||||||
exit 5
|
|
||||||
fi
|
|
||||||
timestamp=$(date +%s)
|
timestamp=$(date +%s)
|
||||||
signature=$(python3 - "$alert_webhook_secret_file" "$status_file" "$timestamp" <<'PY'
|
signature=$(python3 - "$secret_file" "$status_file" "$timestamp" <<'PY'
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import sys
|
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())
|
print(hmac.new(secret, sys.argv[3].encode() + b"." + body, hashlib.sha256).hexdigest())
|
||||||
PY
|
PY
|
||||||
)
|
)
|
||||||
python3 - "$alert_webhook_url" "$curl_config" <<'PY'
|
python3 - "$target_url" "$curl_config" <<'PY'
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
url = sys.argv[1].replace("\\", "\\\\").replace('"', '\\"')
|
url = sys.argv[1].replace("\\", "\\\\").replace('"', '\\"')
|
||||||
@@ -260,6 +260,23 @@ PY
|
|||||||
-H "X-Platform-Timestamp: $timestamp" \
|
-H "X-Platform-Timestamp: $timestamp" \
|
||||||
-H "X-Platform-Signature: sha256=$signature" \
|
-H "X-Platform-Signature: sha256=$signature" \
|
||||||
--data-binary "@$status_file" >/dev/null
|
--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
|
fi
|
||||||
|
|
||||||
printf 'platform_health status=%s critical=%s warning=%s status_file=%s\n' \
|
printf 'platform_health status=%s critical=%s warning=%s status_file=%s\n' \
|
||||||
|
|||||||
@@ -9,13 +9,31 @@ fi
|
|||||||
project_root=${PLATFORM_ROOT:-/opt/data-terminal-cloud/current}
|
project_root=${PLATFORM_ROOT:-/opt/data-terminal-cloud/current}
|
||||||
install -d -m 0700 /etc/data-terminal-cloud
|
install -d -m 0700 /etc/data-terminal-cloud
|
||||||
install -d -m 0750 /var/lib/data-terminal-cloud-monitor
|
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
|
if [[ ! -s /etc/data-terminal-cloud/monitor-webhook-secret ]]; then
|
||||||
umask 077
|
umask 077
|
||||||
openssl rand -base64 48 >/etc/data-terminal-cloud/monitor-webhook-secret
|
openssl rand -base64 48 >/etc/data-terminal-cloud/monitor-webhook-secret
|
||||||
fi
|
fi
|
||||||
chmod 0600 /etc/data-terminal-cloud/monitor-webhook-secret
|
chmod 0600 /etc/data-terminal-cloud/monitor-webhook-secret
|
||||||
if [[ ! -e /etc/data-terminal-cloud/monitor.env ]]; then
|
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 <<EOF
|
||||||
|
PLATFORM_HEALTH_REPORT_URL=$report_url
|
||||||
|
PLATFORM_HEALTH_REPORT_SECRET_FILE=/etc/data-terminal-cloud/platform-health-callback-secret
|
||||||
ALERT_WEBHOOK_URL=
|
ALERT_WEBHOOK_URL=
|
||||||
ALERT_WEBHOOK_SECRET_FILE=/etc/data-terminal-cloud/monitor-webhook-secret
|
ALERT_WEBHOOK_SECRET_FILE=/etc/data-terminal-cloud/monitor-webhook-secret
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 开发状态与门禁记录
|
# 开发状态与门禁记录
|
||||||
|
|
||||||
> 更新日期:2026-08-26
|
> 更新日期:2026-08-26
|
||||||
> 当前接口契约版本:0.64.0
|
> 当前接口契约版本:0.65.0
|
||||||
> 当前主里程碑:M8目标环境联调、试点与上线验收(外部条件待提供)
|
> 当前主里程碑: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项缺口,临时文件已清理。 |
|
| 真实终端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项缺口,临时文件已清理。 |
|
| 真实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.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`。 |
|
| 阶段8外部执行轻量预检 | 新增`scripts/stage8-preflight.ps1`,以单一只读入口核对候选Git提交、远程仓库、Compose本地基线、外部配置变量名称和十类证据索引结构/状态;报告明确不读取密钥值,也不把环境变量或本地服务可用性当作验收证据。默认仅报告缺口,`-Strict`可供自动化阻断未就绪交接;当前已建立本地`main`候选提交,本地运行基线就绪且索引结构有效,仍缺远程仓库,样例构建摘要为占位值,十类外部证据均为`PENDING`。 |
|
||||||
| 候选构建身份贯通 | Compose新增统一候选构建参数,后端二进制和后端/Web镜像OCI标签共享版本、完整Git提交及UTC构建时间;远程CI从OpenAPI版本和运行编号形成候选版本,构建后重新读取两个镜像标签并与`GITHUB_SHA`和构建时间逐项比对,再把候选版本、提交、时间和双镜像摘要写入构建元数据。修复了此前Dockerfile虽支持注入但Compose/CI未传参、运行版本仍为`dev/unknown`的问题。 |
|
| 候选构建身份贯通 | Compose新增统一候选构建参数,后端二进制和后端/Web镜像OCI标签共享版本、完整Git提交及UTC构建时间;远程CI从OpenAPI版本和运行编号形成候选版本,构建后重新读取两个镜像标签并与`GITHUB_SHA`和构建时间逐项比对,再把候选版本、提交、时间和双镜像摘要写入构建元数据。修复了此前Dockerfile虽支持注入但Compose/CI未传参、运行版本仍为`dev/unknown`的问题。 |
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 设备 API 兼容性约定
|
# 设备 API 兼容性约定
|
||||||
|
|
||||||
> 当前设备接口主版本:`/device/v2`
|
> 当前设备接口主版本:`/device/v2`
|
||||||
> 当前接口契约:OpenAPI 0.64.0
|
> 当前接口契约:OpenAPI 0.65.0
|
||||||
> 适用对象:云端 API、终端固件、模拟终端和设备联调工具
|
> 适用对象:云端 API、终端固件、模拟终端和设备联调工具
|
||||||
|
|
||||||
## 1. 兼容目标
|
## 1. 兼容目标
|
||||||
@@ -43,6 +43,7 @@ CI在拉取请求中以目标分支OpenAPI为基线执行破坏性变更检查
|
|||||||
| OpenAPI版本 | 设备兼容变更 | 兼容结论 |
|
| OpenAPI版本 | 设备兼容变更 | 兼容结论 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 0.64.0 | 运营外部预检增加`content_safety_tls`枚举和候选快照第四类可选必需依赖;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 |
|
| 0.64.0 | 运营外部预检增加`content_safety_tls`枚举和候选快照第四类可选必需依赖;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 |
|
||||||
|
| 0.65.0 | 新增主机健康签名回调和运营只读查询;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 |
|
||||||
| 0.63.0 | 增加设备固件阶段事件查询及云端证据字段;设备已有命令回执仅记录同一结果的不可变事件,未增加旧终端必填字段 | v2兼容,旧终端行为不变 |
|
| 0.63.0 | 增加设备固件阶段事件查询及云端证据字段;设备已有命令回执仅记录同一结果的不可变事件,未增加旧终端必填字段 | v2兼容,旧终端行为不变 |
|
||||||
| 0.62.0 | 命令结果增加A/B写入、待重启、启动健康和回退可选字段,省略时保持原有单分区结果语义 | v2兼容,旧终端可继续省略 |
|
| 0.62.0 | 命令结果增加A/B写入、待重启、启动健康和回退可选字段,省略时保持原有单分区结果语义 | v2兼容,旧终端可继续省略 |
|
||||||
| 0.61.0 | 新增家庭侧固件版本影响只读接口,未修改设备协议 | v2完全兼容 |
|
| 0.61.0 | 新增家庭侧固件版本影响只读接口,未修改设备协议 | v2完全兼容 |
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ docker push "$Registry/migrate:$Version"
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `voice-platform-common-secrets` | `database-url`、`redis-password` | API、Callbacks、Worker、迁移Job |
|
| `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-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-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-model-storage-secrets` | `object-store-access-key`、`object-store-secret-key`、`ark-api-key`、`content-safety-api-key` | API、Worker;内容安全Key仅由Worker引用 |
|
||||||
| `voice-platform-device-secrets` | 每台设备一个key,例如`terminal-001` | API |
|
| `voice-platform-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-common` | `database-url`、`redis-password` |
|
||||||
| `voice-platform-api` | `rtc-app-key`、`cursor-signing-secret`、`share-token-signing-secret` |
|
| `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-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-model-storage` | `object-store-access-key`、`object-store-secret-key`、`ark-api-key`、`content-safety-api-key` |
|
||||||
| `voice-platform-devices` | 每台设备一个文件,例如`terminal-001` |
|
| `voice-platform-devices` | 每台设备一个文件,例如`terminal-001` |
|
||||||
|
|||||||
@@ -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`;运营查询返回最新观测时间;无签名、旧时间戳、聚合状态不一致均被拒绝;下一周期再次上报后快照时间前进。
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
| 证书 | 本地活动证书剩余时间不少于36小时 | `missing`或`remaining_seconds=...` |
|
| 证书 | 本地活动证书剩余时间不少于36小时 | `missing`或`remaining_seconds=...` |
|
||||||
| 磁盘 | 低于80%为健康,80%—89%为warning,90%及以上为critical | `used_percent=...` |
|
| 磁盘 | 低于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. 状态模型
|
## 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
|
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状态文件。请求包含:
|
只有总体状态发生变化时才发送JSON状态文件。请求包含:
|
||||||
|
|
||||||
- `Content-Type: application/json`;
|
- `Content-Type: application/json`;
|
||||||
|
|||||||
+9
-4
@@ -124,6 +124,12 @@ func Run(ctx context.Context, serviceName string) error {
|
|||||||
var alertService *alerts.Service
|
var alertService *alerts.Service
|
||||||
var diagnosticService *diagnostics.Service
|
var diagnosticService *diagnostics.Service
|
||||||
var opsAccessService *opsaccess.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" {
|
if serviceName == "api" {
|
||||||
webEventService = webevents.NewService(databasePool)
|
webEventService = webevents.NewService(databasePool)
|
||||||
cursorCodec, cursorErr := pagination.NewCodec([]byte(cfg.CursorSigningSecret))
|
cursorCodec, cursorErr := pagination.NewCodec([]byte(cfg.CursorSigningSecret))
|
||||||
@@ -143,10 +149,6 @@ func Run(ctx context.Context, serviceName string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create alert operations service: %w", err)
|
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)
|
opsAccessService, err = opsaccess.NewService(databasePool, idempotency.NewExecutor(databasePool), audit.NewRepository(), exportStore, 5*time.Minute)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create temporary sensitive access service: %w", err)
|
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),
|
ProviderCallbackSecret: []byte(cfg.ProviderCallbackSecret),
|
||||||
ProviderCallbackSkew: cfg.ProviderCallbackSkew,
|
ProviderCallbackSkew: cfg.ProviderCallbackSkew,
|
||||||
ProviderCallbackProcessor: providerEventProcessor,
|
ProviderCallbackProcessor: providerEventProcessor,
|
||||||
|
PlatformHealthCallbackSecret: []byte(cfg.PlatformHealthSecret),
|
||||||
|
PlatformHealthCallbackSkew: cfg.PlatformHealthSkew,
|
||||||
|
PlatformHealthProcessor: diagnosticService,
|
||||||
})
|
})
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: cfg.HTTPAddr,
|
Addr: cfg.HTTPAddr,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func TestCallbacksApplicationStartsAndShutsDown(t *testing.T) {
|
|||||||
t.Setenv("REDIS_ADDR", "localhost:56379")
|
t.Setenv("REDIS_ADDR", "localhost:56379")
|
||||||
t.Setenv("SHUTDOWN_TIMEOUT", "1s")
|
t.Setenv("SHUTDOWN_TIMEOUT", "1s")
|
||||||
t.Setenv("DEPENDENCY_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)
|
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := Run(ctx, "callbacks"); err != nil {
|
if err := Run(ctx, "callbacks"); err != nil {
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ type Config struct {
|
|||||||
HeartbeatInterval int
|
HeartbeatInterval int
|
||||||
ProviderCallbackSecret string
|
ProviderCallbackSecret string
|
||||||
ProviderCallbackSkew time.Duration
|
ProviderCallbackSkew time.Duration
|
||||||
|
PlatformHealthSecret string
|
||||||
|
PlatformHealthSkew time.Duration
|
||||||
MockProviderBaseURL string
|
MockProviderBaseURL string
|
||||||
MockProviderAPIKey string
|
MockProviderAPIKey string
|
||||||
VoiceProvider string
|
VoiceProvider string
|
||||||
@@ -160,6 +162,10 @@ func Load(serviceName string) (Config, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
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)
|
providerRequestTimeout, err := durationEnv("PROVIDER_REQUEST_TIMEOUT", 5*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
@@ -203,7 +209,7 @@ func Load(serviceName string) (Config, error) {
|
|||||||
environment := stringEnv("APP_ENV", EnvironmentLocal)
|
environment := stringEnv("APP_ENV", EnvironmentLocal)
|
||||||
secretValues, err := loadSecretValues(
|
secretValues, err := loadSecretValues(
|
||||||
"DATABASE_URL", "REDIS_PASSWORD", "DEVICE_LOCAL_SECRET", "RTC_APP_KEY", "RTC_TOKEN_SIGNING_SECRET",
|
"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",
|
"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",
|
"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,
|
AllocationTTL: allocationTTL,
|
||||||
ProviderCallbackSecret: providerCallbackSecret,
|
ProviderCallbackSecret: providerCallbackSecret,
|
||||||
ProviderCallbackSkew: providerCallbackSkew,
|
ProviderCallbackSkew: providerCallbackSkew,
|
||||||
|
PlatformHealthSecret: secretValues["PLATFORM_HEALTH_CALLBACK_SECRET"],
|
||||||
|
PlatformHealthSkew: platformHealthSkew,
|
||||||
MockProviderBaseURL: stringEnv("MOCK_PROVIDER_BASE_URL", "http://localhost:59200"),
|
MockProviderBaseURL: stringEnv("MOCK_PROVIDER_BASE_URL", "http://localhost:59200"),
|
||||||
MockProviderAPIKey: secretDefault(secretValues["MOCK_PROVIDER_API_KEY"], "local-mock-provider-key"),
|
MockProviderAPIKey: secretDefault(secretValues["MOCK_PROVIDER_API_KEY"], "local-mock-provider-key"),
|
||||||
VoiceProvider: stringEnv("VOICE_PROVIDER", "mock"),
|
VoiceProvider: stringEnv("VOICE_PROVIDER", "mock"),
|
||||||
@@ -457,6 +465,9 @@ func (c Config) Validate() error {
|
|||||||
if c.ServiceName == "callbacks" && len(c.ProviderCallbackSecret) < 32 {
|
if c.ServiceName == "callbacks" && len(c.ProviderCallbackSecret) < 32 {
|
||||||
return errors.New("PROVIDER_CALLBACK_SECRET must contain at least 32 characters")
|
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.ServiceName == "worker" {
|
||||||
if c.VoiceProvider != "mock" && c.VoiceProvider != "volcengine" {
|
if c.VoiceProvider != "mock" && c.VoiceProvider != "volcengine" {
|
||||||
return errors.New("VOICE_PROVIDER must be mock or 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")
|
return errors.New("provider callback skew and request timeout must be positive")
|
||||||
}
|
}
|
||||||
if c.AlertWebhookTimeout <= 0 {
|
if c.AlertWebhookTimeout <= 0 {
|
||||||
@@ -578,6 +589,9 @@ func (c Config) validateProduction() error {
|
|||||||
if c.ServiceName == "callbacks" && isPlaceholderSecret(c.ProviderCallbackSecret) {
|
if c.ServiceName == "callbacks" && isPlaceholderSecret(c.ProviderCallbackSecret) {
|
||||||
return errors.New("production PROVIDER_CALLBACK_SECRET must not use a local or example value")
|
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.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) {
|
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")
|
return errors.New("production voice provider must use signed Volcengine RTC HTTPS with non-placeholder AK/SK")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
func TestLoadUsesServicePortDefaults(t *testing.T) {
|
func TestLoadUsesServicePortDefaults(t *testing.T) {
|
||||||
t.Setenv("HTTP_ADDR", "")
|
t.Setenv("HTTP_ADDR", "")
|
||||||
t.Setenv("APP_ENV", "test")
|
t.Setenv("APP_ENV", "test")
|
||||||
|
t.Setenv("PLATFORM_HEALTH_CALLBACK_SECRET", strings.Repeat("h", 32))
|
||||||
|
|
||||||
tests := map[string]string{
|
tests := map[string]string{
|
||||||
"api": "0.0.0.0:58080",
|
"api": "0.0.0.0:58080",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,26 @@ import (
|
|||||||
"data-terminal-cloud/internal/idempotency"
|
"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 {
|
func ListOpsIntegrationIssuesHandler(service *diagnostics.Service) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
principal, ok := webPrincipal(w, r)
|
principal, ok := webPrincipal(w, r)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -48,6 +48,9 @@ type RouterOptions struct {
|
|||||||
ProviderCallbackSecret []byte
|
ProviderCallbackSecret []byte
|
||||||
ProviderCallbackSkew time.Duration
|
ProviderCallbackSkew time.Duration
|
||||||
ProviderCallbackProcessor ProviderCallbackProcessor
|
ProviderCallbackProcessor ProviderCallbackProcessor
|
||||||
|
PlatformHealthCallbackSecret []byte
|
||||||
|
PlatformHealthCallbackSkew time.Duration
|
||||||
|
PlatformHealthProcessor PlatformHealthProcessor
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRouter(options RouterOptions) http.Handler {
|
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)))
|
mux.Handle("GET /api/v1/ops/cost-summary", authenticated(GetOpsCostSummaryHandler(options.BillingService)))
|
||||||
}
|
}
|
||||||
if options.DiagnosticService != nil {
|
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("GET /api/v1/ops/integration-issues", authenticated(ListOpsIntegrationIssuesHandler(options.DiagnosticService)))
|
||||||
mux.Handle("POST /api/v1/ops/integration-issues", authenticated(CreateOpsIntegrationIssueHandler(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)))
|
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,
|
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) {
|
mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
|
||||||
WriteError(writer, request, http.StatusNotFound, "RESOURCE_NOT_FOUND", "请求的资源不存在")
|
WriteError(writer, request, http.StatusNotFound, "RESOURCE_NOT_FOUND", "请求的资源不存在")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS platform_health_snapshot;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -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;
|
||||||
+96
-1
@@ -1,7 +1,7 @@
|
|||||||
openapi: 3.1.0
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: 数据采集终端—云端系统 API
|
title: 数据采集终端—云端系统 API
|
||||||
version: 0.64.0
|
version: 0.65.0
|
||||||
description: 工程框架、SaaS核心业务、设备v2能力协商与智能体启动状态查询、首次通话就绪与最低固件门禁、背景资料准备失败闭环、活动通话授权撤回安全停止、语音内容闭环、终端终稿补传、批量设备遥测、录音上传队列、隐私对账、固件、双轨录音、状态事件、具体资源分享、受控运营配置、Agent组合组件引用、质量证据门禁、运营自定义版本族投放、新通话运行时冻结与供应商启动传递、带并发版本校验的配置投放暂停恢复、带严重度和验收证据门禁的联调问题闭环、含阻断告警定位证据的发布候选技术验收快照、绑定候选版本与构建摘要并按业务时区生成的可审计试点日/周运行证据、可审计且不生成正式告警的接收器联通测试、外部依赖预检台账、证书到期清单,以及带价格版本冻结、供应商用量去重和硬额度控制的费用治理接口。
|
description: 工程框架、SaaS核心业务、设备v2能力协商与智能体启动状态查询、首次通话就绪与最低固件门禁、背景资料准备失败闭环、活动通话授权撤回安全停止、语音内容闭环、终端终稿补传、批量设备遥测、录音上传队列、隐私对账、固件、双轨录音、状态事件、具体资源分享、受控运营配置、Agent组合组件引用、质量证据门禁、运营自定义版本族投放、新通话运行时冻结与供应商启动传递、带并发版本校验的配置投放暂停恢复、带严重度和验收证据门禁的联调问题闭环、含阻断告警定位证据的发布候选技术验收快照、绑定候选版本与构建摘要并按业务时区生成的可审计试点日/周运行证据、可审计且不生成正式告警的接收器联通测试、外部依赖预检台账、证书到期清单,以及带价格版本冻结、供应商用量去重和硬额度控制的费用治理接口。
|
||||||
servers:
|
servers:
|
||||||
- url: http://localhost:58080
|
- url: http://localhost:58080
|
||||||
@@ -241,6 +241,17 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200': { description: 平台外部依赖预检状态, content: { application/json: { schema: { $ref: '#/components/schemas/OpsExternalDependencyListResponse' } } } }
|
'200': { description: 平台外部依赖预检状态, content: { application/json: { schema: { $ref: '#/components/schemas/OpsExternalDependencyListResponse' } } } }
|
||||||
'403': { $ref: '#/components/responses/AccessDeniedError' }
|
'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:
|
/api/v1/ops/diagnostics/jobs:
|
||||||
get:
|
get:
|
||||||
operationId: listOpsDiagnosticJobs
|
operationId: listOpsDiagnosticJobs
|
||||||
@@ -2644,6 +2655,27 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/ErrorResponse' }
|
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:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
oidcBearer:
|
oidcBearer:
|
||||||
@@ -2659,6 +2691,11 @@ components:
|
|||||||
in: header
|
in: header
|
||||||
name: X-Provider-Signature
|
name: X-Provider-Signature
|
||||||
description: 供应商回调正文摘要和时间戳的HMAC-SHA256签名。
|
description: 供应商回调正文摘要和时间戳的HMAC-SHA256签名。
|
||||||
|
platformHealthSignature:
|
||||||
|
type: apiKey
|
||||||
|
in: header
|
||||||
|
name: X-Platform-Signature
|
||||||
|
description: 使用独立密钥对Unix时间戳、点号和原始健康报告正文计算的HMAC-SHA256十六进制签名,格式为sha256=摘要。
|
||||||
headers:
|
headers:
|
||||||
StrongETag:
|
StrongETag:
|
||||||
description: 资源强版本标签,例如"v2"。
|
description: 资源强版本标签,例如"v2"。
|
||||||
@@ -2755,6 +2792,16 @@ components:
|
|||||||
in: header
|
in: header
|
||||||
required: true
|
required: true
|
||||||
schema: { type: string }
|
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:
|
schemas:
|
||||||
CreateOpsConfigVersionRequest:
|
CreateOpsConfigVersionRequest:
|
||||||
type: object
|
type: object
|
||||||
@@ -3086,6 +3133,54 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
request_id: { type: string }
|
request_id: { type: string }
|
||||||
data: { $ref: '#/components/schemas/OpsExternalDependencyListData' }
|
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:
|
OpsDiagnosticAction:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
|||||||
@@ -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 '证据包生成结果不正确' }
|
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
|
Expand-Archive -LiteralPath $bundlePath -DestinationPath $expandedPath
|
||||||
$manifest = Get-Content -Raw -LiteralPath (Join-Path $expandedPath 'release-manifest.json') | ConvertFrom-Json
|
$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.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.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两类轮换缺口未在证据包中保留' }
|
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两类轮换缺口未在证据包中保留' }
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export type AlertIncident = components["schemas"]["AlertIncident"];
|
|||||||
export type AlertSilence = components["schemas"]["AlertSilence"];
|
export type AlertSilence = components["schemas"]["AlertSilence"];
|
||||||
export type OpsDiagnosticRecord = components["schemas"]["OpsDiagnosticRecord"];
|
export type OpsDiagnosticRecord = components["schemas"]["OpsDiagnosticRecord"];
|
||||||
export type OpsExternalDependency = components["schemas"]["OpsExternalDependency"];
|
export type OpsExternalDependency = components["schemas"]["OpsExternalDependency"];
|
||||||
|
export type PlatformHealthSnapshot = components["schemas"]["PlatformHealthSnapshot"];
|
||||||
export type OpsDiagnosticAction = components["schemas"]["OpsDiagnosticAction"];
|
export type OpsDiagnosticAction = components["schemas"]["OpsDiagnosticAction"];
|
||||||
export type OpsDiagnosticJob = components["schemas"]["OpsDiagnosticJob"];
|
export type OpsDiagnosticJob = components["schemas"]["OpsDiagnosticJob"];
|
||||||
export type OpsDiagnosticBundle = components["schemas"]["OpsDiagnosticBundle"];
|
export type OpsDiagnosticBundle = components["schemas"]["OpsDiagnosticBundle"];
|
||||||
@@ -206,6 +207,8 @@ export const platformApi = {
|
|||||||
authenticatedJSON<Envelope<{ items: OpsDiagnosticRecord[] }>>("GET", `/api/v1/ops/diagnostics/search?field=${encodeURIComponent(field)}&value=${encodeURIComponent(value)}`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID }),
|
authenticatedJSON<Envelope<{ items: OpsDiagnosticRecord[] }>>("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) =>
|
opsExternalDependencies: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
|
||||||
authenticatedJSON<Envelope<{ items: OpsExternalDependency[] }>>("GET", "/api/v1/ops/diagnostics/external-dependencies", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
|
authenticatedJSON<Envelope<{ items: OpsExternalDependency[] }>>("GET", "/api/v1/ops/diagnostics/external-dependencies", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
|
||||||
|
opsPlatformHealth: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
|
||||||
|
authenticatedJSON<Envelope<PlatformHealthSnapshot>>("GET", "/api/v1/ops/diagnostics/platform-health", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
|
||||||
stopOpsDiagnosticConversation: (tenantID: string, conversationID: string, reason: string, accessToken: string) =>
|
stopOpsDiagnosticConversation: (tenantID: string, conversationID: string, reason: string, accessToken: string) =>
|
||||||
authenticatedJSON<Envelope<OpsDiagnosticAction>>("POST", `/api/v1/ops/diagnostics/conversations/${conversationID}/stop`, accessToken, undefined, { reason }, { "X-Tenant-Id": tenantID, "Idempotency-Key": `ops-diagnostic-stop-${crypto.randomUUID()}` }),
|
authenticatedJSON<Envelope<OpsDiagnosticAction>>("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) =>
|
reconcileOpsDiagnosticConversation: (tenantID: string, conversationID: string, reason: string, accessToken: string) =>
|
||||||
|
|||||||
Vendored
+159
@@ -189,6 +189,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/v1/ops/diagnostics/jobs": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2145,6 +2162,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
@@ -2535,6 +2569,49 @@ export interface components {
|
|||||||
request_id: string;
|
request_id: string;
|
||||||
data: components["schemas"]["OpsExternalDependencyListData"];
|
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: {
|
OpsDiagnosticAction: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
action: "stop_conversation" | "reconcile_conversation" | "refresh_config" | "revoke_device" | "retry_job";
|
action: "stop_conversation" | "reconcile_conversation" | "refresh_config" | "revoke_device" | "retry_job";
|
||||||
@@ -5585,6 +5662,8 @@ export interface components {
|
|||||||
OpsAccessGrantHeader: string;
|
OpsAccessGrantHeader: string;
|
||||||
ProviderTimestampHeader: string;
|
ProviderTimestampHeader: string;
|
||||||
ProviderSignatureHeader: string;
|
ProviderSignatureHeader: string;
|
||||||
|
PlatformTimestampHeader: string;
|
||||||
|
PlatformSignatureHeader: string;
|
||||||
};
|
};
|
||||||
requestBodies: never;
|
requestBodies: never;
|
||||||
headers: {
|
headers: {
|
||||||
@@ -6084,6 +6163,31 @@ export interface operations {
|
|||||||
403: components["responses"]["AccessDeniedError"];
|
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: {
|
listOpsDiagnosticJobs: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type FormEvent, useEffect, useState } from "react";
|
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 = [
|
const fields = [
|
||||||
["device_id", "设备编号"], ["conversation_id", "通话编号"], ["room_id", "房间编号"],
|
["device_id", "设备编号"], ["conversation_id", "通话编号"], ["room_id", "房间编号"],
|
||||||
@@ -23,6 +23,7 @@ export function OpsDiagnosticsWorkspace({ accessToken, accounts }: { accessToken
|
|||||||
const [value,setValue]=useState(""); const [items,setItems]=useState<OpsDiagnosticRecord[]>([]);
|
const [value,setValue]=useState(""); const [items,setItems]=useState<OpsDiagnosticRecord[]>([]);
|
||||||
const [jobs,setJobs]=useState<OpsDiagnosticJob[]>([]);
|
const [jobs,setJobs]=useState<OpsDiagnosticJob[]>([]);
|
||||||
const [externalDependencies,setExternalDependencies]=useState<OpsExternalDependency[]>([]);
|
const [externalDependencies,setExternalDependencies]=useState<OpsExternalDependency[]>([]);
|
||||||
|
const [platformHealth,setPlatformHealth]=useState<PlatformHealthSnapshot>();
|
||||||
const [issues,setIssues]=useState<IntegrationIssue[]>([]); const [issueStatus,setIssueStatus]=useState<IntegrationIssue["status"]|"">("OPEN");
|
const [issues,setIssues]=useState<IntegrationIssue[]>([]); const [issueStatus,setIssueStatus]=useState<IntegrationIssue["status"]|"">("OPEN");
|
||||||
const [issueSummary,setIssueSummary]=useState<IntegrationIssueSummary>({total:0,open:0,critical_open:0,high_open:0,release_blocking:0,release_gate_satisfied:true});
|
const [issueSummary,setIssueSummary]=useState<IntegrationIssueSummary>({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()});
|
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));
|
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 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(()=>{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]);
|
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;
|
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 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 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)}
|
function openDiagnosticControl(kind:DiagnosticControlKind,item:OpsDiagnosticRecord){setDiagnosticControlDraft({kind,item,reason:"",acknowledged:false});setError(undefined);setMessage(undefined)}
|
||||||
async function executeDiagnosticControl(event:FormEvent){
|
async function executeDiagnosticControl(event:FormEvent){
|
||||||
event.preventDefault();const draft=diagnosticControlDraft;if(!draft||!draft.reason.trim()||(diagnosticControlNeedsAcknowledgement(draft.kind)&&!draft.acknowledged))return;
|
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 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)}}
|
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 <section className="ops-workspace ops-diagnostics" aria-label="联调诊断工作台">
|
return <section className="ops-workspace ops-diagnostics" aria-label="联调诊断工作台">
|
||||||
<div className="ops-workspace__head"><div><p className="eyebrow">INTEGRATION DIAGNOSTICS</p><h2>设备与通话联调</h2><p>只展示脱敏运行状态,不展示完整短期凭证、字幕正文或原录音。</p></div><label>客户组织<select value={tenantID} onChange={(event)=>{setTenantID(event.target.value);setItems([]);setJobs([]);setIssues([]);setAccessRequests([]);setAcceptanceResult(undefined);setPilotDailyResult(undefined);setPilotWeeklyResult(undefined);setDiagnosticControlDraft(undefined);setDiagnosticBundleDraft(undefined);setDiagnosticJobRetryDraft(undefined);setIssueActionDraft(undefined);setSensitiveAccessDraft(undefined);setSensitiveAccessReviewDraft(undefined);setSensitive(undefined)}}>{opsAccounts.map((item)=><option value={item.tenant_id} key={item.user_id}>{item.tenant_name}</option>)}</select></label></div>
|
<div className="ops-workspace__head"><div><p className="eyebrow">INTEGRATION DIAGNOSTICS</p><h2>设备与通话联调</h2><p>只展示脱敏运行状态,不展示完整短期凭证、字幕正文或原录音。</p></div><label>客户组织<select value={tenantID} onChange={(event)=>{setTenantID(event.target.value);setItems([]);setJobs([]);setIssues([]);setAccessRequests([]);setPlatformHealth(undefined);setAcceptanceResult(undefined);setPilotDailyResult(undefined);setPilotWeeklyResult(undefined);setDiagnosticControlDraft(undefined);setDiagnosticBundleDraft(undefined);setDiagnosticJobRetryDraft(undefined);setIssueActionDraft(undefined);setSensitiveAccessDraft(undefined);setSensitiveAccessReviewDraft(undefined);setSensitive(undefined)}}>{opsAccounts.map((item)=><option value={item.tenant_id} key={item.user_id}>{item.tenant_name}</option>)}</select></label></div>
|
||||||
{canOperate?<section className="diagnostic-jobs acceptance-snapshot"><div className="section-heading"><div><p className="eyebrow">RELEASE CANDIDATE EVIDENCE</p><h3>发布候选验收快照</h3><p>一次冻结候选构建、迁移、必需依赖、联调问题、可靠任务和严重告警;输出仅是技术预检证据,不代替发布批准。</p></div></div><form className="integration-issue-form" onSubmit={(event)=>void exportAcceptanceSnapshot(event)}><label><span>候选版本</span><input required maxLength={128} pattern="[A-Za-z0-9][A-Za-z0-9._+\-]*" value={acceptanceDraft.candidate_version} onChange={(event)=>setAcceptanceDraft({...acceptanceDraft,candidate_version:event.target.value})} placeholder="如 0.55.0-rc.1" /></label><label><span>构建摘要</span><input required pattern="sha256:[A-Fa-f0-9]{64}" value={acceptanceDraft.build_digest} onChange={(event)=>setAcceptanceDraft({...acceptanceDraft,build_digest:event.target.value})} placeholder="sha256: 后跟64位十六进制" /></label><label><span>目标环境</span><select value={acceptanceDraft.environment} onChange={(event)=>setAcceptanceDraft({...acceptanceDraft,environment:event.target.value as OpsAcceptanceSnapshotRequest["environment"]})}><option value="LAB">实验室</option><option value="STAGING">预发布</option><option value="PRODUCTION">生产</option></select></label><label><span>验收类型</span><select value={acceptanceDraft.run_type} onChange={(event)=>setAcceptanceDraft({...acceptanceDraft,run_type:event.target.value as OpsAcceptanceSnapshotRequest["run_type"]})}><option value="REAL_DEVICE_PROVIDER">真实设备与供应商</option><option value="PILOT">灰度试点</option><option value="PRODUCTION_RELEASE">生产发布</option></select></label><label><span>验收范围</span><textarea required maxLength={500} value={acceptanceDraft.scope} onChange={(event)=>setAcceptanceDraft({...acceptanceDraft,scope:event.target.value})} /></label><label><span>归档原因</span><textarea required maxLength={255} value={acceptanceDraft.reason} onChange={(event)=>setAcceptanceDraft({...acceptanceDraft,reason:event.target.value})} /></label><fieldset><legend>本次必需依赖</legend>{(["oidc_jwks","ark_tls","volc_rtc_tls","content_safety_tls"] as const).map((dependency)=><label className="checkbox-label" key={dependency}><input type="checkbox" checked={acceptanceDraft.required_dependencies.includes(dependency)} onChange={()=>toggleAcceptanceDependency(dependency)} />{externalDependencyLabel(dependency)}</label>)}</fieldset><button type="submit" disabled={busy||!acceptanceDraft.candidate_version.trim()||!acceptanceDraft.build_digest.trim()||!acceptanceDraft.scope.trim()||!acceptanceDraft.reason.trim()||acceptanceDraft.required_dependencies.length===0}>{busy?"生成中…":"生成并下载快照"}</button></form>{acceptanceResult?<div className="acceptance-result"><div className="section-heading"><div><h4>最近生成:{acceptanceResult.candidate_version}</h4><p>{formatTimestamp(acceptanceResult.generated_at)} · 快照 {acceptanceResult.id}</p></div><span className={`status ${acceptanceResult.overall_gate_satisfied?"active":"danger"}`}>{acceptanceResult.overall_gate_satisfied?"技术门禁通过":"技术门禁阻断"}</span></div><div className="issue-summary-grid">{acceptanceResult.gates.map((gate)=><article className={gate.satisfied?"issue-gate-pass":"issue-gate-blocked"} key={gate.code}><span>{acceptanceGateLabel(gate.code)}</span><strong>{gate.satisfied?"通过":"阻断"}</strong><small>{gate.summary}</small></article>)}</div>{acceptanceResult.alerts.blocking_items.length?<div className="diagnostic-results"><h4>阻断告警定位</h4>{acceptanceResult.alerts.blocking_items.map((alert)=><article className="diagnostic-card" key={alert.id}><div className="diagnostic-card__head"><div><strong>{alert.rule_code}</strong><span>告警 {alert.id} · 最近命中 {formatTimestamp(alert.last_detected_at)}</span></div><span className={`status ${alert.severity==="CRITICAL"?"danger":"warning"}`}>{alert.severity}</span></div><dl><div><dt>处置状态</dt><dd>{alert.status}</dd></div><div><dt>关联类型</dt><dd>{alert.resource_type||"聚合队列"}</dd></div><div><dt>关联编号</dt><dd>{alert.resource_id||"—"}</dd></div><div><dt>资源核对</dt><dd>{acceptanceResourceStateLabel(alert.resource_state)}</dd></div></dl></article>)}</div>:<p className="empty-copy">当前没有阻断发布的HIGH或CRITICAL告警。</p>}{acceptanceResult.alerts.blocking_items_truncated?<p className="recording-notice recording-notice--error">阻断告警超过100条,下载文件仅包含最早100条;必须在告警工作台查看完整队列。</p>:null}<p className="empty-copy">{acceptanceResult.disclaimer}</p></div>:null}</section>:null}
|
{canOperate?<section className="diagnostic-jobs acceptance-snapshot"><div className="section-heading"><div><p className="eyebrow">RELEASE CANDIDATE EVIDENCE</p><h3>发布候选验收快照</h3><p>一次冻结候选构建、迁移、必需依赖、联调问题、可靠任务和严重告警;输出仅是技术预检证据,不代替发布批准。</p></div></div><form className="integration-issue-form" onSubmit={(event)=>void exportAcceptanceSnapshot(event)}><label><span>候选版本</span><input required maxLength={128} pattern="[A-Za-z0-9][A-Za-z0-9._+\-]*" value={acceptanceDraft.candidate_version} onChange={(event)=>setAcceptanceDraft({...acceptanceDraft,candidate_version:event.target.value})} placeholder="如 0.55.0-rc.1" /></label><label><span>构建摘要</span><input required pattern="sha256:[A-Fa-f0-9]{64}" value={acceptanceDraft.build_digest} onChange={(event)=>setAcceptanceDraft({...acceptanceDraft,build_digest:event.target.value})} placeholder="sha256: 后跟64位十六进制" /></label><label><span>目标环境</span><select value={acceptanceDraft.environment} onChange={(event)=>setAcceptanceDraft({...acceptanceDraft,environment:event.target.value as OpsAcceptanceSnapshotRequest["environment"]})}><option value="LAB">实验室</option><option value="STAGING">预发布</option><option value="PRODUCTION">生产</option></select></label><label><span>验收类型</span><select value={acceptanceDraft.run_type} onChange={(event)=>setAcceptanceDraft({...acceptanceDraft,run_type:event.target.value as OpsAcceptanceSnapshotRequest["run_type"]})}><option value="REAL_DEVICE_PROVIDER">真实设备与供应商</option><option value="PILOT">灰度试点</option><option value="PRODUCTION_RELEASE">生产发布</option></select></label><label><span>验收范围</span><textarea required maxLength={500} value={acceptanceDraft.scope} onChange={(event)=>setAcceptanceDraft({...acceptanceDraft,scope:event.target.value})} /></label><label><span>归档原因</span><textarea required maxLength={255} value={acceptanceDraft.reason} onChange={(event)=>setAcceptanceDraft({...acceptanceDraft,reason:event.target.value})} /></label><fieldset><legend>本次必需依赖</legend>{(["oidc_jwks","ark_tls","volc_rtc_tls","content_safety_tls"] as const).map((dependency)=><label className="checkbox-label" key={dependency}><input type="checkbox" checked={acceptanceDraft.required_dependencies.includes(dependency)} onChange={()=>toggleAcceptanceDependency(dependency)} />{externalDependencyLabel(dependency)}</label>)}</fieldset><button type="submit" disabled={busy||!acceptanceDraft.candidate_version.trim()||!acceptanceDraft.build_digest.trim()||!acceptanceDraft.scope.trim()||!acceptanceDraft.reason.trim()||acceptanceDraft.required_dependencies.length===0}>{busy?"生成中…":"生成并下载快照"}</button></form>{acceptanceResult?<div className="acceptance-result"><div className="section-heading"><div><h4>最近生成:{acceptanceResult.candidate_version}</h4><p>{formatTimestamp(acceptanceResult.generated_at)} · 快照 {acceptanceResult.id}</p></div><span className={`status ${acceptanceResult.overall_gate_satisfied?"active":"danger"}`}>{acceptanceResult.overall_gate_satisfied?"技术门禁通过":"技术门禁阻断"}</span></div><div className="issue-summary-grid">{acceptanceResult.gates.map((gate)=><article className={gate.satisfied?"issue-gate-pass":"issue-gate-blocked"} key={gate.code}><span>{acceptanceGateLabel(gate.code)}</span><strong>{gate.satisfied?"通过":"阻断"}</strong><small>{gate.summary}</small></article>)}</div>{acceptanceResult.alerts.blocking_items.length?<div className="diagnostic-results"><h4>阻断告警定位</h4>{acceptanceResult.alerts.blocking_items.map((alert)=><article className="diagnostic-card" key={alert.id}><div className="diagnostic-card__head"><div><strong>{alert.rule_code}</strong><span>告警 {alert.id} · 最近命中 {formatTimestamp(alert.last_detected_at)}</span></div><span className={`status ${alert.severity==="CRITICAL"?"danger":"warning"}`}>{alert.severity}</span></div><dl><div><dt>处置状态</dt><dd>{alert.status}</dd></div><div><dt>关联类型</dt><dd>{alert.resource_type||"聚合队列"}</dd></div><div><dt>关联编号</dt><dd>{alert.resource_id||"—"}</dd></div><div><dt>资源核对</dt><dd>{acceptanceResourceStateLabel(alert.resource_state)}</dd></div></dl></article>)}</div>:<p className="empty-copy">当前没有阻断发布的HIGH或CRITICAL告警。</p>}{acceptanceResult.alerts.blocking_items_truncated?<p className="recording-notice recording-notice--error">阻断告警超过100条,下载文件仅包含最早100条;必须在告警工作台查看完整队列。</p>:null}<p className="empty-copy">{acceptanceResult.disclaimer}</p></div>:null}</section>:null}
|
||||||
{canOperate?<section className="diagnostic-jobs pilot-daily-snapshot"><div className="section-heading"><div><p className="eyebrow">PILOT DAILY OPERATIONS</p><h3>试点运行日报</h3><p>按业务时区自然日汇总设备、通话、供应商、上传、告警和费用;下载内容不含字幕、录音、个人资料和供应商任务编号。</p></div></div><form className="integration-issue-form" onSubmit={(event)=>void exportPilotDailySnapshot(event)}><label><span>业务日期</span><input required type="date" max={localDateInput()} value={pilotDailyDraft.local_date} onChange={(event)=>setPilotDailyDraft({...pilotDailyDraft,local_date:event.target.value})} /></label><label><span>IANA 时区</span><input required maxLength={80} value={pilotDailyDraft.timezone} onChange={(event)=>setPilotDailyDraft({...pilotDailyDraft,timezone:event.target.value})} placeholder="Asia/Shanghai" /></label><label><span>归档原因</span><textarea required maxLength={255} value={pilotDailyDraft.reason} onChange={(event)=>setPilotDailyDraft({...pilotDailyDraft,reason:event.target.value})} /></label><button type="submit" disabled={busy||!pilotDailyDraft.candidate_version.trim()||!pilotDailyDraft.build_digest.trim()||!pilotDailyDraft.local_date||!pilotDailyDraft.timezone.trim()||!pilotDailyDraft.reason.trim()}>{busy?"生成中…":"生成并下载日报"}</button></form>{pilotDailyResult?<div className="acceptance-result"><div className="section-heading"><div><h4>最近生成:{pilotDailyResult.local_date}</h4><p>{pilotDailyResult.timezone} · {formatTimestamp(pilotDailyResult.generated_at)} · 日报 {pilotDailyResult.id}</p></div><span className="status active">已脱敏归档</span></div><div className="issue-summary-grid"><article><span>可用设备</span><strong>{pilotDailyResult.devices.eligible}</strong><small>当前在线 {pilotDailyResult.devices.online}</small></article><article><span>当日通话</span><strong>{pilotDailyResult.conversations.total}</strong><small>失败 {pilotStatusCount(pilotDailyResult,"FAILED")}</small></article><article><span>上传积压</span><strong>{pilotDailyResult.uploads.open_batches}</strong><small>待处理分段 {pilotDailyResult.uploads.pending_segments}</small></article><article className={pilotDailyResult.alerts.high_open+pilotDailyResult.alerts.critical_open?"issue-gate-blocked":"issue-gate-pass"}><span>高严重度告警</span><strong>{pilotDailyResult.alerts.high_open+pilotDailyResult.alerts.critical_open}</strong><small>全部未关闭 {pilotDailyResult.alerts.open}</small></article></div><div className="diagnostic-results"><article className="diagnostic-card"><div className="diagnostic-card__head"><div><strong>供应商运行</strong><span>{pilotDailyResult.providers.length} 个供应商有运行记录</span></div></div><p>{pilotDailyResult.providers.length?pilotDailyResult.providers.map((item)=>`${item.provider}:${item.current_status},失败通知${item.failed_events_during_day},失败通话${item.failed_calls_during_day},积压${item.notification_backlog}`).join(";"):"所选日期和当前状态中没有供应商记录。"}</p></article><article className="diagnostic-card"><div className="diagnostic-card__head"><div><strong>当日费用</strong><span>按币种分列,不做汇率换算</span></div></div><p>{pilotDailyResult.costs.length?pilotDailyResult.costs.map((item)=>`${item.currency} ${(item.cost_micros/1_000_000).toFixed(6)}(${item.line_count}条)`).join(";"):"所选自然日没有费用明细。"}</p></article></div><p className="empty-copy">{pilotDailyResult.disclaimer}</p></div>:null}</section>:null}
|
{canOperate?<section className="diagnostic-jobs pilot-daily-snapshot"><div className="section-heading"><div><p className="eyebrow">PILOT DAILY OPERATIONS</p><h3>试点运行日报</h3><p>按业务时区自然日汇总设备、通话、供应商、上传、告警和费用;下载内容不含字幕、录音、个人资料和供应商任务编号。</p></div></div><form className="integration-issue-form" onSubmit={(event)=>void exportPilotDailySnapshot(event)}><label><span>业务日期</span><input required type="date" max={localDateInput()} value={pilotDailyDraft.local_date} onChange={(event)=>setPilotDailyDraft({...pilotDailyDraft,local_date:event.target.value})} /></label><label><span>IANA 时区</span><input required maxLength={80} value={pilotDailyDraft.timezone} onChange={(event)=>setPilotDailyDraft({...pilotDailyDraft,timezone:event.target.value})} placeholder="Asia/Shanghai" /></label><label><span>归档原因</span><textarea required maxLength={255} value={pilotDailyDraft.reason} onChange={(event)=>setPilotDailyDraft({...pilotDailyDraft,reason:event.target.value})} /></label><button type="submit" disabled={busy||!pilotDailyDraft.candidate_version.trim()||!pilotDailyDraft.build_digest.trim()||!pilotDailyDraft.local_date||!pilotDailyDraft.timezone.trim()||!pilotDailyDraft.reason.trim()}>{busy?"生成中…":"生成并下载日报"}</button></form>{pilotDailyResult?<div className="acceptance-result"><div className="section-heading"><div><h4>最近生成:{pilotDailyResult.local_date}</h4><p>{pilotDailyResult.timezone} · {formatTimestamp(pilotDailyResult.generated_at)} · 日报 {pilotDailyResult.id}</p></div><span className="status active">已脱敏归档</span></div><div className="issue-summary-grid"><article><span>可用设备</span><strong>{pilotDailyResult.devices.eligible}</strong><small>当前在线 {pilotDailyResult.devices.online}</small></article><article><span>当日通话</span><strong>{pilotDailyResult.conversations.total}</strong><small>失败 {pilotStatusCount(pilotDailyResult,"FAILED")}</small></article><article><span>上传积压</span><strong>{pilotDailyResult.uploads.open_batches}</strong><small>待处理分段 {pilotDailyResult.uploads.pending_segments}</small></article><article className={pilotDailyResult.alerts.high_open+pilotDailyResult.alerts.critical_open?"issue-gate-blocked":"issue-gate-pass"}><span>高严重度告警</span><strong>{pilotDailyResult.alerts.high_open+pilotDailyResult.alerts.critical_open}</strong><small>全部未关闭 {pilotDailyResult.alerts.open}</small></article></div><div className="diagnostic-results"><article className="diagnostic-card"><div className="diagnostic-card__head"><div><strong>供应商运行</strong><span>{pilotDailyResult.providers.length} 个供应商有运行记录</span></div></div><p>{pilotDailyResult.providers.length?pilotDailyResult.providers.map((item)=>`${item.provider}:${item.current_status},失败通知${item.failed_events_during_day},失败通话${item.failed_calls_during_day},积压${item.notification_backlog}`).join(";"):"所选日期和当前状态中没有供应商记录。"}</p></article><article className="diagnostic-card"><div className="diagnostic-card__head"><div><strong>当日费用</strong><span>按币种分列,不做汇率换算</span></div></div><p>{pilotDailyResult.costs.length?pilotDailyResult.costs.map((item)=>`${item.currency} ${(item.cost_micros/1_000_000).toFixed(6)}(${item.line_count}条)`).join(";"):"所选自然日没有费用明细。"}</p></article></div><p className="empty-copy">{pilotDailyResult.disclaimer}</p></div>:null}</section>:null}
|
||||||
{canOperate?<section className="diagnostic-jobs pilot-weekly-snapshot"><div className="section-heading"><div><p className="eyebrow">PILOT WEEKLY REVIEW</p><h3>试点运行周报</h3><p>按周一开始的业务自然周汇总内容审核、记忆、隐私请求、固件和配置差异;投诉仍需附客服或CRM权威证据。</p></div></div><form className="integration-issue-form" onSubmit={(event)=>void exportPilotWeeklySnapshot(event)}><label><span>周开始日期(周一)</span><input required type="date" max={localDateInput()} value={pilotWeeklyDraft.week_start} onChange={(event)=>setPilotWeeklyDraft({...pilotWeeklyDraft,week_start:event.target.value})} /></label><label><span>IANA 时区</span><input required maxLength={80} value={pilotWeeklyDraft.timezone} onChange={(event)=>setPilotWeeklyDraft({...pilotWeeklyDraft,timezone:event.target.value})} placeholder="Asia/Shanghai" /></label><label><span>归档原因</span><textarea required maxLength={255} value={pilotWeeklyDraft.reason} onChange={(event)=>setPilotWeeklyDraft({...pilotWeeklyDraft,reason:event.target.value})} /></label><button type="submit" disabled={busy||!pilotWeeklyDraft.candidate_version.trim()||!pilotWeeklyDraft.build_digest.trim()||!pilotWeeklyDraft.week_start||!pilotWeeklyDraft.timezone.trim()||!pilotWeeklyDraft.reason.trim()}>{busy?"生成中…":"生成并下载周报"}</button></form>{pilotWeeklyResult?<div className="acceptance-result"><div className="section-heading"><div><h4>最近生成:{pilotWeeklyResult.week_start} 起始周</h4><p>{pilotWeeklyResult.timezone} · {formatTimestamp(pilotWeeklyResult.generated_at)} · 周报 {pilotWeeklyResult.id}</p></div><span className={`status ${pilotWeeklyResult.is_complete?"active":"warning"}`}>{pilotWeeklyResult.is_complete?"完整自然周":"周中快照"}</span></div><div className="issue-summary-grid"><article><span>人工修订率</span><strong>{basisPointsLabel(pilotWeeklyResult.content_review.manual_revision_rate_available,pilotWeeklyResult.content_review.manual_revision_rate_basis_points)}</strong><small>{pilotWeeklyResult.content_review.stories_manually_revised}/{pilotWeeklyResult.content_review.stories_review_touched} 个审核故事</small></article><article><span>故事确认率</span><strong>{basisPointsLabel(pilotWeeklyResult.content_review.story_confirmation_rate_available,pilotWeeklyResult.content_review.story_confirmation_rate_basis_points)}</strong><small>确认 {pilotWeeklyResult.content_review.stories_confirmed} · 拒绝 {pilotWeeklyResult.content_review.stories_rejected}</small></article><article><span>隐私请求</span><strong>{pilotWeeklyResult.privacy.requests_created_during_week}</strong><small>当前未完成 {pilotWeeklyResult.privacy.current_open}</small></article><article className={pilotWeeklyResult.config.drifted+pilotWeeklyResult.config.unreported?"issue-gate-blocked":"issue-gate-pass"}><span>配置差异</span><strong>{pilotWeeklyResult.config.drifted+pilotWeeklyResult.config.unreported}</strong><small>一致 {pilotWeeklyResult.config.aligned}/{pilotWeeklyResult.config.eligible_devices}</small></article></div><div className="diagnostic-results"><article className="diagnostic-card"><div className="diagnostic-card__head"><div><strong>记忆确认与索引</strong><span>首版 {pilotWeeklyResult.content_review.memories_created} · 修订 {pilotWeeklyResult.content_review.memory_revisions}</span></div></div><p>当前索引待处理 {pilotWeeklyResult.content_review.current_memory_index_pending},失败 {pilotWeeklyResult.content_review.current_memory_index_failed}。</p></article><article className="diagnostic-card"><div className="diagnostic-card__head"><div><strong>固件分布</strong><span>{pilotWeeklyResult.firmware_versions.reduce((sum,item)=>sum+item.device_count,0)} 台可用设备</span></div></div><p>{pilotWeeklyResult.firmware_versions.length?pilotWeeklyResult.firmware_versions.map((item)=>`${item.model}/${item.firmware_version}:${item.device_count}台`).join(";"):"当前没有可用且已绑定设备。"}</p></article></div>{pilotWeeklyResult.external_evidence.map((item)=><p className="recording-notice recording-notice--error" key={item.code}><strong>外部证据仍需补充:投诉</strong><br />{item.reason}</p>)}<p className="empty-copy">{pilotWeeklyResult.disclaimer}</p></div>:null}</section>:null}
|
{canOperate?<section className="diagnostic-jobs pilot-weekly-snapshot"><div className="section-heading"><div><p className="eyebrow">PILOT WEEKLY REVIEW</p><h3>试点运行周报</h3><p>按周一开始的业务自然周汇总内容审核、记忆、隐私请求、固件和配置差异;投诉仍需附客服或CRM权威证据。</p></div></div><form className="integration-issue-form" onSubmit={(event)=>void exportPilotWeeklySnapshot(event)}><label><span>周开始日期(周一)</span><input required type="date" max={localDateInput()} value={pilotWeeklyDraft.week_start} onChange={(event)=>setPilotWeeklyDraft({...pilotWeeklyDraft,week_start:event.target.value})} /></label><label><span>IANA 时区</span><input required maxLength={80} value={pilotWeeklyDraft.timezone} onChange={(event)=>setPilotWeeklyDraft({...pilotWeeklyDraft,timezone:event.target.value})} placeholder="Asia/Shanghai" /></label><label><span>归档原因</span><textarea required maxLength={255} value={pilotWeeklyDraft.reason} onChange={(event)=>setPilotWeeklyDraft({...pilotWeeklyDraft,reason:event.target.value})} /></label><button type="submit" disabled={busy||!pilotWeeklyDraft.candidate_version.trim()||!pilotWeeklyDraft.build_digest.trim()||!pilotWeeklyDraft.week_start||!pilotWeeklyDraft.timezone.trim()||!pilotWeeklyDraft.reason.trim()}>{busy?"生成中…":"生成并下载周报"}</button></form>{pilotWeeklyResult?<div className="acceptance-result"><div className="section-heading"><div><h4>最近生成:{pilotWeeklyResult.week_start} 起始周</h4><p>{pilotWeeklyResult.timezone} · {formatTimestamp(pilotWeeklyResult.generated_at)} · 周报 {pilotWeeklyResult.id}</p></div><span className={`status ${pilotWeeklyResult.is_complete?"active":"warning"}`}>{pilotWeeklyResult.is_complete?"完整自然周":"周中快照"}</span></div><div className="issue-summary-grid"><article><span>人工修订率</span><strong>{basisPointsLabel(pilotWeeklyResult.content_review.manual_revision_rate_available,pilotWeeklyResult.content_review.manual_revision_rate_basis_points)}</strong><small>{pilotWeeklyResult.content_review.stories_manually_revised}/{pilotWeeklyResult.content_review.stories_review_touched} 个审核故事</small></article><article><span>故事确认率</span><strong>{basisPointsLabel(pilotWeeklyResult.content_review.story_confirmation_rate_available,pilotWeeklyResult.content_review.story_confirmation_rate_basis_points)}</strong><small>确认 {pilotWeeklyResult.content_review.stories_confirmed} · 拒绝 {pilotWeeklyResult.content_review.stories_rejected}</small></article><article><span>隐私请求</span><strong>{pilotWeeklyResult.privacy.requests_created_during_week}</strong><small>当前未完成 {pilotWeeklyResult.privacy.current_open}</small></article><article className={pilotWeeklyResult.config.drifted+pilotWeeklyResult.config.unreported?"issue-gate-blocked":"issue-gate-pass"}><span>配置差异</span><strong>{pilotWeeklyResult.config.drifted+pilotWeeklyResult.config.unreported}</strong><small>一致 {pilotWeeklyResult.config.aligned}/{pilotWeeklyResult.config.eligible_devices}</small></article></div><div className="diagnostic-results"><article className="diagnostic-card"><div className="diagnostic-card__head"><div><strong>记忆确认与索引</strong><span>首版 {pilotWeeklyResult.content_review.memories_created} · 修订 {pilotWeeklyResult.content_review.memory_revisions}</span></div></div><p>当前索引待处理 {pilotWeeklyResult.content_review.current_memory_index_pending},失败 {pilotWeeklyResult.content_review.current_memory_index_failed}。</p></article><article className="diagnostic-card"><div className="diagnostic-card__head"><div><strong>固件分布</strong><span>{pilotWeeklyResult.firmware_versions.reduce((sum,item)=>sum+item.device_count,0)} 台可用设备</span></div></div><p>{pilotWeeklyResult.firmware_versions.length?pilotWeeklyResult.firmware_versions.map((item)=>`${item.model}/${item.firmware_version}:${item.device_count}台`).join(";"):"当前没有可用且已绑定设备。"}</p></article></div>{pilotWeeklyResult.external_evidence.map((item)=><p className="recording-notice recording-notice--error" key={item.code}><strong>外部证据仍需补充:投诉</strong><br />{item.reason}</p>)}<p className="empty-copy">{pilotWeeklyResult.disclaimer}</p></div>:null}</section>:null}
|
||||||
|
<section className="diagnostic-jobs platform-health"><div className="section-heading"><div><p className="eyebrow">SERVER HEALTH</p><h3>云端主机运行状态</h3><p>主机每五分钟签名上报公网入口、容器、备份、证书、磁盘和定时任务的脱敏结果。</p></div><button type="button" className="button-secondary" disabled={busy} onClick={()=>void loadPlatformHealth()}>刷新状态</button></div>{platformHealth?(()=>{const state=platformHealthState(platformHealth);const critical=platformHealth.checks.filter((item)=>item.status==="critical").length;const warning=platformHealth.checks.filter((item)=>item.status==="warning").length;return <><div className="issue-summary-grid"><article className={state.tone==="active"?"issue-gate-pass":"issue-gate-blocked"}><span>综合状态</span><strong>{state.label}</strong><small>上报 {formatTimestamp(platformHealth.observed_at)}</small></article><article><span>检查项</span><strong>{platformHealth.checks.length}</strong><small>全部脱敏</small></article><article className={critical?"issue-gate-blocked":"issue-gate-pass"}><span>严重异常</span><strong>{critical}</strong><small>需要立即处置</small></article><article className={warning?"issue-gate-blocked":"issue-gate-pass"}><span>预警</span><strong>{warning}</strong><small>需要持续观察</small></article></div><div className="external-dependency-grid">{platformHealth.checks.map((item)=><article className="diagnostic-card" key={item.name}><div className="diagnostic-card__head"><div><strong>{platformHealthCheckLabel(item.name)}</strong><span>{item.name}</span></div><span className={`status ${item.status==="healthy"?"active":item.status==="warning"?"warning":"danger"}`}>{platformHealthCheckStatusLabel(item.status)}</span></div><p>{platformHealthValueLabel(item.name,item.value)}</p></article>)}</div>{state.stale?<p className="recording-notice recording-notice--error">最近上报已超过15分钟,不能继续视为当前状态;请检查健康监测定时器、回调进程和签名配置。</p>:null}</>})():<p className="empty-copy">尚未收到主机健康快照;完成监测回调配置后,首个五分钟周期会自动出现。</p>}</section>
|
||||||
<section className="diagnostic-jobs external-dependencies"><div className="section-heading"><div><p className="eyebrow">EXTERNAL PREFLIGHT</p><h3>外部依赖预检</h3><p>API与Worker周期执行不消耗模型或语音额度的连通性检查,仅保存脱敏结果。</p></div><button type="button" className="button-secondary" disabled={busy} onClick={()=>void loadExternalDependencies()}>刷新状态</button></div>{externalDependencies.length?<div className="external-dependency-grid">{externalDependencies.map((item)=>{const state=externalDependencyState(item);return <article className="diagnostic-card" key={`${item.service_name}-${item.dependency}`}><div className="diagnostic-card__head"><div><strong>{externalDependencyLabel(item.dependency)}</strong><span>{serviceLabel(item.service_name)} · 最近检查 {formatTimestamp(item.checked_at)}</span></div><span className={`status ${state.tone}`}>{state.label}</span></div><dl><div><dt>探针耗时</dt><dd>{item.latency_ms}毫秒</dd></div><div><dt>连续失败</dt><dd>{item.consecutive_failures}次</dd></div><div><dt>最近成功</dt><dd>{formatTimestamp(item.last_success_at)}</dd></div><div><dt>最近失败</dt><dd>{formatTimestamp(item.last_failure_at)}</dd></div></dl>{state.stale?<p>该进程已超过三倍探针周期未更新,不能视为当前可用。</p>:item.last_error_code?<p>归一化错误:<strong>{item.last_error_code}</strong></p>:null}</article>})}</div>:<p className="empty-copy">当前进程尚未写入外部探针结果;本地未启用的方舟或RTC依赖不会伪装为已验收。</p>}</section>
|
<section className="diagnostic-jobs external-dependencies"><div className="section-heading"><div><p className="eyebrow">EXTERNAL PREFLIGHT</p><h3>外部依赖预检</h3><p>API与Worker周期执行不消耗模型或语音额度的连通性检查,仅保存脱敏结果。</p></div><button type="button" className="button-secondary" disabled={busy} onClick={()=>void loadExternalDependencies()}>刷新状态</button></div>{externalDependencies.length?<div className="external-dependency-grid">{externalDependencies.map((item)=>{const state=externalDependencyState(item);return <article className="diagnostic-card" key={`${item.service_name}-${item.dependency}`}><div className="diagnostic-card__head"><div><strong>{externalDependencyLabel(item.dependency)}</strong><span>{serviceLabel(item.service_name)} · 最近检查 {formatTimestamp(item.checked_at)}</span></div><span className={`status ${state.tone}`}>{state.label}</span></div><dl><div><dt>探针耗时</dt><dd>{item.latency_ms}毫秒</dd></div><div><dt>连续失败</dt><dd>{item.consecutive_failures}次</dd></div><div><dt>最近成功</dt><dd>{formatTimestamp(item.last_success_at)}</dd></div><div><dt>最近失败</dt><dd>{formatTimestamp(item.last_failure_at)}</dd></div></dl>{state.stale?<p>该进程已超过三倍探针周期未更新,不能视为当前可用。</p>:item.last_error_code?<p>归一化错误:<strong>{item.last_error_code}</strong></p>:null}</article>})}</div>:<p className="empty-copy">当前进程尚未写入外部探针结果;本地未启用的方舟或RTC依赖不会伪装为已验收。</p>}</section>
|
||||||
<form className="diagnostic-search" onSubmit={(event)=>void search(event)}><label>检索字段<select value={field} onChange={(event)=>setField(event.target.value as typeof field)}>{fields.map(([key,label])=><option value={key} key={key}>{label}</option>)}</select></label><label>精确编号<input value={value} onChange={(event)=>setValue(event.target.value)} placeholder="输入完整编号" /></label><button type="submit" disabled={loading||!value.trim()}>{loading?"查询中…":"查询"}</button>{canOperate?<button type="button" className="button-secondary" disabled={busy||!value.trim()} onClick={openBundleExport}>导出脱敏诊断包</button>:null}</form>
|
<form className="diagnostic-search" onSubmit={(event)=>void search(event)}><label>检索字段<select value={field} onChange={(event)=>setField(event.target.value as typeof field)}>{fields.map(([key,label])=><option value={key} key={key}>{label}</option>)}</select></label><label>精确编号<input value={value} onChange={(event)=>setValue(event.target.value)} placeholder="输入完整编号" /></label><button type="submit" disabled={loading||!value.trim()}>{loading?"查询中…":"查询"}</button>{canOperate?<button type="button" className="button-secondary" disabled={busy||!value.trim()} onClick={openBundleExport}>导出脱敏诊断包</button>:null}</form>
|
||||||
{diagnosticBundleDraft?<form className="sensitive-access-request controlled-operation" aria-label="脱敏诊断包导出" onSubmit={(event)=>void exportBundle(event)}><div className="sensitive-access-request__head"><div><p className="eyebrow">REDACTED DIAGNOSTIC EXPORT</p><h3>导出脱敏诊断包</h3></div><span className="status warning">导出行为写入审计</span></div><p>本次导出固定使用“<strong>{diagnosticFieldLabel(diagnosticBundleDraft.field)}</strong> = <strong>{diagnosticBundleDraft.value}</strong>”的精确检索条件。文件只包含当前脱敏诊断快照,不包含字幕、录音地址、短期凭证或密钥正文。</p><label><span>导出原因</span><textarea autoFocus required maxLength={500} value={diagnosticBundleDraft.reason} onChange={(event)=>setDiagnosticBundleDraft({...diagnosticBundleDraft,reason:event.target.value})} placeholder="说明接收方、联调问题和使用范围;不要粘贴敏感正文" /></label><div className="sensitive-access-request__actions"><button type="button" className="button-secondary" disabled={busy} onClick={()=>setDiagnosticBundleDraft(undefined)}>取消</button><button type="submit" disabled={busy||!diagnosticBundleDraft.reason.trim()}>{busy?"正在生成…":"生成并下载"}</button></div></form>:null}
|
{diagnosticBundleDraft?<form className="sensitive-access-request controlled-operation" aria-label="脱敏诊断包导出" onSubmit={(event)=>void exportBundle(event)}><div className="sensitive-access-request__head"><div><p className="eyebrow">REDACTED DIAGNOSTIC EXPORT</p><h3>导出脱敏诊断包</h3></div><span className="status warning">导出行为写入审计</span></div><p>本次导出固定使用“<strong>{diagnosticFieldLabel(diagnosticBundleDraft.field)}</strong> = <strong>{diagnosticBundleDraft.value}</strong>”的精确检索条件。文件只包含当前脱敏诊断快照,不包含字幕、录音地址、短期凭证或密钥正文。</p><label><span>导出原因</span><textarea autoFocus required maxLength={500} value={diagnosticBundleDraft.reason} onChange={(event)=>setDiagnosticBundleDraft({...diagnosticBundleDraft,reason:event.target.value})} placeholder="说明接收方、联调问题和使用范围;不要粘贴敏感正文" /></label><div className="sensitive-access-request__actions"><button type="button" className="button-secondary" disabled={busy} onClick={()=>setDiagnosticBundleDraft(undefined)}>取消</button><button type="submit" disabled={busy||!diagnosticBundleDraft.reason.trim()}>{busy?"正在生成…":"生成并下载"}</button></div></form>:null}
|
||||||
@@ -143,6 +147,10 @@ function integrationIssueSeverityTone(value:IntegrationIssue["severity"]){return
|
|||||||
function externalDependencyLabel(value:OpsExternalDependency["dependency"]){return ({oidc_jwks:"OIDC签名密钥",ark_tls:"方舟模型服务TLS",volc_rtc_tls:"火山RTC服务TLS",content_safety_tls:"内容安全策略服务TLS"} as Record<OpsExternalDependency["dependency"],string>)[value]}
|
function externalDependencyLabel(value:OpsExternalDependency["dependency"]){return ({oidc_jwks:"OIDC签名密钥",ark_tls:"方舟模型服务TLS",volc_rtc_tls:"火山RTC服务TLS",content_safety_tls:"内容安全策略服务TLS"} as Record<OpsExternalDependency["dependency"],string>)[value]}
|
||||||
function serviceLabel(value:OpsExternalDependency["service_name"]){return ({api:"API进程",worker:"Worker进程",callbacks:"回调进程"} as Record<OpsExternalDependency["service_name"],string>)[value]}
|
function serviceLabel(value:OpsExternalDependency["service_name"]){return ({api:"API进程",worker:"Worker进程",callbacks:"回调进程"} as Record<OpsExternalDependency["service_name"],string>)[value]}
|
||||||
function externalDependencyState(item:OpsExternalDependency){const stale=Date.now()-new Date(item.checked_at).getTime()>item.probe_interval_ms*3;return stale?{label:"结果过期",tone:"warning",stale:true}:item.status==="AVAILABLE"?{label:"可用",tone:"active",stale:false}:{label:"不可用",tone:"danger",stale:false}}
|
function externalDependencyState(item:OpsExternalDependency){const stale=Date.now()-new Date(item.checked_at).getTime()>item.probe_interval_ms*3;return stale?{label:"结果过期",tone:"warning",stale:true}:item.status==="AVAILABLE"?{label:"可用",tone:"active",stale:false}:{label:"不可用",tone:"danger",stale:false}}
|
||||||
|
function platformHealthState(item:PlatformHealthSnapshot){const stale=Date.now()-new Date(item.observed_at).getTime()>15*60*1000;if(stale)return{label:"上报过期",tone:"danger",stale:true};return item.status==="healthy"?{label:"运行正常",tone:"active",stale:false}:item.status==="warning"?{label:"存在预警",tone:"warning",stale:false}:{label:"严重异常",tone:"danger",stale:false}}
|
||||||
|
function platformHealthCheckStatusLabel(value:PlatformHealthSnapshot["checks"][number]["status"]){return value==="healthy"?"正常":value==="warning"?"预警":"严重"}
|
||||||
|
function platformHealthCheckLabel(value:string){const labels:Record<string,string>={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 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<OpsAcceptanceSnapshot["gates"][number]["code"],string>)[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:"高严重度告警"} as Record<OpsAcceptanceSnapshot["gates"][number]["code"],string>)[value]}
|
||||||
function acceptanceResourceStateLabel(value:OpsAcceptanceSnapshot["alerts"]["blocking_items"][number]["resource_state"]){return ({PRESENT:"资源存在",MISSING:"资源已不存在,仍需人工复核关闭",AGGREGATE:"聚合队列",UNKNOWN:"未提供自动核对规则"} as Record<OpsAcceptanceSnapshot["alerts"]["blocking_items"][number]["resource_state"],string>)[value]}
|
function acceptanceResourceStateLabel(value:OpsAcceptanceSnapshot["alerts"]["blocking_items"][number]["resource_state"]){return ({PRESENT:"资源存在",MISSING:"资源已不存在,仍需人工复核关闭",AGGREGATE:"聚合队列",UNKNOWN:"未提供自动核对规则"} as Record<OpsAcceptanceSnapshot["alerts"]["blocking_items"][number]["resource_state"],string>)[value]}
|
||||||
|
|||||||
Reference in New Issue
Block a user