186 changed files with 25614 additions and 840 deletions
+2
View File
@@ -72,6 +72,8 @@ VOLC_RTC_API_VERSION=2024-12-01
VOLC_VOICE_CHAT_TEMPLATE_FILE=
PROVIDER_CALLBACK_SECRET=local-provider-callback-secret-32-bytes-minimum
PROVIDER_CALLBACK_CLOCK_SKEW=5m
PLATFORM_HEALTH_CALLBACK_SECRET=local-platform-health-callback-secret-32-bytes-minimum
PLATFORM_HEALTH_CALLBACK_CLOCK_SKEW=5m
PROVIDER_REQUEST_TIMEOUT=5s
PROVIDER_FAILURE_THRESHOLD=3
PROVIDER_CIRCUIT_COOLDOWN=1m
+4
View File
@@ -42,6 +42,10 @@ jobs:
npm run build
- name: Validate compose
run: docker compose config --quiet
- name: Validate offsite backup authentication and round trip
run: |
bash deploy/server/test-sync-backup-offsite.sh
python3 -B deploy/server/test_public_listener_policy.py
- name: Resolve immutable build identity
run: |
openapi_version="$(awk '/^ version:/{print $2; exit}' openapi/openapi.yaml)"
+5
View File
@@ -2,6 +2,8 @@
.idea/
.vscode/
.DS_Store
__pycache__/
*.py[cod]
*.log
coverage.out
coverage*.out
@@ -10,6 +12,7 @@ coverage*.out
*.exe
/opsconfig
/deploy/server/.env
/deploy/local-rtc/runtime/
bin/
dist/
tmp/
@@ -17,3 +20,5 @@ web/node_modules/
web/dist/
.gocache/
test/e2e/screenshots/
.design-evidence/
device-simulator-windows/story-continuity-rtc/
+21
View File
@@ -0,0 +1,21 @@
# 变更记录
本项目使用语义化版本号。正式生产放行仍以仓库内的发布候选证据和外部门禁为准;里程碑标签只表示代码、迁移、接口和本地质量门禁形成了可追溯基线。
## v0.70.0-rc.1 — 第一阶段里程碑
第一阶段把家庭陪伴业务、真实语音接入基础能力和运营维护能力整理为第一个可运行候选版本。
主要内容:
- 家庭端、维护端和Windows语音终端模拟器形成完整入口。
- 长者档案、分项授权、设备绑定、通话记录、故事、已确认记忆和隐私控制形成闭环。
- 火山RTC/豆包语音、方舟内容摘要、通话内记忆工具和供应商记忆适配具备受控配置入口。
- 运营配置、Agent质量证据、内容质量、设备维护、费用额度和诊断能力形成版本化管理闭环。
- 数据库迁移整理至V42,并把客户账号与运营账号彻底隔离。
- 本地质量门禁覆盖Go格式、静态检查、单元测试、隔离数据库集成测试、OpenAPI类型生成、Web类型检查与构建、Compose配置校验。
已知边界:
- 该标签是发布候选里程碑,不代表生产外部证据门禁已经通过。
- 真实终端、真实供应商、Safari、云基础设施和试点运行仍按对应证据文档验收。
+23 -5
View File
@@ -2,6 +2,8 @@
这是依据《数据采集终端—云端系统研发需求规格说明书》建立的正式产品工程。当前已完成工程框架、公共能力、本地模拟语音闭环、家庭用户SaaS核心业务、字幕到人工确认记忆闭环,以及隐私、可靠设备命令、签名固件灰度、双轨录音、认证SSE状态事件流、具体资源分享、运营配置灰度发布、用量费用额度、告警处置、集成诊断和敏感数据临时提权闭环。
当前代码基线为`v0.70.0-rc.1`第一阶段里程碑。版本范围、主要能力和生产放行边界见[变更记录](CHANGELOG.md)。
## 当前已实现
- Go模块化单体的`api`、`callbacks`和`worker`三个进程入口。
@@ -34,11 +36,15 @@
- 角色化业务导航:家庭用户只加载家庭业务,运营角色可按需切换运行配置、费用额度、告警处置和联调诊断,避免无权限页面与全部工作台同时加载。
- 家庭成员协作:成员列表、一次性邀请/撤销/接受、跨客户组织受邀账号映射、角色变更、成员移除和家庭所有权转移。
- 通话查询API:长者维度游标分页、状态过滤、脱敏详情、冻结快照摘要和完整状态历史。
- 长者扩展档案、稳定事实、授权记录、设备绑定、最终话轮、摘要、故事版本和已确认记忆数据库模型;V8迁移已完成空库升降级验证。
- 长者扩展档案、稳定事实、授权记录、设备绑定、最终话轮、摘要、故事版本、已确认记忆、通话内记忆工具和内容质量反馈数据库模型;迁移已覆盖到V41并执行隔离数据库升降级验证。
- 模型内容候选化:只使用可追溯长者话轮生成候选;人工确认和当前有效记忆授权共同控制长期记忆写入。
- 可切换内容生成器:本地使用确定性适配器,生产可通过火山方舟OpenAI兼容接口生成结构化摘要、开放话题、故事和事实候选;模型网络调用不占用数据库事务,写入前重新核对通话状态、授权和话轮指纹,敏感内容在本地拦截后不发送外部模型。
- 新通话在数据库层按租户、长者、当前授权、状态、敏感级别、有效期和索引状态检索已确认记忆,并把实际版本冻结到背景快照。
- 在`story_extraction`与`personalized_memory`均有效时,新通话还会按同租户同长者带入最近3次已完成通话摘要;它是可撤回的短期连续上下文,不会绕过人工审核成为长期记忆。长者档案可配置最多8个“避免主动提及”话题,并从下一次新通话起随上下文快照生效。
- 可切换记忆向量:本地使用1536维确定性向量,生产可通过火山方舟OpenAI兼容`/embeddings`接口完成索引和查询;两端共用同一模型及维度,外部调用均位于数据库事务外,查询服务临时不可用时通话可无记忆降级启动。
- 通话中受控记忆工具:平台为固定通话和供应商任务签发短期令牌,服务端从数据库反查租户与长者边界;模型不能自行选择查询范围,检索失败返回空结果且不打断语音通话,实际使用按版本留痕。
- 可选供应商原生记忆:数据库始终是主数据,`managed_http`适配器通过可靠任务同步;修订、停用、授权撤回、单条删除和主体隐私删除均清理外部引用并对账,未完成真实账号验证前默认关闭。
- 细粒度内容审核与维护质量页:故事、单条事实和已确认记忆支持九类标准问题反馈;不可覆盖版本和最小质量信号按模型/提示词版本聚合到独立`/maintenance`维护端,不展示私密正文。
- 隐私数据访问请求:所有者幂等提交、Outbox/Worker子任务、八类数据范围盘点、无正文结果和审计。
- 私有数据导出:按长者和分类生成JSON,MinIO私有存储、SHA-256校验、短期签名下载和过期对象清理。
- 主体级隐私删除:精确称呼确认、活动通话保护、立即冻结读取/上传/召回,以及PostgreSQL、Redis、对象存储、pgvector、外部索引和私有导出六子系统的可重试对账与最小删除证明。
@@ -103,13 +109,15 @@ Set-Location E:\Codex\story\platform
Web也允许通过`http://127.0.0.1:3100`访问;本地Keycloak客户端为`localhost`和`127.0.0.1`分别登记了3100生产预览端口与5173前端开发端口,登录回调必须保持当前浏览器来源一致。
本地Web点击“登录研发环境”后可使用`owner`、`editor`或`outsider`三个合成账号,密码均为`LocalOnly123!`。账号只用于本机联调,权限说明见[家庭角色权限矩阵](docs/permission-matrix.md)。
本地家庭端可使用`owner`、`editor`或`outsider`三个合成客户账号,密码均为`LocalOnly123!`。维护中心使用完全独立、且没有家庭成员关系的`operator`(`ops_admin`)或`operator-approver`(`ops_approver`),本地密码均为`OpsLocalOnly123!`。数据库通过`user_account.account_kind`约束客户账号只能是`customer`角色、运维账号只能是运营角色,禁止同一账号同时进入家庭端和维护端。账号只用于本机联调。
维护中心地址为`/maintenance`。其中“设备管理”允许独立运维操作员查看客户组织内全部设备及当前家庭/长者归属,登记或修改设备、调整归属、隔离或软删除设备,并在创建或轮换时自动下载仅显示一次的随机设备密钥。删除会吊销设备和密钥、关闭当前绑定,但保留历史通话与审计记录。
Web镜像在容器启动时根据`WEB_OIDC_AUTHORITY`和`WEB_OIDC_CLIENT_ID`生成不缓存的`runtime-config.js`。dev、preprod和prod可复用同一镜像,只需为各环境设置对应OIDC地址和客户端编号;这些值是浏览器可见的公开配置,不能用于保存客户端密钥。
真实火山RTC模式由两组独立开关启用:API设置`RTC_TOKEN_ISSUER=volcengine`、`RTC_APP_ID`和`RTC_APP_KEY`,Worker设置`VOICE_PROVIDER=volcengine`、`VOLC_ACCESS_KEY_ID`、`VOLC_SECRET_ACCESS_KEY`、区域、接口版本以及`VOLC_VOICE_CHAT_TEMPLATE_FILE`。生产模板采用`voice-chat-template-bundle-v1`,分别提供`s2s_pure`、`s2s_hybrid`和`cascade`三个完整VoiceChat `Config/AgentConfig`;平台按通话冻结模式选择模板,再覆盖AppId、RoomId、TaskId、目标设备用户、智能体用户,并追加本次已授权且脱敏的背景摘要。可从[模板示例](deploy/volcengine/voice-chat-template.example.json)复制结构并按照已开通的官方能力替换每个完整`Config`对象;不要把AppKey、AK/SK、ASR/TTS Token提交到仓库。
真实火山RTC模式由两组独立开关启用:API设置`RTC_TOKEN_ISSUER=volcengine`、`RTC_APP_ID`和`RTC_APP_KEY`,Worker设置`VOICE_PROVIDER=volcengine`、`VOLC_ACCESS_KEY_ID`、`VOLC_SECRET_ACCESS_KEY`、区域、接口版本以及`VOLC_VOICE_CHAT_TEMPLATE_FILE`。生产模板采用`voice-chat-template-bundle-v1`,分别提供`s2s_pure`、`s2s_hybrid`和`cascade`三个完整VoiceChat `Config/AgentConfig`;平台按通话冻结模式选择模板,再覆盖AppId、RoomId、TaskId、目标设备用户、智能体用户,并追加本次已授权且脱敏的背景摘要。级联模式写入`LLMConfig.SystemMessages`,纯端到端模式写入`S2SConfig.ProviderParams.dialog.system_role`。可从[模板示例](deploy/volcengine/voice-chat-template.example.json)复制结构并按照已开通的官方能力替换每个完整`Config`对象;不要把AppKey、AK/SK、ASR/TTS Token提交到仓库。
生产API必须设置`DEVICE_SECRET_DIRECTORY`为容器内绝对路径,并把Docker/Kubernetes Secret或云KMS CSI卷只读挂载到该目录。每台设备的`credential_ref`写为`device-secret://terminal-001`,对应文件名为`terminal-001`,文件内容为至少32字节的独立随机密钥。解析器拒绝相对路径、子目录、越界符号、缺失文件和短密钥;每次验签重新读取文件,因此密钥文件原子轮换后立即生效。`DEVICE_LOCAL_SECRET`只允许本地模拟使用。
设备HMAC密钥保存在`device_credential`表中,并使用AES-256-GCM和每条记录独立随机nonce加密;数据库不保存明文。API通过`DEVICE_CREDENTIAL_MASTER_KEY`(64位十六进制、解码后32字节)解密验签,该主密钥必须由Docker/Kubernetes Secret或云KMS CSI独立托管,不能进入数据库。`DEVICE_LOCAL_SECRET`只用于把既有演示设备凭据一次性导入数据库,完成迁移后可从运行环境移除。
数据库连接串、Redis、RTC AppKey、火山AK/SK、方舟API Key、对象存储凭据、回调/告警签名等敏感配置同时支持`NAME_FILE=/run/secrets/name`。文件路径必须是容器内绝对路径、目标必须是普通文件且不超过64KiB;读取时只去掉末尾换行,不在错误中打印内容。`NAME`与`NAME_FILE`同时存在会拒绝启动,防止轮换时仍意外使用旧的明文环境变量。
@@ -185,6 +193,15 @@ docker compose --profile simulation run --rm sim-device
docker compose --profile simulation run --rm -e SIM_SCENARIO=offline,conversation,transcript,telemetry,commands sim-device
```
在Windows本机使用真实麦克风、扬声器和火山RTC/豆包端到端语音进行联调:
```powershell
.\deploy\local-rtc\configure.ps1
.\deploy\local-rtc\start.ps1
```
然后运行`device-simulator-windows/release/voice-device-simulator-0.1.0-windows-x64.exe`。密钥配置、纯端到端首轮联调范围和操作步骤见[Windows本地真实RTC联调说明](deploy/local-rtc/README.md)。
还可用`SIM_SCENARIO=media`和`SIM_CONVERSATION_ID`验证已授权通话的录音直传、后台校验、延迟删除及设备删除回执;详见`docs/device-integration.md`。
执行设备状态、错误和命令结果的批量遥测、幂等、事件判重及冲突回滚烟测:
@@ -326,13 +343,14 @@ internal/ 平台公共能力及后续领域模块
migrations/ 仅追加的数据库迁移
openapi/ 对外接口契约基线
web/ 家庭用户和运营网页
device-simulator-windows/ Windows真实音频终端模拟器
deploy/ 容器构建与运行配置
docs/ 架构决策与研发基线
scripts/ 本地启动和质量检查脚本
test/ 契约、集成和端到端测试
```
公共接口约束与监控指标见[可观测性与接口治理设计](docs/observability-and-api-governance.md),设备签名和模拟联调见[设备接入与启动配置联调说明](docs/device-integration.md),设备命令见[设备命令可靠下发说明](docs/device-commands.md),固件流程见[固件制品、签名、灰度与回滚开发说明](docs/firmware-rollout.md),A/B升级与启动失败回退见[设备A/B分区固件升级与启动确认协议](docs/device-firmware-ab-protocol.md),设备/云端时间线对账见[固件真机联调一致性证据采集与校验说明](docs/firmware-conformance-evidence.md),双轨录音见[双轨录音上传、校验与访问开发说明](docs/media-upload-and-access.md),网页状态通知见[Web状态事件流开发说明](docs/web-event-stream.md),具体分享见[具体资源分享开发说明](docs/resource-sharing.md),运营配置见[内部运营身份与运行配置版本开发说明](docs/operations-config.md),用量费用与额度见[用量、费用与额度开发说明](docs/usage-cost-quota.md),告警闭环见[告警监测与运营处置开发说明](docs/alert-operations.md),真实网关归档见[告警接收器一致性证据](docs/alert-receiver-conformance-evidence.md),云密钥轮换见[KMS/HSM轮换一致性证据](docs/kms-hsm-rotation-evidence.md),生产恢复见[生产等价恢复演练一致性证据](docs/production-recovery-evidence.md),生产基础设施见[生产基础设施一致性证据](docs/production-infrastructure-evidence.md),联调工作台及发布候选验收快照见[集成诊断工作台开发说明](docs/integration-diagnostics.md),试点每日证据见[试点运行日报开发说明](docs/pilot-daily-operations.md),试点周度复盘口径、外部投诉缺口、接口和轻量烟测见[试点运行周报开发说明](docs/pilot-weekly-review.md),敏感数据提权见[运营敏感数据临时访问开发说明](docs/ops-sensitive-access.md),外部上线阶段见[真机、真实供应商、试点与生产发布外部验收清单](docs/external-acceptance-checklist.md),生产门禁和压测告警见[压测、告警与生产安全门禁](docs/load-alerting-and-production-security.md),专项实测证据见[性能、恢复、安全与兼容性专项验证报告](docs/performance-recovery-security-compatibility.md),隐私数据访问见[隐私数据请求开发说明](docs/privacy-requests.md),私有导出见[私有数据导出开发说明](docs/data-export.md)。
公共接口约束与监控指标见[可观测性与接口治理设计](docs/observability-and-api-governance.md),设备签名和模拟联调见[设备接入与启动配置联调说明](docs/device-integration.md),设备命令见[设备命令可靠下发说明](docs/device-commands.md),固件流程见[固件制品、签名、灰度与回滚开发说明](docs/firmware-rollout.md),A/B升级与启动失败回退见[设备A/B分区固件升级与启动确认协议](docs/device-firmware-ab-protocol.md),设备/云端时间线对账见[固件真机联调一致性证据采集与校验说明](docs/firmware-conformance-evidence.md),双轨录音见[双轨录音上传、校验与访问开发说明](docs/media-upload-and-access.md),网页状态通知见[Web状态事件流开发说明](docs/web-event-stream.md),具体分享见[具体资源分享开发说明](docs/resource-sharing.md),运营配置见[内部运营身份与运行配置版本开发说明](docs/operations-config.md),用量费用与额度见[用量、费用与额度开发说明](docs/usage-cost-quota.md),告警闭环见[告警监测与运营处置开发说明](docs/alert-operations.md),真实网关归档见[告警接收器一致性证据](docs/alert-receiver-conformance-evidence.md),云KMS轮换见[KMS/HSM轮换一致性证据](docs/kms-hsm-rotation-evidence.md),生产恢复见[生产等价恢复演练一致性证据](docs/production-recovery-evidence.md),单机云端备份见[自动备份与隔离恢复手册](docs/server-backup-and-restore.md),OSS异地备份见[阿里云OSS异地备份适配与门禁](docs/offsite-backup.md),服务器健康检查见[单机云端健康监测与告警手册](docs/server-health-monitoring.md),生产基础设施见[生产基础设施一致性证据](docs/production-infrastructure-evidence.md),联调工作台及发布候选验收快照见[集成诊断工作台开发说明](docs/integration-diagnostics.md),试点每日证据见[试点运行日报开发说明](docs/pilot-daily-operations.md),试点周度复盘口径、外部投诉缺口、接口和轻量烟测见[试点运行周报开发说明](docs/pilot-weekly-review.md),敏感数据提权见[运营敏感数据临时访问开发说明](docs/ops-sensitive-access.md),外部上线阶段见[真机、真实供应商、试点与生产发布外部验收清单](docs/external-acceptance-checklist.md),生产门禁和压测告警见[压测、告警与生产安全门禁](docs/load-alerting-and-production-security.md),专项实测证据见[性能、恢复、安全与兼容性专项验证报告](docs/performance-recovery-security-compatibility.md),隐私数据访问见[隐私数据请求开发说明](docs/privacy-requests.md),私有导出见[私有数据导出开发说明](docs/data-export.md)。
从框架搭建到生产落地的阶段任务、里程碑、人员、测试门禁、依赖和风险见[详细开发计划](docs/development-plan.md),当前实际完成证据见[开发状态与门禁记录](docs/development-status.md)。
+85
View File
@@ -0,0 +1,85 @@
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"data-terminal-cloud/internal/deviceauth"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
if err := run(context.Background(), os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(ctx context.Context, args []string) error {
if len(args) != 2 || (args[0] != "provision" && args[0] != "rotate" && args[0] != "revoke") {
return errors.New("usage: device-credential <provision|rotate|revoke> <device-public-uuid>")
}
if _, err := uuid.Parse(args[1]); err != nil {
return errors.New("device public id must be a UUID")
}
databaseURL, err := secretEnvironment("DATABASE_URL")
if err != nil {
return err
}
masterKey, err := secretEnvironment("DEVICE_CREDENTIAL_MASTER_KEY")
if err != nil {
return err
}
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer pool.Close()
resolver, err := deviceauth.NewDatabaseSecretResolver(pool, masterKey)
if err != nil {
return err
}
if args[0] == "revoke" {
if err := resolver.Revoke(ctx, args[1]); err != nil {
return err
}
return json.NewEncoder(os.Stdout).Encode(map[string]any{"device_id": args[1], "status": "revoked"})
}
random := make([]byte, 32)
if _, err := rand.Read(random); err != nil {
return fmt.Errorf("generate device credential: %w", err)
}
secret := hex.EncodeToString(random)
version, err := resolver.Provision(ctx, args[1], []byte(secret), args[0] == "rotate")
if err != nil {
return err
}
fmt.Fprintln(os.Stderr, "device credential plaintext is shown once; transfer it through a controlled channel")
return json.NewEncoder(os.Stdout).Encode(map[string]any{
"device_id": args[1], "key_version": version, "secret": secret,
})
}
func secretEnvironment(name string) (string, error) {
if file := strings.TrimSpace(os.Getenv(name + "_FILE")); file != "" {
value, err := os.ReadFile(file)
if err != nil {
return "", fmt.Errorf("read %s_FILE: %w", name, err)
}
if result := strings.TrimSpace(string(value)); result != "" {
return result, nil
}
}
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value, nil
}
return "", fmt.Errorf("%s or %s_FILE is required", name, name)
}
+251
View File
@@ -0,0 +1,251 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
volcbase "github.com/volcengine/volc-sdk-golang/base"
)
type responseEnvelope struct {
ResponseMetadata struct {
Error *struct {
Code string `json:"Code"`
Message string `json:"Message"`
} `json:"Error"`
} `json:"ResponseMetadata"`
}
func main() {
akFile := flag.String("ak-file", "", "file containing the Volcengine AccessKey ID")
skFile := flag.String("sk-file", "", "file containing the Volcengine SecretAccessKey")
endpoint := flag.String("endpoint", "https://rtc.volcengineapi.com", "RTC OpenAPI endpoint")
rtcAppID := flag.String("rtc-app-id", "", "RTC application ID used for staged validation probes")
templateFile := flag.String("template-file", "", "voice-chat template bundle used for ProviderParams probes")
roomID := flag.String("room-id", "", "RTC room ID to inspect without starting a task")
flag.Parse()
if *akFile == "" || *skFile == "" {
panic("-ak-file and -sk-file are required")
}
ak := readSecret(*akFile)
sk := readSecret(*skFile)
credentials := volcbase.Credentials{AccessKeyID: ak, SecretAccessKey: sk, Service: "rtc", Region: "cn-north-1"}
client := &http.Client{Timeout: 15 * time.Second}
if *rtcAppID != "" && *roomID != "" {
queryRoom(client, credentials, *endpoint, *rtcAppID, *roomID)
return
}
for _, version := range []string{"2024-06-01", "2024-12-01", "2025-06-01"} {
probe(client, credentials, *endpoint, version, "empty", map[string]any{})
}
if *rtcAppID != "" {
version := "2024-12-01"
identity := map[string]any{
"AppId": *rtcAppID, "RoomId": "probe-room", "TaskId": "probe-task",
}
agent := map[string]any{
"TargetUserId": []string{"probe-user"}, "UserId": "probe-agent",
}
probe(client, credentials, *endpoint, version, "app-only", map[string]any{"AppId": *rtcAppID})
probe(client, credentials, *endpoint, version, "identity-only", map[string]any{
"AppId": *rtcAppID, "RoomId": "probe-room", "TaskId": "probe-task",
})
probe(client, credentials, *endpoint, version, "empty-config", map[string]any{
"AppId": *rtcAppID, "RoomId": "probe-room", "TaskId": "probe-task",
"Config": map[string]any{}, "AgentConfig": map[string]any{},
})
probe(client, credentials, *endpoint, version, "agent-valid", merge(identity, map[string]any{
"Config": map[string]any{}, "AgentConfig": agent,
}))
probe(client, credentials, *endpoint, version, "s2s-shell", merge(identity, map[string]any{
"Config": map[string]any{"S2SConfig": map[string]any{}}, "AgentConfig": agent,
}))
probe(client, credentials, *endpoint, version, "s2s-provider", merge(identity, map[string]any{
"Config": map[string]any{"S2SConfig": map[string]any{
"Provider": "volcano", "OutputMode": 0,
}}, "AgentConfig": agent,
}))
probe(client, credentials, *endpoint, version, "s2s-params-empty", merge(identity, map[string]any{
"Config": map[string]any{"S2SConfig": map[string]any{
"Provider": "volcano", "OutputMode": 0, "ProviderParams": map[string]any{},
}}, "AgentConfig": agent,
}))
if *templateFile != "" {
providerParams := readProviderParams(*templateFile)
for _, item := range []struct {
label string
keys []string
}{
{label: "params-app", keys: []string{"app"}},
{label: "params-dialog", keys: []string{"dialog"}},
{label: "params-asr", keys: []string{"asr"}},
{label: "params-tts", keys: []string{"tts"}},
{label: "params-full", keys: []string{"app", "dialog", "asr", "tts"}},
} {
selected := make(map[string]any, len(item.keys))
for _, key := range item.keys {
if value, ok := providerParams[key]; ok {
selected[key] = value
}
}
probeIdentity := merge(identity, map[string]any{"TaskId": "probe-" + item.label})
payload := merge(probeIdentity, map[string]any{
"Config": map[string]any{"S2SConfig": map[string]any{
"Provider": "volcano", "OutputMode": 0, "ProviderParams": selected,
}}, "AgentConfig": agent,
})
if probeAction(client, credentials, *endpoint, version, item.label, "StartVoiceChat", payload) {
probeAction(client, credentials, *endpoint, version, item.label+"-cleanup", "StopVoiceChat", probeIdentity)
}
}
fullTemplate := readHybridTemplate(*templateFile)
fullIdentity := merge(identity, map[string]any{"TaskId": "probe-full-template"})
fullTemplate = merge(fullTemplate, fullIdentity)
fullAgent, _ := fullTemplate["AgentConfig"].(map[string]any)
fullAgent["TargetUserId"] = []string{"probe-user"}
fullAgent["UserId"] = "probe-agent"
if probeAction(client, credentials, *endpoint, version, "full-template", "StartVoiceChat", fullTemplate) {
probeAction(client, credentials, *endpoint, version, "full-template-cleanup", "StopVoiceChat", fullIdentity)
}
}
probeAction(client, credentials, *endpoint, version, "cleanup", "StopVoiceChat", identity)
}
}
func queryRoom(client *http.Client, credentials volcbase.Credentials, endpoint, appID, roomID string) {
target, err := url.Parse(endpoint)
if err != nil {
panic(err)
}
query := target.Query()
query.Set("Action", "GetRoomOnlineUsers")
query.Set("Version", "2023-11-01")
query.Set("AppId", appID)
query.Set("RoomId", roomID)
query.Set("Property", "false")
target.RawQuery = query.Encode()
request, err := http.NewRequest(http.MethodGet, target.String(), nil)
if err != nil {
panic(err)
}
request.Host = target.Host
request.Header.Set("Accept", "application/json")
credentials.Sign(request)
response, err := client.Do(request)
if err != nil {
panic(err)
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
panic(err)
}
var decoded map[string]any
if err := json.Unmarshal(body, &decoded); err != nil {
panic(err)
}
encoded, _ := json.Marshal(decoded)
fmt.Printf("room-status=%d response=%s\n", response.StatusCode, encoded)
}
func readProviderParams(path string) map[string]any {
template := readHybridTemplate(path)
config, _ := template["Config"].(map[string]any)
s2s, _ := config["S2SConfig"].(map[string]any)
params, _ := s2s["ProviderParams"].(map[string]any)
if params == nil {
panic("template does not contain s2s_hybrid Config.S2SConfig.ProviderParams")
}
return params
}
func readHybridTemplate(path string) map[string]any {
raw, err := os.ReadFile(path)
if err != nil {
panic(err)
}
var bundle map[string]any
if err := json.Unmarshal(raw, &bundle); err != nil {
panic(err)
}
templates, _ := bundle["templates"].(map[string]any)
template, _ := templates["s2s_hybrid"].(map[string]any)
if template == nil {
panic("template does not contain s2s_hybrid")
}
return template
}
func merge(base map[string]any, extra map[string]any) map[string]any {
result := make(map[string]any, len(base)+len(extra))
for key, value := range base {
result[key] = value
}
for key, value := range extra {
result[key] = value
}
return result
}
func readSecret(path string) string {
value, err := os.ReadFile(path)
if err != nil {
panic(err)
}
return strings.TrimSpace(string(value))
}
func probe(client *http.Client, credentials volcbase.Credentials, endpoint, version, label string, payload map[string]any) {
probeAction(client, credentials, endpoint, version, label, "StartVoiceChat", payload)
}
func probeAction(client *http.Client, credentials volcbase.Credentials, endpoint, version, label, action string, payload map[string]any) bool {
target, err := url.Parse(endpoint)
if err != nil {
panic(err)
}
query := target.Query()
query.Set("Action", action)
query.Set("Version", version)
target.RawQuery = query.Encode()
bodyBytes, err := json.Marshal(payload)
if err != nil {
panic(err)
}
request, err := http.NewRequest(http.MethodPost, target.String(), bytes.NewReader(bodyBytes))
if err != nil {
panic(err)
}
request.Host = target.Host
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
credentials.Sign(request)
response, err := client.Do(request)
if err != nil {
fmt.Printf("label=%s version=%s transport_error=%v\n", label, version, err)
return false
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
fmt.Printf("label=%s version=%s status=%d read_error=%v\n", label, version, response.StatusCode, err)
return false
}
var envelope responseEnvelope
_ = json.Unmarshal(body, &envelope)
if envelope.ResponseMetadata.Error == nil {
fmt.Printf("label=%s version=%s status=%d code=none\n", label, version, response.StatusCode)
return response.StatusCode >= 200 && response.StatusCode < 300
}
fmt.Printf("label=%s version=%s status=%d code=%s message=%s\n", label, version, response.StatusCode,
envelope.ResponseMetadata.Error.Code, envelope.ResponseMetadata.Error.Message)
return false
}
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
volcbase "github.com/volcengine/volc-sdk-golang/base"
)
func main() {
akFile := flag.String("ak-file", "", "file containing the Volcengine AccessKey ID")
skFile := flag.String("sk-file", "", "file containing the Volcengine SecretAccessKey")
resourceID := flag.String("resource-id", "volc.speech.dialog", "speech service resource ID")
projectName := flag.String("project", "default", "Volcengine project name")
flag.Parse()
if *akFile == "" || *skFile == "" {
panic("-ak-file and -sk-file are required")
}
target, _ := url.Parse("https://open.volcengineapi.com")
query := target.Query()
query.Set("Action", "QuotaMonitoring")
query.Set("Version", "2025-05-21")
target.RawQuery = query.Encode()
today := time.Now().Format("2006-01-02")
body, _ := json.Marshal(map[string]any{
"ProjectName": *projectName,
"ResourceID": *resourceID,
"Start": today,
"End": today,
"Mode": "daily",
"QuotaType": "concurrency",
})
request, _ := http.NewRequest(http.MethodPost, target.String(), bytes.NewReader(body))
request.Host = target.Host
request.Header.Set("Content-Type", "application/json; charset=UTF-8")
credentials := volcbase.Credentials{
AccessKeyID: readSecret(*akFile), SecretAccessKey: readSecret(*skFile),
Service: "speech_saas_prod", Region: "cn-beijing",
}
credentials.Sign(request)
response, err := (&http.Client{Timeout: 15 * time.Second}).Do(request)
if err != nil {
panic(err)
}
defer response.Body.Close()
responseBody, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20))
var decoded map[string]any
_ = json.Unmarshal(responseBody, &decoded)
encoded, _ := json.Marshal(decoded)
fmt.Printf("status=%d response=%s\n", response.StatusCode, encoded)
}
func readSecret(path string) string {
value, err := os.ReadFile(path)
if err != nil {
panic(err)
}
return strings.TrimSpace(string(value))
}
+12 -2
View File
@@ -13,7 +13,7 @@ x-backend-environment: &backend-environment
AUTH_ISSUER_URL: http://localhost:58180/realms/voice-local
AUTH_JWKS_URL: http://keycloak:8080/realms/voice-local/protocol/openid-connect/certs
AUTH_AUDIENCE: voice-platform-web
OTEL_EXPORTER_OTLP_ENDPOINT: otel-collector:4317
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
RATE_LIMIT_RPS: "50"
RATE_LIMIT_BURST: "100"
RATE_LIMIT_USER_RPS: "20"
@@ -25,8 +25,17 @@ x-backend-environment: &backend-environment
PROVIDER_START_RPS: "20"
PROVIDER_START_BURST: "40"
PROVIDER_MAX_ACTIVE_TASKS: "100"
MEMORY_TOOL_BASE_URL: ${MEMORY_TOOL_BASE_URL:-http://api:8080/internal/v1/tools/memory-search}
MEMORY_TOOL_SIGNING_SECRET: ${MEMORY_TOOL_SIGNING_SECRET:-local-memory-tool-signing-secret-32-bytes-minimum}
MEMORY_TOOL_TOKEN_TTL: 4h
MEMORY_TOOL_RATE_RPS: "2"
MEMORY_TOOL_RATE_BURST: "5"
PROVIDER_MEMORY_MODE: ${PROVIDER_MEMORY_MODE:-disabled}
PROVIDER_MEMORY_BASE_URL: ${PROVIDER_MEMORY_BASE_URL:-}
PROVIDER_MEMORY_API_KEY: ${PROVIDER_MEMORY_API_KEY:-}
PROVIDER_MEMORY_TIMEOUT: ${PROVIDER_MEMORY_TIMEOUT:-5s}
DEVICE_CLOCK_SKEW: 5m
DEVICE_SECRET_DIRECTORY: ${DEVICE_SECRET_DIRECTORY:-}
DEVICE_CREDENTIAL_MASTER_KEY: ${DEVICE_CREDENTIAL_MASTER_KEY:-000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f}
RTC_APP_ID: local-public-rtc-app
RTC_APP_KEY: ${RTC_APP_KEY:-}
RTC_TOKEN_ISSUER: ${RTC_TOKEN_ISSUER:-local}
@@ -220,6 +229,7 @@ services:
<<: *backend-environment
HTTP_ADDR: 0.0.0.0:8081
PROVIDER_CALLBACK_SECRET: local-provider-callback-secret-32-bytes-minimum
PLATFORM_HEALTH_CALLBACK_SECRET: local-platform-health-callback-secret-32-bytes-minimum
RATE_LIMIT_RPS: "1000"
RATE_LIMIT_BURST: "1500"
ports:
+1
View File
@@ -20,6 +20,7 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w \
-X data-terminal-cloud/internal/buildinfo.Commit=${COMMIT} \
-X data-terminal-cloud/internal/buildinfo.BuildTime=${BUILD_TIME}" -o /out/worker ./cmd/worker && \
CGO_ENABLED=0 go build -trimpath -o /out/sim-device ./cmd/sim-device && \
CGO_ENABLED=0 go build -trimpath -o /out/device-credential ./cmd/device-credential && \
CGO_ENABLED=0 go build -trimpath -o /out/firmware-conformance ./cmd/firmware-conformance && \
CGO_ENABLED=0 go build -trimpath -o /out/alert-receiver-conformance ./cmd/alert-receiver-conformance && \
CGO_ENABLED=0 go build -trimpath -o /out/kms-rotation-conformance ./cmd/kms-rotation-conformance && \
+20
View File
@@ -79,6 +79,26 @@
"emailVerified": true,
"enabled": true,
"credentials": [{"type": "password", "value": "LocalOnly123!", "temporary": false}]
},
{
"id": "00000000-0000-0000-0000-000000000104",
"username": "operator",
"firstName": "平台",
"lastName": "运维管理员",
"email": "operator@example.invalid",
"emailVerified": true,
"enabled": true,
"credentials": [{"type": "password", "value": "OpsLocalOnly123!", "temporary": false}]
},
{
"id": "00000000-0000-0000-0000-000000000105",
"username": "operator-approver",
"firstName": "平台",
"lastName": "运维审批员",
"email": "operator-approver@example.invalid",
"emailVerified": true,
"enabled": true,
"credentials": [{"type": "password", "value": "OpsLocalOnly123!", "temporary": false}]
}
]
}
+5
View File
@@ -60,6 +60,11 @@ data:
ARK_CONTENT_MODEL: replace-with-ark-content-endpoint-id
ARK_EMBEDDING_MODEL: replace-with-ark-embedding-endpoint-id
ARK_REQUEST_TIMEOUT: 30s
MEMORY_TOOL_TOKEN_TTL: 4h
MEMORY_TOOL_RATE_RPS: "2"
MEMORY_TOOL_RATE_BURST: "5"
PROVIDER_MEMORY_MODE: disabled
PROVIDER_MEMORY_TIMEOUT: 5s
WORKER_POLL_INTERVAL: 2s
ALERT_WEBHOOK_TIMEOUT: 5s
---
+8 -8
View File
@@ -55,14 +55,16 @@ spec:
value: /var/run/secrets/voice-platform/common/database-url
- name: REDIS_PASSWORD_FILE
value: /var/run/secrets/voice-platform/common/redis-password
- name: DEVICE_SECRET_DIRECTORY
value: /var/run/secrets/voice-platform/devices
- name: DEVICE_CREDENTIAL_MASTER_KEY_FILE
value: /var/run/secrets/voice-platform/api/device-credential-master-key
- name: RTC_APP_KEY_FILE
value: /var/run/secrets/voice-platform/api/rtc-app-key
- name: CURSOR_SIGNING_SECRET_FILE
value: /var/run/secrets/voice-platform/api/cursor-signing-secret
- name: SHARE_TOKEN_SIGNING_SECRET_FILE
value: /var/run/secrets/voice-platform/api/share-token-signing-secret
- name: MEMORY_TOOL_SIGNING_SECRET_FILE
value: /var/run/secrets/voice-platform/model-storage/memory-tool-signing-secret
- name: OBJECT_STORE_ACCESS_KEY_FILE
value: /var/run/secrets/voice-platform/model-storage/object-store-access-key
- name: OBJECT_STORE_SECRET_KEY_FILE
@@ -114,9 +116,6 @@ spec:
- name: model-storage-secrets
mountPath: /var/run/secrets/voice-platform/model-storage
readOnly: true
- name: device-secrets
mountPath: /var/run/secrets/voice-platform/devices
readOnly: true
volumes:
- name: common-secrets
secret:
@@ -127,9 +126,6 @@ spec:
- name: model-storage-secrets
secret:
secretName: voice-platform-model-storage-secrets
- name: device-secrets
secret:
secretName: voice-platform-device-secrets
---
apiVersion: apps/v1
kind: Deployment
@@ -190,6 +186,8 @@ spec:
value: /var/run/secrets/voice-platform/common/redis-password
- name: PROVIDER_CALLBACK_SECRET_FILE
value: /var/run/secrets/voice-platform/callbacks/provider-callback-secret
- name: PLATFORM_HEALTH_CALLBACK_SECRET_FILE
value: /var/run/secrets/voice-platform/callbacks/platform-health-callback-secret
ports:
- name: http
containerPort: 8081
@@ -309,6 +307,8 @@ spec:
value: /var/run/secrets/voice-platform/model-storage/ark-api-key
- name: CONTENT_SAFETY_API_KEY_FILE
value: /var/run/secrets/voice-platform/model-storage/content-safety-api-key
- name: MEMORY_TOOL_SIGNING_SECRET_FILE
value: /var/run/secrets/voice-platform/model-storage/memory-tool-signing-secret
ports:
- name: http
containerPort: 8082
@@ -19,7 +19,7 @@ metadata:
spec:
provider: replace-with-installed-provider
parameters:
objects: replace-with-provider-specific-rtc-app-key-cursor-and-share-secrets
objects: replace-with-provider-specific-rtc-app-key-cursor-share-and-device-credential-master-key
---
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
@@ -29,7 +29,7 @@ metadata:
spec:
provider: replace-with-installed-provider
parameters:
objects: replace-with-provider-specific-callback-secret
objects: replace-with-provider-specific-provider-and-platform-health-callback-secrets
---
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
@@ -49,14 +49,4 @@ metadata:
spec:
provider: replace-with-installed-provider
parameters:
objects: replace-with-provider-specific-object-store-ark-and-content-safety-credentials
---
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: voice-platform-devices
namespace: voice-platform
spec:
provider: replace-with-installed-provider
parameters:
objects: replace-with-provider-specific-per-device-secrets
objects: replace-with-provider-specific-object-store-ark-content-safety-and-memory-tool-credentials
@@ -26,12 +26,6 @@ spec:
readOnly: true
volumeAttributes:
secretProviderClass: voice-platform-model-storage
- name: device-secrets
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: voice-platform-devices
---
apiVersion: apps/v1
kind: Deployment
+64
View File
@@ -0,0 +1,64 @@
# Windows 本地真实 RTC 联调
该覆盖层只用于本机联调,不访问阿里云服务器。它把 API 的 RTC Token 签发器和 Worker 的语音供应商切换到火山引擎,同时保留数据库、Redis、MinIO、Keycloak和告警模拟器在本地 Docker 中运行。
## 1. 生成本地凭据
在项目根目录运行:
```powershell
.\deploy\local-rtc\configure.ps1
```
如果还要同时验证方舟大模型总结,可改用:
```powershell
.\deploy\local-rtc\configure.ps1 -EnableArkSummary
```
该模式会额外隐藏读取方舟API Key和内容模型Endpoint ID,并通过只读密钥文件传给Worker。
如果 RTC 配置已经存在,只需要补充大模型总结,不必使用`-Force`重录全部密钥。先在[方舟 API Key 控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/apikey)创建密钥,再运行:
```powershell
.\deploy\local-rtc\configure-ark-summary.ps1
```
该脚本只更新`ark-api-key`、`CONTENT_GENERATOR`和`ARK_CONTENT_MODEL`,不会修改已有RTC、语音或设备凭据。当前Chat API既支持模型Model ID,也支持Endpoint ID;脚本默认使用`doubao-seed-2-0-lite-260215`,如果账号未开通该模型,可在提示中填写已开通的Model ID或Endpoint ID。
脚本会以隐藏输入方式依次读取:
- RTC AppKey;
- 火山引擎 AccessKey ID / Secret AccessKey;
- 豆包端到端语音 APP ID / Access Token;
- 豆包音色 ID。
密钥文件只生成到`deploy/local-rtc/runtime`,该目录不会提交到 Git。当前首轮联调使用豆包 S2S-O 纯端到端模式(`OutputMode=0`),平台本地路由仍沿用`s2s_hybrid`模板槽位;这是为了只依赖豆包端到端语音凭据,先验证真实双向音频。后续接入火山方舟 Endpoint 后再切换真正的混合编排模式。
## 2. 启动本地后端
```powershell
.\deploy\local-rtc\start.ps1
```
首次构建需要下载 Docker 基础镜像。脚本会等待`http://localhost:58080/health/ready`可用。
## 3. 启动模拟器
双击`device-simulator-windows/release/voice-device-simulator-0.1.0-windows-x64.exe`,然后:
1. 保持默认本地平台地址、设备 ID 和本地测试设备密钥;
2. 点击“检测平台和音频设备”;
3. 选择麦克风和扬声器;
4. 确认同一个RTC AppID已在RTC控制台的“实时字幕”功能中启用流式语音识别;
5. 点击“开始语音聊天”;
6. 看到“RTC 实时字幕已启动”和“豆包语音任务已激活”后直接说话;
7. 确认“实时字幕与上传”区域同时出现长者和豆包话轮;
8. 点击“结束通话”验证字幕补交、`StopVoiceChat`和RTC离房。
实时字幕要求先在豆包语音控制台创建服务类型为“流式语音识别”的应用,再在RTC控制台的实时字幕配置中填写该应用的APP ID、Access Token和Cluster ID。未开通或配置不匹配时SDK返回`ServiceAccessDenied`;这与“实时对话式AI > 回调设置”无关,不需要为字幕上传填写回调URL。
通话结束后,Worker会根据最终话轮生成通话摘要、关键词、开放话题和待审核故事。只有人工确认故事后才会进入长期记忆,这是既有授权与审核边界,模拟器不会绕过审核自动写入长期记忆。本地默认`CONTENT_GENERATOR=local`仅用于确定性闭环验证;要验证真实大模型总结,必须另外为Worker配置`CONTENT_GENERATOR=ark`、`ARK_API_KEY_FILE`和`ARK_CONTENT_MODEL`。
当新通话冻结快照中的`story_extraction`和`personalized_memory`授权均有效时,平台会把最近3次已完成通话的就绪摘要作为短期连续上下文注入豆包;S2S-O模式使用`dialog.system_role`,无需开启供应商侧`dialog_id`历史留存。长者档案中的“避免主动提及”话题也会从下一次开房起一并生效。
RTC AppKey、火山 AK/SK 和豆包 Access Token 均不进入模拟器安装包,也不会通过渲染页面读取。
+32
View File
@@ -0,0 +1,32 @@
services:
api:
environment:
RTC_APP_ID: ${RTC_APP_ID}
RTC_TOKEN_ISSUER: volcengine
RTC_APP_KEY: ""
RTC_APP_KEY_FILE: /run/secrets/local-rtc/rtc-app-key
volumes:
- ./deploy/local-rtc/runtime/rtc-app-key:/run/secrets/local-rtc/rtc-app-key:ro
worker:
environment:
VOICE_PROVIDER: volcengine
# StartVoiceChat must use the same real RTC AppId as the API token issuer.
RTC_APP_ID: ${RTC_APP_ID}
VOLC_ACCESS_KEY_ID: ""
VOLC_ACCESS_KEY_ID_FILE: /run/secrets/local-rtc/volc-access-key-id
VOLC_SECRET_ACCESS_KEY: ""
VOLC_SECRET_ACCESS_KEY_FILE: /run/secrets/local-rtc/volc-secret-access-key
VOLC_RTC_REGION: cn-north-1
# Quote date-shaped API versions so YAML does not coerce them into timestamps.
VOLC_RTC_API_VERSION: "2024-12-01"
VOLC_VOICE_CHAT_TEMPLATE_FILE: /run/secrets/local-rtc/voice-chat-template.json
CONTENT_GENERATOR: ${CONTENT_GENERATOR:-local}
ARK_API_KEY: ""
ARK_API_KEY_FILE: /run/secrets/local-rtc/ark-api-key
ARK_CONTENT_MODEL: ${ARK_CONTENT_MODEL:-}
volumes:
- ./deploy/local-rtc/runtime/volc-access-key-id:/run/secrets/local-rtc/volc-access-key-id:ro
- ./deploy/local-rtc/runtime/volc-secret-access-key:/run/secrets/local-rtc/volc-secret-access-key:ro
- ./deploy/local-rtc/runtime/voice-chat-template.json:/run/secrets/local-rtc/voice-chat-template.json:ro
- ./deploy/local-rtc/runtime/ark-api-key:/run/secrets/local-rtc/ark-api-key:ro
@@ -0,0 +1,52 @@
[CmdletBinding()]
param(
[string]$Model = "doubao-seed-2-0-lite-260215"
)
$ErrorActionPreference = "Stop"
$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path
$runtimeDirectory = Join-Path $scriptDirectory "runtime"
$environmentFile = Join-Path $runtimeDirectory "local-rtc.env"
$apiKeyFile = Join-Path $runtimeDirectory "ark-api-key"
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
if (-not (Test-Path -LiteralPath $environmentFile -PathType Leaf)) {
throw "Run .\deploy\local-rtc\configure.ps1 before configuring Ark summaries."
}
$secureKey = Read-Host "Ark API Key (input is hidden)" -AsSecureString
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureKey)
try {
$apiKey = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
}
finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
}
if ([string]::IsNullOrWhiteSpace($apiKey)) {
throw "Ark API Key is required."
}
$selectedModel = Read-Host "Ark content Model ID or Endpoint ID [$Model]"
if ([string]::IsNullOrWhiteSpace($selectedModel)) {
$selectedModel = $Model
}
$selectedModel = $selectedModel.Trim()
if ([string]::IsNullOrWhiteSpace($selectedModel)) {
throw "Ark content model is required."
}
$settings = [ordered]@{}
foreach ($line in Get-Content -LiteralPath $environmentFile) {
if ($line -match '^([^#=][^=]*)=(.*)$') {
$settings[$matches[1].Trim()] = $matches[2]
}
}
$settings["CONTENT_GENERATOR"] = "ark"
$settings["ARK_CONTENT_MODEL"] = $selectedModel
$environmentContent = ($settings.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "`n"
[IO.File]::WriteAllText($apiKeyFile, $apiKey.Trim(), $utf8NoBom)
[IO.File]::WriteAllText($environmentFile, $environmentContent + "`n", $utf8NoBom)
Write-Host "Ark summary configuration updated. Existing RTC and voice credentials were not changed." -ForegroundColor Green
Write-Host "Next: .\deploy\local-rtc\start.ps1" -ForegroundColor Green
+126
View File
@@ -0,0 +1,126 @@
[CmdletBinding()]
param(
[string]$RtcAppId = "6a8f92ca43726c017d34cce1",
[switch]$EnableArkSummary,
[string]$ArkContentModel = "",
[switch]$Force
)
$ErrorActionPreference = "Stop"
$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path
$runtimeDirectory = Join-Path $scriptDirectory "runtime"
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
function Read-RequiredText {
param([string]$Prompt, [string]$Default = "")
$suffix = if ($Default) { " [$Default]" } else { "" }
$value = Read-Host "$Prompt$suffix"
if ([string]::IsNullOrWhiteSpace($value)) { $value = $Default }
if ([string]::IsNullOrWhiteSpace($value)) { throw "$Prompt 不能为空" }
return $value.Trim()
}
function Read-RequiredSecret {
param([string]$Prompt, [int]$MinimumLength = 1)
$secureValue = Read-Host $Prompt -AsSecureString
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureValue)
try {
$value = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
}
finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
}
if ($value.Length -lt $MinimumLength) { throw "$Prompt 长度不足" }
return $value
}
function Write-SecretFile {
param([string]$Name, [string]$Value)
$target = Join-Path $runtimeDirectory $Name
[IO.File]::WriteAllText($target, $Value, $utf8NoBom)
}
if ($RtcAppId.Length -ne 24) {
throw "RTC AppId 必须正好为 24 个字符"
}
if ((Test-Path -LiteralPath $runtimeDirectory) -and -not $Force) {
throw "本地凭据目录已经存在。确认需要重新配置后使用 -Force。"
}
New-Item -ItemType Directory -Path $runtimeDirectory -Force | Out-Null
Write-Host "`n配置只保存在本机 deploy/local-rtc/runtime,已被 Git 忽略。" -ForegroundColor Cyan
Write-Host "输入密钥时屏幕不会显示字符。`n" -ForegroundColor DarkGray
$rtcAppKey = Read-RequiredSecret "RTC AppKey" 16
$volcAccessKeyId = Read-RequiredText "火山引擎 AccessKey ID"
$volcSecretAccessKey = Read-RequiredSecret "火山引擎 Secret AccessKey"
$doubaoAppId = Read-RequiredText "豆包端到端语音 APP ID"
$doubaoAccessToken = Read-RequiredSecret "豆包端到端语音 Access Token"
$speaker = Read-RequiredText "豆包音色 ID" "zh_female_vv_jupiter_bigtts"
$arkApiKey = ""
if ($EnableArkSummary) {
$arkApiKey = Read-RequiredSecret "方舟 API Key"
$ArkContentModel = Read-RequiredText "方舟内容模型 Endpoint ID" $ArkContentModel
}
$template = [ordered]@{
schema_version = "voice-chat-template-bundle-v1"
templates = [ordered]@{
s2s_hybrid = [ordered]@{
AgentConfig = [ordered]@{
TargetUserId = @()
UserId = ""
WelcomeMessage = "您好,我是豆包语音助手。今天想聊些什么?"
EnableConversationStateCallback = $false
}
Config = [ordered]@{
InterruptMode = 0
S2SConfig = [ordered]@{
Provider = "volcano"
OutputMode = 0
ProviderParams = [ordered]@{
app = [ordered]@{
appid = $doubaoAppId
token = $doubaoAccessToken
}
dialog = [ordered]@{
extra = [ordered]@{
model = "O"
strict_audit = $true
audit_response = "抱歉,这个话题我暂时无法回答。我们换个轻松的话题吧。"
}
}
asr = [ordered]@{
extra = [ordered]@{
end_smooth_window_ms = 1200
}
}
tts = [ordered]@{
speaker = $speaker
}
}
}
}
}
}
}
Write-SecretFile "rtc-app-key" $rtcAppKey
Write-SecretFile "volc-access-key-id" $volcAccessKeyId
Write-SecretFile "volc-secret-access-key" $volcSecretAccessKey
Write-SecretFile "ark-api-key" $arkApiKey
[IO.File]::WriteAllText(
(Join-Path $runtimeDirectory "voice-chat-template.json"),
($template | ConvertTo-Json -Depth 20),
$utf8NoBom
)
[IO.File]::WriteAllText(
(Join-Path $runtimeDirectory "local-rtc.env"),
"RTC_APP_ID=$RtcAppId`nCONTENT_GENERATOR=$(if ($EnableArkSummary) { 'ark' } else { 'local' })`nARK_CONTENT_MODEL=$ArkContentModel`n",
$utf8NoBom
)
Write-Host "`n本地真实 RTC 配置已生成。" -ForegroundColor Green
Write-Host "下一步运行: .\deploy\local-rtc\start.ps1" -ForegroundColor Green
+58
View File
@@ -0,0 +1,58 @@
[CmdletBinding()]
param([switch]$NoBuild)
$ErrorActionPreference = "Stop"
$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path
$projectRoot = Resolve-Path (Join-Path $scriptDirectory "..\..")
$runtimeDirectory = Join-Path $scriptDirectory "runtime"
$environmentFile = Join-Path $runtimeDirectory "local-rtc.env"
$requiredFiles = @(
"rtc-app-key",
"volc-access-key-id",
"volc-secret-access-key",
"ark-api-key",
"voice-chat-template.json",
"local-rtc.env"
)
foreach ($name in $requiredFiles) {
if (-not (Test-Path -LiteralPath (Join-Path $runtimeDirectory $name) -PathType Leaf)) {
throw "Missing $name. Run .\deploy\local-rtc\configure.ps1 first."
}
}
Push-Location $projectRoot
try {
$arguments = @(
"compose",
"-f", "compose.yaml",
"-f", "deploy/local-rtc/compose.override.yaml",
"--env-file", $environmentFile,
"up", "-d"
)
if (-not $NoBuild) { $arguments += "--build" }
$arguments += @(
"postgres", "redis", "minio", "keycloak", "migrate", "seed",
"callbacks", "mock-alert-receiver", "mock-provider", "api", "worker"
)
& docker @arguments
if ($LASTEXITCODE -ne 0) { throw "Docker Compose startup failed." }
Write-Host "`nWaiting for local API readiness..." -ForegroundColor Cyan
$deadline = [DateTime]::UtcNow.AddMinutes(3)
do {
Start-Sleep -Seconds 2
try {
$health = Invoke-RestMethod -Uri "http://localhost:58080/health/ready" -TimeoutSec 3
if ($health) { break }
}
catch { }
} while ([DateTime]::UtcNow -lt $deadline)
if (-not $health) { throw "Local API did not become ready within three minutes. Check docker compose logs api worker." }
Write-Host "Local real RTC backend is ready: http://localhost:58080" -ForegroundColor Green
Write-Host "You can now start the Windows voice device simulator." -ForegroundColor Green
}
finally {
Pop-Location
}
+59 -2
View File
@@ -1,6 +1,6 @@
# 阿里云单机演示部署
该配置用于单机公网演示,不是生产验收配置。只向公网发布HTTP 80和HTTPS 443;PostgreSQL、Redis、MinIO、Keycloak内部端口以及API/Worker端口均不映射到宿主机。当前阿里云演示机使用Let's Encrypt短期IP证书,`certbot-ip-renew.timer`负责自动续期。真实RTC、模型、内容安全、云KMS、备份恢复和监控告警仍按阶段8验收清单执行。
该配置用于单机公网演示,不是生产验收配置。只向公网发布HTTP 80和HTTPS 443;PostgreSQL、Redis、MinIO、Keycloak内部端口以及API/Worker端口均不映射到宿主机。当前阿里云演示机使用Let's Encrypt短期IP证书,`certbot-ip-renew.timer`负责自动续期。单机备份覆盖PostgreSQL、Redis、MinIO对象和当前Keycloak数据目录,并执行数据库级隔离恢复校验;五分钟健康检查覆盖部署模式、公网入口、OIDC、容器、模拟服务、备份、外部告警、证书、定时任务和磁盘。跨故障域备份、真实告警接收器、真实RTC、模型、内容安全和云KMS仍按阶段8验收清单执行。
服务器至少需要Docker Engine 29与Compose 2.40。首次部署先生成权限为600的服务器专用配置:
@@ -15,10 +15,67 @@
载入同一候选版本的`data-terminal-cloud-backend`和`data-terminal-cloud-web`镜像后执行:
```sh
chmod 0755 deploy/server/*.sh
docker compose -f compose.yaml -f deploy/server/compose.override.yaml \
--env-file deploy/server/.env up -d --no-build \
postgres redis minio keycloak migrate seed mock-alert-receiver callbacks \
mock-provider api worker web gateway
docker compose -f compose.yaml -f deploy/server/compose.override.yaml \
--env-file deploy/server/.env up -d --no-build --force-recreate --no-deps gateway
```
安全组和主机防火墙只保留TCP 80、443以及受限来源的SSH 22。如果云安全组暂未放行443,可临时将宿主机80映射到网关443,并使用`https://SERVER_IP:80`;放行443后应恢复标准HTTPS地址。正式环境仍应配置自有域名、生产OIDC和真实外部服务,并切换到Kubernetes生产覆盖层。
从Windows生成的压缩包解开后必须恢复脚本执行位。若`current`是切换到新发布目录的符号链接,还必须强制重建Gateway;否则Docker会继续使用旧发布目录已解析的Nginx绑定挂载,新增路由不会生效。
安全组和主机防火墙只保留TCP 80、443以及受限来源的SSH 22。HTTP 80仅用于跳转到标准HTTPS 443。正式环境仍应配置自有域名、生产OIDC和真实外部服务,并切换到Kubernetes生产覆盖层。
需要在演示机接入真实RTC、豆包语音和方舟摘要时,把凭据文件以`0600`权限放入`/etc/data-terminal-cloud/voice-runtime`,并在`deploy/server/.env`设置`RTC_TOKEN_ISSUER=volcengine`、`VOICE_PROVIDER=volcengine`、`RTC_APP_ID`、`CONTENT_GENERATOR`和`ARK_CONTENT_MODEL`。Compose只读挂载该目录,敏感值不进入源码、镜像或环境文件。
设备HMAC密钥由`device_credential`表以AES-256-GCM密文保存。`deploy/server/.env`中的`DEVICE_CREDENTIAL_MASTER_KEY`只负责数据库密文的认证解密,不得写入数据库或设备;既有`DEVICE_LOCAL_SECRET`只用于首次部署升级时导入两台演示设备,确认数据库凭据记录完整后可移除。新增、轮换和吊销设备密钥使用后端镜像内的`/app/device-credential`维护命令,生成的设备明文密钥只显示一次。
通话内记忆工具只通过网关精确开放`/internal/v1/tools/memory-search`,请求必须携带平台为当前通话签发的短期任务令牌。`MEMORY_TOOL_SIGNING_SECRET`必须是服务器独立随机密钥,不得与游标签名、分享签名或RTC密钥复用;网关不会开放其他`/internal`路径。
## 自动备份与恢复校验
首次安装定时任务:
```bash
chmod 0755 deploy/server/backup.sh deploy/server/verify-backup.sh deploy/server/install-backup.sh
sudo deploy/server/install-backup.sh
sudo systemctl start platform-backup.service
sudo systemctl status platform-backup.service --no-pager
```
备份以AES-256-CBC、PBKDF2和独立随机口令加密,默认保存在`/var/backups/data-terminal-cloud`并保留14天。每次定时任务会实际把PostgreSQL恢复到随机隔离数据库,对比迁移状态、客户组织、账号、表和外键计数,再删除临时数据库。Redis RDB、MinIO对象和暂停后的Keycloak数据目录会进入同一加密归档并进行逐文件SHA-256校验;当前单机配置不把这些文件级检查冒充跨故障域生产恢复。
手工验证已有备份:
```bash
sudo deploy/server/verify-backup.sh /var/backups/data-terminal-cloud/platform-YYYYmmddTHHMMSSZ.tar.gz.enc
```
加密口令位于`/etc/data-terminal-cloud/backup-passphrase`且仅root可读。必须把该口令和加密备份复制到不同故障域;口令丢失将无法恢复备份。
## 健康监测与告警
```bash
chmod 0755 deploy/server/health-monitor.sh deploy/server/install-monitoring.sh
sudo deploy/server/install-monitoring.sh
sudo systemctl start platform-health-monitor.service
sudo systemctl show platform-health-monitor.service -p Result -p ExecMainStatus
python3 -m json.tool /var/lib/data-terminal-cloud-monitor/status.json
```
监测任务每五分钟执行,正常状态为`healthy`。默认`HOST_DEPLOYMENT_PROFILE=demo`,要求两个模拟服务完整运行;切到`production`后则要求模拟服务全部停止。它还要求宿主机所有非回环TCP监听只能使用22、80和443,避免安全组误放行与错误容器端口映射叠加后暴露数据库或缓存。默认只记录结构化状态和systemd日志;在`/etc/data-terminal-cloud/monitor.env`配置`ALERT_WEBHOOK_URL`后,状态变化会发送带时间戳和HMAC-SHA256签名的JSON通知。完整检查项、签名规则、阈值和失败分支见[《单机云端健康监测与告警手册》](../../docs/server-health-monitoring.md)。
生产候选不能只修改部署模式。生产验收快照同时要求:`HOST_DEPLOYMENT_PROFILE=production`;API、Callbacks和Worker实际容器`APP_ENV=prod`;Keycloak不使用`start-dev`;OSS异地备份最近一次上传与回读验证成功;真实外部告警接收器已配置;`mock-provider`和`mock-alert-receiver`均不运行。任一条件缺失都会阻断生产基础设施门禁,但不会影响`LAB`或`STAGING`联调。当前Compose覆盖层固定为演示用途,不能通过改标签升级成生产部署。
## OSS异地备份适配
```bash
chmod 0755 deploy/server/install-ossutil.sh \
deploy/server/install-offsite-backup.sh \
deploy/server/sync-backup-offsite.sh
sudo deploy/server/install-offsite-backup.sh
```
安装后默认为禁用状态,不会访问OSS。默认认证方式为`ecs_ram_role`,使用`cn-hangzhou`地域端点并强制先通过IMDSv2确认实例角色,不保存长期AccessKey。创建私有Bucket并绑定最小权限ECS RAM角色后,在`/etc/data-terminal-cloud/offsite-backup.env`填写Bucket和前缀、设置`OFFSITE_BACKUP_ENABLED=true`与`OFFSITE_BACKUP_REQUIRED=true`,再手工启动一次`platform-offsite-backup.service`。任务会上传加密归档及摘要、重新下载并复算SHA-256;健康检查在启用后强制要求最新本机归档已有对应远端验证。配置和真实验收步骤见[《阿里云OSS异地备份适配与门禁》](../../docs/offsite-backup.md)。
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env bash
set -Eeuo pipefail
project_root=${PLATFORM_ROOT:-/opt/data-terminal-cloud/current}
backup_root=${BACKUP_ROOT:-/var/backups/data-terminal-cloud}
passphrase_file=${BACKUP_PASSPHRASE_FILE:-/etc/data-terminal-cloud/backup-passphrase}
retention_days=${BACKUP_RETENTION_DAYS:-14}
verify_after_backup=${VERIFY_AFTER_BACKUP:-false}
case "$backup_root" in
/*) ;;
*) echo "BACKUP_ROOT must be an absolute path" >&2; exit 2 ;;
esac
if [[ "$backup_root" == "/" || "$backup_root" == "/var" || "$backup_root" == "/var/backups" ]]; then
echo "BACKUP_ROOT is too broad" >&2
exit 2
fi
if [[ ! "$retention_days" =~ ^[1-9][0-9]*$ ]]; then
echo "BACKUP_RETENTION_DAYS must be a positive integer" >&2
exit 2
fi
if [[ ! -r "$passphrase_file" ]]; then
echo "Backup passphrase file is missing or unreadable: $passphrase_file" >&2
exit 2
fi
compose=(docker compose -f "$project_root/compose.yaml" -f "$project_root/deploy/server/compose.override.yaml" --env-file "$project_root/deploy/server/.env")
mkdir -p "$backup_root"
chmod 0700 "$backup_root"
umask 077
exec 9>"$backup_root/.backup.lock"
if ! flock -n 9; then
echo "Another backup is already running" >&2
exit 3
fi
stamp=$(date -u +%Y%m%dT%H%M%SZ)
staging=$(mktemp -d "$backup_root/.staging-${stamp}-XXXXXX")
plain_archive="$backup_root/.platform-${stamp}.tar.gz"
encrypted_archive="$backup_root/platform-${stamp}.tar.gz.enc"
checksum_file="$encrypted_archive.sha256"
minio_container=""
keycloak_container=""
keycloak_paused=false
cleanup() {
status=$?
if [[ "$keycloak_paused" == true && -n "$keycloak_container" ]]; then
docker unpause "$keycloak_container" >/dev/null 2>&1 || true
fi
if [[ -n "$minio_container" ]]; then
docker exec "$minio_container" rm -rf /tmp/platform-backup /tmp/platform-mc >/dev/null 2>&1 || true
fi
rm -rf -- "$staging"
rm -f -- "$plain_archive"
exit "$status"
}
trap cleanup EXIT INT TERM
service_container() {
local service=$1
local container
container=$("${compose[@]}" ps -q "$service")
if [[ -z "$container" ]] || [[ $(docker inspect -f '{{.State.Running}}' "$container") != true ]]; then
echo "Required service is not running: $service" >&2
exit 4
fi
printf '%s' "$container"
}
postgres_container=$(service_container postgres)
redis_container=$(service_container redis)
minio_container=$(service_container minio)
keycloak_container=$(service_container keycloak)
redis_password=$(sed -n 's/^REDIS_PASSWORD=//p' "$project_root/deploy/server/.env")
if [[ -z "$redis_password" ]]; then
echo "REDIS_PASSWORD is missing from the server environment" >&2
exit 4
fi
docker exec "$postgres_container" pg_dump -U voice -d voice_platform -Fc --no-owner --no-acl >"$staging/postgres.dump"
docker exec "$postgres_container" psql -U voice -d voice_platform -Atc \
"SELECT version||'|'||dirty||'|'||(SELECT count(*) FROM tenant)||'|'||(SELECT count(*) FROM user_account)||'|'||(SELECT count(*) FROM information_schema.tables WHERE table_schema='public')||'|'||(SELECT count(*) FROM information_schema.table_constraints WHERE constraint_type='FOREIGN KEY') FROM schema_migrations;" \
>"$staging/database-evidence.txt"
mkdir -p "$staging/redis"
printf '%s\n' "$redis_password" | docker exec -i "$redis_container" sh -ec '
IFS= read -r REDISCLI_AUTH
export REDISCLI_AUTH
redis-cli --no-auth-warning SAVE >/dev/null
'
docker exec "$redis_container" redis-check-rdb /data/dump.rdb >/dev/null
docker cp "$redis_container:/data/dump.rdb" "$staging/redis/dump.rdb" >/dev/null
docker exec "$minio_container" sh -ec '
rm -rf /tmp/platform-backup /tmp/platform-mc
mkdir -p /tmp/platform-backup /tmp/platform-mc
mc --config-dir /tmp/platform-mc alias set local http://127.0.0.1:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null
mc --config-dir /tmp/platform-mc mirror --overwrite --preserve local/ /tmp/platform-backup/
'
mkdir -p "$staging/object-store"
docker cp "$minio_container:/tmp/platform-backup/." "$staging/object-store/" >/dev/null
docker exec "$minio_container" rm -rf /tmp/platform-backup /tmp/platform-mc
docker pause "$keycloak_container" >/dev/null
keycloak_paused=true
mkdir -p "$staging/keycloak"
docker cp "$keycloak_container:/opt/keycloak/data/." "$staging/keycloak/" >/dev/null
docker unpause "$keycloak_container" >/dev/null
keycloak_paused=false
object_files=$(find "$staging/object-store" -type f | wc -l | tr -d ' ')
keycloak_files=$(find "$staging/keycloak" -type f | wc -l | tr -d ' ')
cat >"$staging/manifest.properties" <<EOF
format=platform-server-backup-v1
created_at=${stamp}
build_version=$(sed -n 's/^PLATFORM_BUILD_VERSION=//p' "$project_root/deploy/server/.env")
build_commit=$(sed -n 's/^PLATFORM_BUILD_COMMIT=//p' "$project_root/deploy/server/.env")
database_evidence=$(cat "$staging/database-evidence.txt")
object_files=${object_files}
keycloak_files=${keycloak_files}
redis_snapshot=true
keycloak_snapshot_mode=paused_filesystem_copy
EOF
(
cd "$staging"
find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 -r sha256sum >SHA256SUMS
)
tar -C "$staging" -czf "$plain_archive" .
openssl enc -aes-256-cbc -salt -pbkdf2 -iter 200000 \
-pass "file:$passphrase_file" -in "$plain_archive" -out "$encrypted_archive"
sha256sum "$encrypted_archive" >"$checksum_file"
chmod 0600 "$encrypted_archive" "$checksum_file"
while IFS= read -r -d '' expired; do
rm -f -- "$expired" "$expired.sha256"
done < <(find "$backup_root" -maxdepth 1 -type f -name 'platform-*.tar.gz.enc' -mtime "+$retention_days" -print0)
if [[ "$verify_after_backup" == true ]]; then
"$project_root/deploy/server/verify-backup.sh" "$encrypted_archive"
fi
printf '%s\n' "$encrypted_archive"
+3 -1
View File
@@ -33,14 +33,16 @@ umask 077
printf 'KEYCLOAK_ADMIN_USERNAME=platform-admin\n'
printf 'KEYCLOAK_ADMIN_PASSWORD=%s\n' "$(openssl rand -hex 24)"
printf 'DEVICE_LOCAL_SECRET=%s\n' "$(openssl rand -hex 32)"
printf 'DEVICE_CREDENTIAL_MASTER_KEY=%s\n' "$(openssl rand -hex 32)"
printf 'RTC_TOKEN_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 'MEMORY_TOOL_SIGNING_SECRET=%s\n' "$(openssl rand -hex 32)"
printf 'PROVIDER_CALLBACK_SECRET=%s\n' "$(openssl rand -hex 32)"
printf 'PLATFORM_HEALTH_CALLBACK_SECRET=%s\n' "$(openssl rand -hex 32)"
printf 'MOCK_PROVIDER_API_KEY=%s\n' "$(openssl rand -hex 24)"
printf 'ALERT_WEBHOOK_SECRET=%s\n' "$(openssl rand -hex 32)"
} > "$output_path"
chmod 600 "$output_path"
echo "created $output_path with mode 600"
+27 -6
View File
@@ -19,8 +19,10 @@ x-server-backend-environment: &server-backend-environment
OBJECT_STORE_PUBLIC_SECURE: "true"
CURSOR_SIGNING_SECRET: ${CURSOR_SIGNING_SECRET}
SHARE_TOKEN_SIGNING_SECRET: ${SHARE_TOKEN_SIGNING_SECRET}
MEMORY_TOOL_BASE_URL: ${PUBLIC_ORIGIN}/internal/v1/tools/memory-search
MEMORY_TOOL_SIGNING_SECRET: ${MEMORY_TOOL_SIGNING_SECRET}
EMBEDDING_PROVIDER: local
VOICE_PROVIDER: mock
VOICE_PROVIDER: ${VOICE_PROVIDER:-mock}
services:
otel-collector:
@@ -92,10 +94,15 @@ services:
environment:
<<: *server-backend-environment
HTTP_ADDR: 0.0.0.0:8080
DEVICE_LOCAL_SECRET: ${DEVICE_LOCAL_SECRET}
DEVICE_LOCAL_SECRET: ${DEVICE_LOCAL_SECRET:-}
DEVICE_CREDENTIAL_MASTER_KEY: ${DEVICE_CREDENTIAL_MASTER_KEY}
RTC_TOKEN_SIGNING_SECRET: ${RTC_TOKEN_SIGNING_SECRET}
RTC_TOKEN_ISSUER: local
RTC_APP_ID: local-public-rtc-app
RTC_TOKEN_ISSUER: ${RTC_TOKEN_ISSUER:-local}
RTC_APP_ID: ${RTC_APP_ID:-local-public-rtc-app}
RTC_APP_KEY: ""
RTC_APP_KEY_FILE: ${RTC_APP_KEY_FILE:-/run/secrets/voice-runtime/rtc-app-key}
volumes:
- /etc/data-terminal-cloud/voice-runtime:/run/secrets/voice-runtime:ro
restart: unless-stopped
callbacks:
@@ -106,6 +113,7 @@ services:
<<: *server-backend-environment
HTTP_ADDR: 0.0.0.0:8081
PROVIDER_CALLBACK_SECRET: ${PROVIDER_CALLBACK_SECRET}
PLATFORM_HEALTH_CALLBACK_SECRET: ${PLATFORM_HEALTH_CALLBACK_SECRET}
RATE_LIMIT_RPS: "1000"
RATE_LIMIT_BURST: "1500"
restart: unless-stopped
@@ -121,8 +129,21 @@ services:
MOCK_PROVIDER_API_KEY: ${MOCK_PROVIDER_API_KEY}
ALERT_WEBHOOK_URL: http://mock-alert-receiver:8084/webhooks/alerts
ALERT_WEBHOOK_SECRET: ${ALERT_WEBHOOK_SECRET}
CONTENT_GENERATOR: local
RTC_APP_ID: ${RTC_APP_ID:-local-public-rtc-app}
VOLC_ACCESS_KEY_ID: ""
VOLC_ACCESS_KEY_ID_FILE: ${VOLC_ACCESS_KEY_ID_FILE:-/run/secrets/voice-runtime/volc-access-key-id}
VOLC_SECRET_ACCESS_KEY: ""
VOLC_SECRET_ACCESS_KEY_FILE: ${VOLC_SECRET_ACCESS_KEY_FILE:-/run/secrets/voice-runtime/volc-secret-access-key}
VOLC_RTC_REGION: ${VOLC_RTC_REGION:-cn-north-1}
VOLC_RTC_API_VERSION: ${VOLC_RTC_API_VERSION:-2024-12-01}
VOLC_VOICE_CHAT_TEMPLATE_FILE: ${VOLC_VOICE_CHAT_TEMPLATE_FILE:-/run/secrets/voice-runtime/voice-chat-template.json}
CONTENT_GENERATOR: ${CONTENT_GENERATOR:-local}
ARK_API_KEY: ""
ARK_API_KEY_FILE: ${ARK_API_KEY_FILE:-/run/secrets/voice-runtime/ark-api-key}
ARK_CONTENT_MODEL: ${ARK_CONTENT_MODEL:-}
CONTENT_SAFETY_PROVIDER: local
volumes:
- /etc/data-terminal-cloud/voice-runtime:/run/secrets/voice-runtime:ro
restart: unless-stopped
mock-provider:
@@ -159,7 +180,7 @@ services:
gateway:
image: nginx:1.30-alpine
ports:
- "80:443"
- "80:80"
- "443:443"
volumes:
- ./deploy/server/nginx.conf:/etc/nginx/conf.d/default.conf:ro
+391
View File
@@ -0,0 +1,391 @@
#!/usr/bin/env bash
set -Eeuo pipefail
project_root=${PLATFORM_ROOT:-/opt/data-terminal-cloud/current}
backup_root=${BACKUP_ROOT:-/var/backups/data-terminal-cloud}
state_dir=${MONITOR_STATE_DIR:-/var/lib/data-terminal-cloud-monitor}
monitor_config=${MONITOR_CONFIG_FILE:-/etc/data-terminal-cloud/monitor.env}
offsite_config_file=${OFFSITE_CONFIG_FILE:-/etc/data-terminal-cloud/offsite-backup.env}
backup_max_age_seconds=${BACKUP_MAX_AGE_SECONDS:-129600}
certificate_min_seconds=${CERTIFICATE_MIN_SECONDS:-129600}
disk_warning_percent=${DISK_WARNING_PERCENT:-80}
disk_critical_percent=${DISK_CRITICAL_PERCENT:-90}
if [[ -f "$monitor_config" ]]; then
# The installer creates this file as root:root 0600.
# shellcheck disable=SC1090
source "$monitor_config"
fi
alert_webhook_url=${ALERT_WEBHOOK_URL:-}
alert_webhook_secret_file=${ALERT_WEBHOOK_SECRET_FILE:-/etc/data-terminal-cloud/monitor-webhook-secret}
platform_health_report_url=${PLATFORM_HEALTH_REPORT_URL:-}
platform_health_report_secret_file=${PLATFORM_HEALTH_REPORT_SECRET_FILE:-/etc/data-terminal-cloud/platform-health-callback-secret}
expected_public_tcp_ports=${EXPECTED_PUBLIC_TCP_PORTS:-22,80,443}
deployment_profile=${HOST_DEPLOYMENT_PROFILE:-demo}
if [[ -f "$offsite_config_file" ]]; then
# shellcheck disable=SC1090
source "$offsite_config_file"
fi
offsite_enabled=${OFFSITE_BACKUP_ENABLED:-false}
offsite_required=${OFFSITE_BACKUP_REQUIRED:-false}
offsite_state_file=${OFFSITE_STATE_FILE:-/var/lib/data-terminal-cloud-offsite/status.json}
offsite_max_age_seconds=${OFFSITE_MAX_AGE_SECONDS:-129600}
for numeric in "$backup_max_age_seconds" "$certificate_min_seconds" "$disk_warning_percent" "$disk_critical_percent" "$offsite_max_age_seconds"; do
[[ "$numeric" =~ ^[0-9]+$ ]] || { echo "Monitor thresholds must be non-negative integers" >&2; exit 2; }
done
if (( disk_warning_percent >= disk_critical_percent || disk_critical_percent > 100 )); then
echo "Disk thresholds are invalid" >&2
exit 2
fi
if [[ ! "$expected_public_tcp_ports" =~ ^[0-9]+(,[0-9]+)*$ ]]; then
echo "EXPECTED_PUBLIC_TCP_PORTS must contain comma-separated integers" >&2
exit 2
fi
if [[ "$deployment_profile" != demo && "$deployment_profile" != production ]]; then
echo "HOST_DEPLOYMENT_PROFILE must be demo or production" >&2
exit 2
fi
compose=(docker compose -f "$project_root/compose.yaml" -f "$project_root/deploy/server/compose.override.yaml" --env-file "$project_root/deploy/server/.env")
mkdir -p "$state_dir"
chmod 0750 "$state_dir"
umask 077
checks_file=$(mktemp "$state_dir/.checks-XXXXXX")
status_tmp=$(mktemp "$state_dir/.status-XXXXXX")
oidc_tmp=$(mktemp "$state_dir/.oidc-XXXXXX")
curl_config=$(mktemp "$state_dir/.curl-XXXXXX")
status_file="$state_dir/status.json"
critical_count=0
warning_count=0
cleanup() {
rm -f -- "$checks_file" "$status_tmp" "$oidc_tmp" "$curl_config"
}
trap cleanup EXIT INT TERM
record_check() {
local name=$1
local status=$2
local value=${3:-}
value=${value//$'\t'/ }
value=${value//$'\r'/ }
value=${value//$'\n'/ }
printf '%s\t%s\t%s\n' "$name" "$status" "$value" >>"$checks_file"
case "$status" in
critical) critical_count=$((critical_count + 1)) ;;
warning) warning_count=$((warning_count + 1)) ;;
esac
}
record_check deployment_profile healthy "$deployment_profile"
if [[ -z "$alert_webhook_url" ]]; then
if [[ "$deployment_profile" == production ]]; then
record_check external_alert_receiver critical not_configured
else
record_check external_alert_receiver healthy not_configured
fi
elif [[ ! -r "$alert_webhook_secret_file" ]]; then
record_check external_alert_receiver critical secret_missing
elif (( $(tr -d '\r\n' <"$alert_webhook_secret_file" | wc -c) < 32 )); then
record_check external_alert_receiver critical secret_invalid
else
record_check external_alert_receiver healthy configured
fi
public_origin=$(sed -n 's/^PUBLIC_ORIGIN=//p' "$project_root/deploy/server/.env")
if [[ "$public_origin" == https://* ]]; then
record_check public_origin healthy "$public_origin"
else
record_check public_origin critical invalid_https_origin
fi
if curl --fail --silent --show-error --max-time 10 "$public_origin/api/v1/version" >/dev/null; then
record_check public_https healthy http_200
else
record_check public_https critical request_failed
fi
if ! command -v ss >/dev/null 2>&1; then
record_check public_tcp_listeners critical probe_unavailable
else
listener_evidence=$(ss -H -ltn | python3 "$project_root/deploy/server/public_listener_policy.py" "$expected_public_tcp_ports" 2>/dev/null || true)
IFS='|' read -r listener_status listener_detail <<<"$listener_evidence"
if [[ "$listener_status" == healthy ]]; then
record_check public_tcp_listeners healthy "$listener_detail"
elif [[ "$listener_status" == critical ]]; then
record_check public_tcp_listeners critical "$listener_detail"
else
record_check public_tcp_listeners critical invalid_probe_result
fi
fi
expected_issuer="$public_origin/realms/voice-local"
if curl --fail --silent --show-error --max-time 10 \
"$expected_issuer/.well-known/openid-configuration" -o "$oidc_tmp"; then
actual_issuer=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8")).get("issuer", ""))' "$oidc_tmp" 2>/dev/null || true)
if [[ "$actual_issuer" == "$expected_issuer" ]]; then
record_check oidc_issuer healthy issuer_matches
else
record_check oidc_issuer critical issuer_mismatch
fi
else
record_check oidc_issuer critical request_failed
fi
required_services=(postgres redis minio keycloak api callbacks worker web gateway)
if [[ "$deployment_profile" == demo ]]; then
required_services+=(mock-provider mock-alert-receiver)
fi
for service in "${required_services[@]}"; do
container=$("${compose[@]}" ps -q "$service")
if [[ -z "$container" ]]; then
record_check "container_$service" critical missing
continue
fi
state=$(docker inspect -f '{{.State.Status}}' "$container")
health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container")
if [[ "$state" != running ]]; then
record_check "container_$service" critical "$state"
elif [[ "$health" != none && "$health" != healthy ]]; then
record_check "container_$service" critical "$health"
else
record_check "container_$service" healthy "$state/$health"
fi
done
application_environments=()
application_environment_missing=false
for application_service in api callbacks worker; do
application_container=$("${compose[@]}" ps -q "$application_service")
if [[ -z "$application_container" ]]; then
application_environment_missing=true
continue
fi
application_environment=$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$application_container" | sed -n 's/^APP_ENV=//p' | head -n1)
if [[ -z "$application_environment" ]]; then
application_environment_missing=true
else
application_environments+=("$application_environment")
fi
done
if [[ "$application_environment_missing" == true || ${#application_environments[@]} -ne 3 ]]; then
record_check application_environment critical missing
elif [[ "${application_environments[0]}" != "${application_environments[1]}" || "${application_environments[0]}" != "${application_environments[2]}" ]]; then
record_check application_environment critical mixed
elif [[ "$deployment_profile" == production && "${application_environments[0]}" != prod ]]; then
record_check application_environment critical "${application_environments[0]}"
elif [[ "$deployment_profile" == demo && "${application_environments[0]}" != local ]]; then
record_check application_environment critical "${application_environments[0]}"
else
record_check application_environment healthy "${application_environments[0]}"
fi
keycloak_container=$("${compose[@]}" ps -q keycloak)
if [[ -z "$keycloak_container" ]]; then
record_check identity_service_mode critical missing
elif docker inspect -f '{{json .Config.Cmd}}' "$keycloak_container" | grep -q 'start-dev'; then
if [[ "$deployment_profile" == production ]]; then
record_check identity_service_mode critical development
else
record_check identity_service_mode healthy development
fi
else
record_check identity_service_mode healthy hardened
fi
mock_services_running=()
for mock_service in mock-provider mock-alert-receiver; do
mock_container=$("${compose[@]}" ps -q "$mock_service")
if [[ -n "$mock_container" && $(docker inspect -f '{{.State.Status}}' "$mock_container") == running ]]; then
mock_services_running+=("$mock_service")
fi
done
if [[ "$deployment_profile" == production ]]; then
if (( ${#mock_services_running[@]} > 0 )); then
record_check mock_services critical unexpected_running
else
record_check mock_services healthy disabled
fi
elif (( ${#mock_services_running[@]} == 2 )); then
record_check mock_services healthy enabled_for_demo
else
record_check mock_services critical incomplete_demo_stack
fi
latest_backup=$(find "$backup_root" -maxdepth 1 -type f -name 'platform-*.tar.gz.enc' -printf '%T@ %p\n' 2>/dev/null | sort -nr | head -n1 | cut -d' ' -f2- || true)
if [[ -z "$latest_backup" || ! -f "$latest_backup" ]]; then
record_check backup_freshness critical missing
else
now_epoch=$(date +%s)
backup_epoch=$(stat -c %Y "$latest_backup")
backup_age=$((now_epoch - backup_epoch))
if (( backup_age > backup_max_age_seconds )); then
record_check backup_freshness critical "age_seconds=$backup_age"
elif [[ ! -f "$latest_backup.sha256" ]] || ! (cd "$backup_root" && sha256sum -c "$(basename "$latest_backup").sha256" >/dev/null 2>&1); then
record_check backup_freshness critical checksum_failed
else
record_check backup_freshness healthy "age_seconds=$backup_age"
fi
fi
if [[ "$offsite_enabled" == true || "$offsite_required" == true ]]; then
if [[ ! -r "$offsite_state_file" ]]; then
record_check offsite_backup critical status_missing
else
offsite_evidence=$(python3 -c 'import json,sys
try:
d=json.load(open(sys.argv[1], encoding="utf-8")); print("|".join([str(d.get("status", "")), str(d.get("verified_epoch", 0)), str(d.get("source_archive", ""))]))
except Exception: print("invalid|0|")' "$offsite_state_file")
IFS='|' read -r offsite_status offsite_epoch offsite_source <<<"$offsite_evidence"
offsite_age=$(($(date +%s) - offsite_epoch))
if [[ "$offsite_status" != verified ]]; then
record_check offsite_backup critical "status=${offsite_status:-invalid}"
elif [[ "$offsite_source" != "$(basename "${latest_backup:-missing}")" ]]; then
record_check offsite_backup critical source_mismatch
elif (( offsite_epoch <= 0 || offsite_age > offsite_max_age_seconds )); then
record_check offsite_backup critical "age_seconds=$offsite_age"
else
record_check offsite_backup healthy "age_seconds=$offsite_age"
fi
fi
else
if [[ "$deployment_profile" == production ]]; then
record_check offsite_backup critical not_required
else
record_check offsite_backup healthy not_required
fi
fi
backup_result=$(systemctl show platform-backup.service -p Result --value 2>/dev/null || true)
backup_exit=$(systemctl show platform-backup.service -p ExecMainStatus --value 2>/dev/null || true)
if [[ "$backup_result" == success && "$backup_exit" == 0 ]]; then
record_check backup_service healthy success
else
record_check backup_service critical "result=${backup_result:-unknown}/exit=${backup_exit:-unknown}"
fi
timers=(platform-backup.timer certbot-ip-renew.timer)
if [[ -f "$offsite_config_file" ]]; then
timers+=(platform-offsite-backup.timer)
fi
for timer in "${timers[@]}"; do
timer_enabled=$(systemctl is-enabled "$timer" 2>/dev/null || true)
timer_active=$(systemctl is-active "$timer" 2>/dev/null || true)
if [[ "$timer_enabled" == enabled && "$timer_active" == active ]]; then
record_check "$timer" healthy enabled/active
else
record_check "$timer" critical "${timer_enabled:-unknown}/${timer_active:-unknown}"
fi
done
public_hostname=$(python3 -c 'import sys,urllib.parse; print(urllib.parse.urlsplit(sys.argv[1]).hostname or "")' "$public_origin")
certificate_file=${TLS_CERTIFICATE_FILE:-/etc/letsencrypt/live/$public_hostname/fullchain.pem}
if [[ ! -r "$certificate_file" ]]; then
record_check certificate critical missing
else
certificate_end=$(openssl x509 -in "$certificate_file" -noout -enddate | cut -d= -f2-)
certificate_end_epoch=$(date -d "$certificate_end" +%s)
certificate_seconds=$((certificate_end_epoch - $(date +%s)))
if (( certificate_seconds < certificate_min_seconds )); then
record_check certificate critical "remaining_seconds=$certificate_seconds"
else
record_check certificate healthy "remaining_seconds=$certificate_seconds"
fi
fi
disk_used=$(df -P "$backup_root" | awk 'NR==2 {gsub(/%/, "", $5); print $5}')
if [[ ! "$disk_used" =~ ^[0-9]+$ ]]; then
record_check disk_usage critical unreadable
elif (( disk_used >= disk_critical_percent )); then
record_check disk_usage critical "used_percent=$disk_used"
elif (( disk_used >= disk_warning_percent )); then
record_check disk_usage warning "used_percent=$disk_used"
else
record_check disk_usage healthy "used_percent=$disk_used"
fi
if (( critical_count > 0 )); then
overall=critical
elif (( warning_count > 0 )); then
overall=warning
else
overall=healthy
fi
observed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
previous_status=$(python3 -c 'import json,sys
try: print(json.load(open(sys.argv[1], encoding="utf-8")).get("status", "unknown"))
except Exception: print("unknown")' "$status_file" 2>/dev/null || true)
python3 - "$checks_file" "$status_tmp" "$overall" "$observed_at" <<'PY'
import json
import sys
checks = []
with open(sys.argv[1], encoding="utf-8") as source:
for line in source:
name, status, value = line.rstrip("\n").split("\t", 2)
checks.append({"name": name, "status": status, "value": value})
payload = {
"schema": "platform-server-health-v1",
"status": sys.argv[3],
"observed_at": sys.argv[4],
"checks": checks,
}
with open(sys.argv[2], "w", encoding="utf-8") as target:
json.dump(payload, target, ensure_ascii=False, separators=(",", ":"))
target.write("\n")
PY
chmod 0644 "$status_tmp"
mv -f "$status_tmp" "$status_file"
post_signed_status() {
local target_url=$1
local secret_file=$2
local timestamp signature
timestamp=$(date +%s)
signature=$(python3 - "$secret_file" "$status_file" "$timestamp" <<'PY'
import hashlib
import hmac
import sys
secret = open(sys.argv[1], "rb").read().rstrip(b"\r\n")
body = open(sys.argv[2], "rb").read()
print(hmac.new(secret, sys.argv[3].encode() + b"." + body, hashlib.sha256).hexdigest())
PY
)
python3 - "$target_url" "$curl_config" <<'PY'
import sys
url = sys.argv[1].replace("\\", "\\\\").replace('"', '\\"')
open(sys.argv[2], "w", encoding="utf-8").write(f'url = "{url}"\n')
PY
curl --fail --silent --show-error --max-time 10 --config "$curl_config" \
-H 'Content-Type: application/json' \
-H "X-Platform-Timestamp: $timestamp" \
-H "X-Platform-Signature: sha256=$signature" \
--data-binary "@$status_file" >/dev/null
}
if [[ -n "$platform_health_report_url" ]]; then
if [[ ! -r "$platform_health_report_secret_file" ]]; then
echo "Platform health reporting is configured but its HMAC secret is unreadable" >&2
exit 6
fi
post_signed_status "$platform_health_report_url" "$platform_health_report_secret_file"
fi
if [[ -n "$alert_webhook_url" && "$previous_status" != "$overall" ]]; then
if [[ ! -r "$alert_webhook_secret_file" ]]; then
record_check alert_delivery critical secret_missing
echo "Alert webhook is configured but its HMAC secret is unreadable" >&2
exit 5
fi
post_signed_status "$alert_webhook_url" "$alert_webhook_secret_file"
fi
printf 'platform_health status=%s critical=%s warning=%s status_file=%s\n' \
"$overall" "$critical_count" "$warning_count" "$status_file"
if (( critical_count > 0 )); then
exit 1
fi
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -Eeuo pipefail
if [[ ${EUID:-$(id -u)} -ne 0 ]]; then
echo "Run this installer as root" >&2
exit 2
fi
project_root=${PLATFORM_ROOT:-/opt/data-terminal-cloud/current}
install -d -m 0700 /etc/data-terminal-cloud /var/backups/data-terminal-cloud
if [[ ! -s /etc/data-terminal-cloud/backup-passphrase ]]; then
umask 077
openssl rand -base64 48 >/etc/data-terminal-cloud/backup-passphrase
fi
chmod 0600 /etc/data-terminal-cloud/backup-passphrase
install -m 0644 "$project_root/deploy/server/platform-backup.service" /etc/systemd/system/platform-backup.service
install -m 0644 "$project_root/deploy/server/platform-backup.timer" /etc/systemd/system/platform-backup.timer
systemctl daemon-reload
systemctl enable --now platform-backup.timer
systemctl is-active --quiet platform-backup.timer
echo "platform-backup.timer enabled"
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -Eeuo pipefail
if [[ ${EUID:-$(id -u)} -ne 0 ]]; then
echo "Run this installer as root" >&2
exit 2
fi
project_root=${PLATFORM_ROOT:-/opt/data-terminal-cloud/current}
install -d -m 0700 /etc/data-terminal-cloud
install -d -m 0750 /var/lib/data-terminal-cloud-monitor
platform_env="$project_root/deploy/server/.env"
if [[ -r "$platform_env" && ! -s /etc/data-terminal-cloud/platform-health-callback-secret ]]; then
platform_health_secret=$(sed -n 's/^PLATFORM_HEALTH_CALLBACK_SECRET=//p' "$platform_env" | head -n1)
if (( ${#platform_health_secret} >= 32 )); then
umask 077
printf '%s\n' "$platform_health_secret" >/etc/data-terminal-cloud/platform-health-callback-secret
fi
fi
if [[ -s /etc/data-terminal-cloud/platform-health-callback-secret ]]; then
chmod 0600 /etc/data-terminal-cloud/platform-health-callback-secret
fi
if [[ ! -s /etc/data-terminal-cloud/monitor-webhook-secret ]]; then
umask 077
openssl rand -base64 48 >/etc/data-terminal-cloud/monitor-webhook-secret
fi
chmod 0600 /etc/data-terminal-cloud/monitor-webhook-secret
if [[ ! -e /etc/data-terminal-cloud/monitor.env ]]; then
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
HOST_DEPLOYMENT_PROFILE=demo
PLATFORM_HEALTH_REPORT_URL=$report_url
PLATFORM_HEALTH_REPORT_SECRET_FILE=/etc/data-terminal-cloud/platform-health-callback-secret
ALERT_WEBHOOK_URL=
ALERT_WEBHOOK_SECRET_FILE=/etc/data-terminal-cloud/monitor-webhook-secret
EOF
fi
chmod 0600 /etc/data-terminal-cloud/monitor.env
install -m 0644 "$project_root/deploy/server/platform-health-monitor.service" /etc/systemd/system/platform-health-monitor.service
install -m 0644 "$project_root/deploy/server/platform-health-monitor.timer" /etc/systemd/system/platform-health-monitor.timer
systemctl daemon-reload
systemctl enable --now platform-health-monitor.timer
systemctl is-active --quiet platform-health-monitor.timer
echo "platform-health-monitor.timer enabled"
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -Eeuo pipefail
if [[ ${EUID:-$(id -u)} -ne 0 ]]; then
echo "Run this installer as root" >&2
exit 2
fi
project_root=${PLATFORM_ROOT:-/opt/data-terminal-cloud/current}
"$project_root/deploy/server/install-ossutil.sh"
install -d -m 0700 /etc/data-terminal-cloud
install -d -m 0750 /var/lib/data-terminal-cloud-offsite
if [[ ! -e /etc/data-terminal-cloud/offsite-backup.env ]]; then
cat >/etc/data-terminal-cloud/offsite-backup.env <<'EOF'
OFFSITE_BACKUP_ENABLED=false
OFFSITE_BACKUP_REQUIRED=false
OFFSITE_OSS_BUCKET=
OFFSITE_OSS_PREFIX=data-terminal-cloud/121.41.46.238
OFFSITE_OSS_AUTH_MODE=ecs_ram_role
OFFSITE_OSS_REGION=cn-hangzhou
OFFSITE_OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
OFFSITE_OSS_CONFIG_FILE=/etc/data-terminal-cloud/ossutilconfig
EOF
fi
chmod 0600 /etc/data-terminal-cloud/offsite-backup.env
install -m 0644 "$project_root/deploy/server/platform-offsite-backup.service" /etc/systemd/system/platform-offsite-backup.service
install -m 0644 "$project_root/deploy/server/platform-offsite-backup.timer" /etc/systemd/system/platform-offsite-backup.timer
systemctl daemon-reload
systemctl enable --now platform-offsite-backup.timer
systemctl is-active --quiet platform-offsite-backup.timer
echo "platform-offsite-backup.timer enabled; upload remains disabled until OSS identity and bucket are configured"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -Eeuo pipefail
if [[ ${EUID:-$(id -u)} -ne 0 ]]; then
echo "Run this installer as root" >&2
exit 2
fi
version=2.3.0
archive=ossutil-${version}-linux-amd64.zip
expected_sha256=3ae4d9fc85a7a6e9f5654d1599766f1a3a42a3692870887b5ae9338d582ef65a
download_url="https://gosspublic.alicdn.com/ossutil/v2/${version}/${archive}"
if command -v ossutil >/dev/null 2>&1 && [[ $(ossutil version 2>/dev/null) == "$version" ]]; then
echo "ossutil $version already installed"
exit 0
fi
install_dir=$(mktemp -d /var/tmp/ossutil-install-XXXXXX)
case "$install_dir" in /var/tmp/ossutil-install-*) ;; *) echo "Unsafe install directory" >&2; exit 9;; esac
trap 'rm -rf -- "$install_dir"' EXIT
curl -fsSL --max-time 120 -o "$install_dir/$archive" "$download_url"
printf '%s %s\n' "$expected_sha256" "$install_dir/$archive" | sha256sum -c -
mkdir -p "$install_dir/unpacked"
if command -v unzip >/dev/null 2>&1; then
unzip -q "$install_dir/$archive" -d "$install_dir/unpacked"
elif command -v python3 >/dev/null 2>&1; then
python3 -m zipfile -e "$install_dir/$archive" "$install_dir/unpacked"
else
echo "Neither unzip nor python3 is available to extract ossutil" >&2
exit 3
fi
binary=$(find "$install_dir/unpacked" -type f -name ossutil -print -quit)
[[ -n "$binary" ]] || { echo "ossutil binary is missing from the verified archive" >&2; exit 4; }
install -m 0755 "$binary" /usr/local/bin/ossutil
[[ $(/usr/local/bin/ossutil version) == "$version" ]]
echo "ossutil $version installed"
+8
View File
@@ -44,6 +44,14 @@ server {
proxy_buffering off;
}
# The voice provider can call only this signed, conversation-scoped tool.
# Do not expose the rest of the /internal namespace.
location = /internal/v1/tools/memory-search {
proxy_pass $api_backend;
proxy_buffering off;
proxy_request_buffering off;
}
location /callbacks/ {
proxy_pass $callbacks_backend;
proxy_buffering off;
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=Encrypted data terminal cloud backup and restore verification
Requires=docker.service
After=docker.service network-online.target
[Service]
Type=oneshot
User=root
Group=root
UMask=0077
Environment=PLATFORM_ROOT=/opt/data-terminal-cloud/current
Environment=BACKUP_ROOT=/var/backups/data-terminal-cloud
Environment=BACKUP_RETENTION_DAYS=14
Environment=VERIFY_AFTER_BACKUP=true
ExecStart=/opt/data-terminal-cloud/current/deploy/server/backup.sh
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
TimeoutStartSec=2h
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Run encrypted data terminal cloud backup daily
[Timer]
OnCalendar=*-*-* 02:40:00
RandomizedDelaySec=45m
Persistent=true
Unit=platform-backup.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,18 @@
[Unit]
Description=Check data terminal cloud server health
Requires=docker.service
After=docker.service network-online.target
[Service]
Type=oneshot
User=root
Group=root
UMask=0077
Environment=PLATFORM_ROOT=/opt/data-terminal-cloud/current
Environment=BACKUP_ROOT=/var/backups/data-terminal-cloud
Environment=MONITOR_STATE_DIR=/var/lib/data-terminal-cloud-monitor
ExecStart=/opt/data-terminal-cloud/current/deploy/server/health-monitor.sh
Nice=10
NoNewPrivileges=true
PrivateTmp=true
TimeoutStartSec=2m
@@ -0,0 +1,12 @@
[Unit]
Description=Check data terminal cloud server health every five minutes
[Timer]
OnBootSec=2m
OnUnitActiveSec=5m
RandomizedDelaySec=30s
Persistent=true
Unit=platform-health-monitor.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,18 @@
[Unit]
Description=Copy encrypted data terminal cloud backup to OSS
Requires=network-online.target
After=network-online.target platform-backup.service
[Service]
Type=oneshot
User=root
Group=root
UMask=0077
Environment=BACKUP_ROOT=/var/backups/data-terminal-cloud
Environment=OFFSITE_STATE_DIR=/var/lib/data-terminal-cloud-offsite
Environment=OFFSITE_CONFIG_FILE=/etc/data-terminal-cloud/offsite-backup.env
ExecStart=/opt/data-terminal-cloud/current/deploy/server/sync-backup-offsite.sh
Nice=10
NoNewPrivileges=true
PrivateTmp=true
TimeoutStartSec=4h
@@ -0,0 +1,11 @@
[Unit]
Description=Copy encrypted data terminal cloud backup to OSS daily
[Timer]
OnCalendar=*-*-* 04:10:00
RandomizedDelaySec=30m
Persistent=true
Unit=platform-offsite-backup.service
[Install]
WantedBy=timers.target
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
import ipaddress
import re
import sys
def parse_endpoint(value: str) -> tuple[str, int] | None:
value = value.strip()
if value.startswith("[") and "]:" in value:
address, port_text = value[1:].rsplit("]:", 1)
elif ":" in value:
address, port_text = value.rsplit(":", 1)
else:
return None
if not port_text.isdigit():
return None
return address, int(port_text)
def externally_reachable(address: str) -> bool:
if address in {"*", "0.0.0.0", "::", "[::]"}:
return True
try:
return not ipaddress.ip_address(address.split("%", 1)[0]).is_loopback
except ValueError:
return True
def public_ports(lines: list[str]) -> set[int]:
ports: set[int] = set()
for line in lines:
fields = line.split()
if len(fields) < 4:
continue
endpoint = parse_endpoint(fields[3])
if endpoint is not None and externally_reachable(endpoint[0]):
ports.add(endpoint[1])
return ports
def parse_allowed(value: str) -> set[int]:
if not re.fullmatch(r"[0-9]+(?:,[0-9]+)*", value):
raise ValueError("allowed ports must be comma-separated integers")
ports = {int(item) for item in value.split(",")}
if any(port < 1 or port > 65535 for port in ports):
raise ValueError("allowed port is outside 1..65535")
return ports
def main() -> int:
if len(sys.argv) != 2:
print("usage: public_listener_policy.py ALLOWED_PORTS", file=sys.stderr)
return 2
try:
allowed = parse_allowed(sys.argv[1])
except ValueError as error:
print(str(error), file=sys.stderr)
return 2
exposed = public_ports(list(sys.stdin))
unexpected = sorted(exposed - allowed)
if unexpected:
print("critical|unexpected_ports=" + ",".join(map(str, unexpected)))
else:
detail = ",".join(map(str, sorted(exposed))) or "none"
print("healthy|ports=" + detail)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
set -Eeuo pipefail
backup_root=${BACKUP_ROOT:-/var/backups/data-terminal-cloud}
state_dir=${OFFSITE_STATE_DIR:-/var/lib/data-terminal-cloud-offsite}
offsite_config_file=${OFFSITE_CONFIG_FILE:-/etc/data-terminal-cloud/offsite-backup.env}
ossutil_bin=${OSSUTIL_BIN:-/usr/local/bin/ossutil}
mkdir -p "$state_dir"
chmod 0750 "$state_dir"
umask 077
status_file="$state_dir/status.json"
status_tmp=$(mktemp "$state_dir/.status-XXXXXX")
transfer_dir=$(mktemp -d "$state_dir/.transfer-XXXXXX")
cleanup() {
rm -f -- "$status_tmp"
rm -rf -- "$transfer_dir"
}
trap cleanup EXIT INT TERM
write_status() {
local status=$1
local reason=$2
local source_archive=${3:-}
local object_ref=${4:-}
local sha256=${5:-}
local size_bytes=${6:-0}
local verified_epoch=0
if [[ "$status" == verified ]]; then
verified_epoch=$(date +%s)
fi
python3 - "$status_tmp" "$status" "$reason" "$source_archive" "$object_ref" "$sha256" "$size_bytes" "$verified_epoch" <<'PY'
import datetime
import json
import sys
payload = {
"schema": "platform-offsite-backup-v1",
"status": sys.argv[2],
"reason": sys.argv[3],
"source_archive": sys.argv[4],
"object_ref": sys.argv[5],
"sha256": sys.argv[6],
"size_bytes": int(sys.argv[7]),
"verified_epoch": int(sys.argv[8]),
"observed_at": datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
}
with open(sys.argv[1], "w", encoding="utf-8") as target:
json.dump(payload, target, ensure_ascii=False, separators=(",", ":"))
target.write("\n")
PY
chmod 0644 "$status_tmp"
mv -f "$status_tmp" "$status_file"
}
fail_sync() {
local reason=$1
local code=${2:-1}
write_status failed "$reason" "${source_name:-}" "${remote_archive:-}" "${source_sha:-}" "${source_size:-0}"
echo "offsite_backup status=failed reason=$reason" >&2
exit "$code"
}
if [[ ! -f "$offsite_config_file" ]]; then
write_status disabled config_missing
echo "offsite_backup status=disabled reason=config_missing"
exit 0
fi
# The installer creates this file as root:root 0600.
# shellcheck disable=SC1090
source "$offsite_config_file"
offsite_enabled=${OFFSITE_BACKUP_ENABLED:-false}
bucket=${OFFSITE_OSS_BUCKET:-}
prefix=${OFFSITE_OSS_PREFIX:-data-terminal-cloud}
auth_mode=${OFFSITE_OSS_AUTH_MODE:-config}
oss_region=${OFFSITE_OSS_REGION:-}
oss_endpoint=${OFFSITE_OSS_ENDPOINT:-}
oss_config=${OFFSITE_OSS_CONFIG_FILE:-/etc/data-terminal-cloud/ossutilconfig}
ecs_metadata_base=${ECS_METADATA_BASE_URL:-http://100.100.100.200/latest}
if [[ "$offsite_enabled" != true ]]; then
write_status disabled not_enabled
echo "offsite_backup status=disabled reason=not_enabled"
exit 0
fi
[[ "$bucket" =~ ^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$ ]] || fail_sync invalid_bucket 2
[[ "$prefix" =~ ^[A-Za-z0-9._/-]+$ && "$prefix" != /* && "$prefix" != *..* ]] || fail_sync invalid_prefix 2
[[ -x "$ossutil_bin" ]] || fail_sync ossutil_missing 3
checkpoint_dir="$state_dir/checkpoints"
common=(-f --job 1 --parallel 1 --checkpoint-dir "$checkpoint_dir" --output-format json)
case "$auth_mode" in
config)
[[ -r "$oss_config" ]] || fail_sync oss_config_missing 4
common+=(-c "$oss_config")
;;
ecs_ram_role)
command -v curl >/dev/null 2>&1 || fail_sync curl_missing 3
[[ "$oss_region" =~ ^[a-z0-9][a-z0-9-]{1,62}$ ]] || fail_sync invalid_oss_region 2
if [[ -z "$oss_endpoint" ]]; then
oss_endpoint="oss-$oss_region.aliyuncs.com"
fi
[[ "$oss_endpoint" =~ ^[a-z0-9][a-z0-9.-]{1,251}[a-z0-9]$ && "$oss_endpoint" != *..* ]] || fail_sync invalid_oss_endpoint 2
metadata_token=$(curl --silent --show-error --fail --max-time 3 -X PUT \
-H 'X-aliyun-ecs-metadata-token-ttl-seconds: 60' "$ecs_metadata_base/api/token" || true)
[[ -n "$metadata_token" ]] || fail_sync ecs_metadata_v2_unavailable 4
role_response="$transfer_dir/ecs-role-name"
role_code=$(curl --silent --show-error --max-time 3 --output "$role_response" --write-out '%{http_code}' \
-H "X-aliyun-ecs-metadata-token: $metadata_token" "$ecs_metadata_base/meta-data/ram/security-credentials/" || true)
role_name=$(tr -d '\r\n' <"$role_response")
[[ "$role_code" == 200 && "$role_name" =~ ^[A-Za-z0-9@._-]{1,64}$ ]] || fail_sync ecs_ram_role_missing 4
common+=(--mode EcsRamRole --region "$oss_region" --endpoint "$oss_endpoint")
;;
*)
fail_sync invalid_auth_mode 2
;;
esac
latest_backup=$(find "$backup_root" -maxdepth 1 -type f -name 'platform-*.tar.gz.enc' -printf '%T@ %p\n' 2>/dev/null | sort -nr | head -n1 | cut -d' ' -f2- || true)
[[ -n "$latest_backup" && -f "$latest_backup" ]] || fail_sync local_backup_missing 5
[[ -f "$latest_backup.sha256" ]] || fail_sync local_checksum_missing 5
if ! (cd "$backup_root" && sha256sum -c "$(basename "$latest_backup").sha256" >/dev/null 2>&1); then
fail_sync local_checksum_failed 5
fi
source_name=$(basename "$latest_backup")
source_sha=$(sha256sum "$latest_backup" | awk '{print $1}')
source_size=$(stat -c %s "$latest_backup")
prefix=${prefix%/}
remote_archive="oss://$bucket/$prefix/$source_name"
remote_checksum="$remote_archive.sha256"
mkdir -p "$checkpoint_dir"
if ! "$ossutil_bin" cp "$latest_backup" "$remote_archive" "${common[@]}" --metadata "sha256=$source_sha" >/dev/null; then
fail_sync archive_upload_failed 6
fi
if ! "$ossutil_bin" cp "$latest_backup.sha256" "$remote_checksum" "${common[@]}" >/dev/null; then
fail_sync checksum_upload_failed 6
fi
downloaded="$transfer_dir/$source_name"
downloaded_checksum="$downloaded.sha256"
if ! "$ossutil_bin" cp "$remote_archive" "$downloaded" "${common[@]}" >/dev/null; then
fail_sync archive_download_verification_failed 7
fi
if ! "$ossutil_bin" cp "$remote_checksum" "$downloaded_checksum" "${common[@]}" >/dev/null; then
fail_sync checksum_download_verification_failed 7
fi
downloaded_sha=$(sha256sum "$downloaded" | awk '{print $1}')
checksum_sha=$(awk 'NF >= 1 {print $1; exit}' "$downloaded_checksum")
if [[ "$downloaded_sha" != "$source_sha" || "$checksum_sha" != "$source_sha" ]]; then
fail_sync remote_checksum_mismatch 8
fi
write_status verified upload_and_download_verified "$source_name" "$remote_archive" "$source_sha" "$source_size"
printf 'offsite_backup status=verified source=%s object=%s size_bytes=%s\n' "$source_name" "$remote_archive" "$source_size"
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
set -Eeuo pipefail
project_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
candidate="$project_root/deploy/server/sync-backup-offsite.sh"
bash -n "$candidate"
test_root=$(mktemp -d /var/tmp/offsite-backup-test-XXXXXX)
case "$test_root" in /var/tmp/offsite-backup-test-*) ;; *) exit 90;; esac
metadata_pid=
cleanup() {
if [[ -n "$metadata_pid" ]]; then kill "$metadata_pid" 2>/dev/null || true; fi
rm -rf -- "$test_root"
}
trap cleanup EXIT INT TERM
mkdir -p "$test_root/backups" "$test_root/state" "$test_root/mock-oss"
role_mode_file="$test_root/role-mode"
port_file="$test_root/metadata-port"
printf 'missing\n' >"$role_mode_file"
python3 - "$port_file" "$role_mode_file" <<'PY' &
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pathlib,sys
port_file=pathlib.Path(sys.argv[1])
role_mode=pathlib.Path(sys.argv[2])
class Handler(BaseHTTPRequestHandler):
def log_message(self,*args): pass
def do_PUT(self):
if self.path!='/latest/api/token': self.send_error(404); return
self.send_response(200); self.end_headers(); self.wfile.write(b'test-imdsv2-token')
def do_GET(self):
if self.path!='/latest/meta-data/ram/security-credentials/': self.send_error(404); return
if role_mode.read_text(encoding='utf-8').strip()!='attached': self.send_error(404); return
if self.headers.get('X-aliyun-ecs-metadata-token')!='test-imdsv2-token': self.send_error(401); return
self.send_response(200); self.end_headers(); self.wfile.write(b'DataTerminalCloudOssRole')
server=ThreadingHTTPServer(('127.0.0.1',0),Handler)
port_file.write_text(str(server.server_port),encoding='utf-8')
server.serve_forever()
PY
metadata_pid=$!
for _ in $(seq 1 50); do [[ -s "$port_file" ]] && break; sleep 0.1; done
[[ -s "$port_file" ]]
metadata_base="http://127.0.0.1:$(<"$port_file")/latest"
cat >"$test_root/enabled-role.env" <<'EOF'
OFFSITE_BACKUP_ENABLED=true
OFFSITE_BACKUP_REQUIRED=true
OFFSITE_OSS_BUCKET=valid-private-backup-bucket
OFFSITE_OSS_PREFIX=data-terminal-cloud/test
OFFSITE_OSS_AUTH_MODE=ecs_ram_role
OFFSITE_OSS_REGION=cn-hangzhou
OFFSITE_OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
EOF
set +e
ECS_METADATA_BASE_URL="$metadata_base" OFFSITE_CONFIG_FILE="$test_root/enabled-role.env" OFFSITE_STATE_DIR="$test_root/state" BACKUP_ROOT="$test_root/backups" \
"$candidate" >/dev/null 2>/dev/null
result=$?
set -e
test "$result" = 4
python3 - "$test_root/state/status.json" <<'PY'
import json,sys
d=json.load(open(sys.argv[1],encoding='utf-8'))
assert d['status']=='failed' and d['reason']=='ecs_ram_role_missing',d
PY
cat >"$test_root/disabled.env" <<'EOF'
OFFSITE_BACKUP_ENABLED=false
EOF
rm -f "$test_root/state/status.json"
OFFSITE_CONFIG_FILE="$test_root/disabled.env" OFFSITE_STATE_DIR="$test_root/state" BACKUP_ROOT="$test_root/backups" "$candidate" >/dev/null
printf 'encrypted-backup-fixture\n' >"$test_root/backups/platform-20260826T140000Z.tar.gz.enc"
(cd "$test_root/backups" && sha256sum platform-20260826T140000Z.tar.gz.enc >platform-20260826T140000Z.tar.gz.enc.sha256)
cat >"$test_root/mock-ossutil" <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
printf '%s\n' "$*" >>"$MOCK_OSS_LOG"
test "$1" = cp
source_path=$2
target_path=$3
map_path() {
if [[ "$1" == oss://* ]]; then printf '%s/%s' "$MOCK_OSS_ROOT" "${1#oss://}"; else printf '%s' "$1"; fi
}
source_local=$(map_path "$source_path")
target_local=$(map_path "$target_path")
mkdir -p "$(dirname "$target_local")"
cp "$source_local" "$target_local"
EOF
chmod 0700 "$test_root/mock-ossutil"
printf '[default]\nregion=cn-hangzhou\n' >"$test_root/ossutilconfig"
cat >"$test_root/config-mode.env" <<EOF
OFFSITE_BACKUP_ENABLED=true
OFFSITE_BACKUP_REQUIRED=true
OFFSITE_OSS_BUCKET=valid-private-backup-bucket
OFFSITE_OSS_PREFIX=data-terminal-cloud/config-test
OFFSITE_OSS_AUTH_MODE=config
OFFSITE_OSS_CONFIG_FILE=$test_root/ossutilconfig
EOF
rm -f "$test_root/state/status.json" "$test_root/ossutil.log"
MOCK_OSS_ROOT="$test_root/mock-oss" MOCK_OSS_LOG="$test_root/ossutil.log" OSSUTIL_BIN="$test_root/mock-ossutil" \
OFFSITE_CONFIG_FILE="$test_root/config-mode.env" OFFSITE_STATE_DIR="$test_root/state" BACKUP_ROOT="$test_root/backups" "$candidate" >/dev/null
python3 - "$test_root/state/status.json" <<'PY'
import json,sys
d=json.load(open(sys.argv[1],encoding='utf-8'))
assert d['status']=='verified' and d['reason']=='upload_and_download_verified',d
PY
printf 'attached\n' >"$role_mode_file"
rm -f "$test_root/state/status.json" "$test_root/ossutil.log"
MOCK_OSS_ROOT="$test_root/mock-oss" MOCK_OSS_LOG="$test_root/ossutil.log" OSSUTIL_BIN="$test_root/mock-ossutil" \
ECS_METADATA_BASE_URL="$metadata_base" OFFSITE_CONFIG_FILE="$test_root/enabled-role.env" OFFSITE_STATE_DIR="$test_root/state" \
BACKUP_ROOT="$test_root/backups" "$candidate" >/dev/null
python3 - "$test_root/state/status.json" <<'PY'
import json,sys
d=json.load(open(sys.argv[1],encoding='utf-8'))
assert d['status']=='verified' and d['reason']=='upload_and_download_verified',d
PY
grep -q -- '--mode EcsRamRole' "$test_root/ossutil.log"
grep -q -- '--region cn-hangzhou' "$test_root/ossutil.log"
grep -q -- '--endpoint oss-cn-hangzhou.aliyuncs.com' "$test_root/ossutil.log"
echo 'offsite_backup_smoke=ok disabled=ok missing_role=ok config_round_trip=ok ecs_role_round_trip=ok'
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
import importlib.util
import pathlib
import subprocess
import sys
import unittest
module_path = pathlib.Path(__file__).with_name("public_listener_policy.py")
spec = importlib.util.spec_from_file_location("public_listener_policy", module_path)
policy = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(policy)
class PublicListenerPolicyTest(unittest.TestCase):
def test_allows_expected_public_ports_and_ignores_loopback(self) -> None:
lines = [
"LISTEN 0 4096 0.0.0.0:443 0.0.0.0:*",
"LISTEN 0 4096 [::]:80 [::]:*",
"LISTEN 0 128 *:22 *:*",
"LISTEN 0 128 127.0.0.1:5432 0.0.0.0:*",
"LISTEN 0 128 [::1]:6379 [::]:*",
]
self.assertEqual(policy.public_ports(lines), {22, 80, 443})
def test_reports_wildcard_and_interface_bound_database_ports(self) -> None:
lines = [
"LISTEN 0 4096 0.0.0.0:3306 0.0.0.0:*",
"LISTEN 0 4096 172.16.0.8:5432 0.0.0.0:*",
"LISTEN 0 4096 [::]:6379 [::]:*",
]
self.assertEqual(policy.public_ports(lines) - {22, 80, 443}, {3306, 5432, 6379})
def test_validates_allowed_port_range(self) -> None:
self.assertEqual(policy.parse_allowed("22,80,443"), {22, 80, 443})
with self.assertRaises(ValueError):
policy.parse_allowed("22,0,443")
with self.assertRaises(ValueError):
policy.parse_allowed("22, 443")
def test_cli_marks_unexpected_port_critical(self) -> None:
result = subprocess.run(
[sys.executable, str(module_path), "22,80,443"],
input="LISTEN 0 4096 0.0.0.0:5432 0.0.0.0:*\n",
text=True,
capture_output=True,
check=True,
)
self.assertEqual(result.stdout.strip(), "critical|unexpected_ports=5432")
if __name__ == "__main__":
unittest.main()
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
set -Eeuo pipefail
project_root=${PLATFORM_ROOT:-/opt/data-terminal-cloud/current}
backup_root=${BACKUP_ROOT:-/var/backups/data-terminal-cloud}
passphrase_file=${BACKUP_PASSPHRASE_FILE:-/etc/data-terminal-cloud/backup-passphrase}
archive=${1:-}
if [[ -z "$archive" ]]; then
archive=$(find "$backup_root" -maxdepth 1 -type f -name 'platform-*.tar.gz.enc' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-)
fi
if [[ -z "$archive" || ! -f "$archive" ]]; then
echo "Backup archive was not found" >&2
exit 2
fi
archive=$(realpath "$archive")
backup_root=$(realpath "$backup_root")
case "$archive" in
"$backup_root"/platform-*.tar.gz.enc) ;;
*) echo "Backup archive is outside the configured backup directory" >&2; exit 2 ;;
esac
if [[ ! -r "$passphrase_file" ]]; then
echo "Backup passphrase file is missing or unreadable" >&2
exit 2
fi
compose=(docker compose -f "$project_root/compose.yaml" -f "$project_root/deploy/server/compose.override.yaml" --env-file "$project_root/deploy/server/.env")
postgres_container=$("${compose[@]}" ps -q postgres)
if [[ -z "$postgres_container" ]] || [[ $(docker inspect -f '{{.State.Running}}' "$postgres_container") != true ]]; then
echo "PostgreSQL service is not running" >&2
exit 3
fi
redis_container=$("${compose[@]}" ps -q redis)
if [[ -z "$redis_container" ]] || [[ $(docker inspect -f '{{.State.Running}}' "$redis_container") != true ]]; then
echo "Redis service is not running" >&2
exit 3
fi
umask 077
work=$(mktemp -d "$backup_root/.verify-XXXXXX")
plain="$work/backup.tar.gz"
verify_token=$(openssl rand -hex 12)
restore_database="voice_restore_verify_$verify_token"
redis_verify_path="/tmp/platform-backup-$verify_token.rdb"
database_created=false
cleanup() {
status=$?
if [[ "$database_created" == true ]]; then
docker exec "$postgres_container" dropdb -U voice --if-exists "$restore_database" >/dev/null 2>&1 || true
fi
docker exec "$redis_container" rm -f -- "$redis_verify_path" >/dev/null 2>&1 || true
rm -rf -- "$work"
exit "$status"
}
trap cleanup EXIT INT TERM
if [[ -f "$archive.sha256" ]]; then
(cd "$(dirname "$archive")" && sha256sum -c "$(basename "$archive").sha256") >/dev/null
fi
openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
-pass "file:$passphrase_file" -in "$archive" -out "$plain"
tar -tzf "$plain" >"$work/archive-paths.txt"
if grep -Eq '(^/|(^|/)\.\.(/|$))' "$work/archive-paths.txt"; then
echo "Backup contains an unsafe archive path" >&2
exit 4
fi
mkdir -p "$work/extracted"
tar -xzf "$plain" --no-same-owner -C "$work/extracted"
(
cd "$work/extracted"
sha256sum -c SHA256SUMS >/dev/null
)
expected=$(tr -d '\r\n' <"$work/extracted/database-evidence.txt")
docker exec "$postgres_container" createdb -U voice "$restore_database"
database_created=true
docker exec -i "$postgres_container" pg_restore -U voice -d "$restore_database" --no-owner --no-acl <"$work/extracted/postgres.dump"
actual=$(docker exec "$postgres_container" psql -U voice -d "$restore_database" -Atc \
"SELECT version||'|'||dirty||'|'||(SELECT count(*) FROM tenant)||'|'||(SELECT count(*) FROM user_account)||'|'||(SELECT count(*) FROM information_schema.tables WHERE table_schema='public')||'|'||(SELECT count(*) FROM information_schema.table_constraints WHERE constraint_type='FOREIGN KEY') FROM schema_migrations;")
if [[ "$actual" != "$expected" ]]; then
echo "Restored database evidence does not match the backup: expected=$expected actual=$actual" >&2
exit 5
fi
[[ -s "$work/extracted/redis/dump.rdb" ]] || { echo "Redis snapshot is missing" >&2; exit 6; }
[[ -d "$work/extracted/object-store" ]] || { echo "Object-store snapshot is missing" >&2; exit 6; }
[[ -d "$work/extracted/keycloak" ]] || { echo "Keycloak snapshot is missing" >&2; exit 6; }
expected_object_files=$(sed -n 's/^object_files=//p' "$work/extracted/manifest.properties")
expected_keycloak_files=$(sed -n 's/^keycloak_files=//p' "$work/extracted/manifest.properties")
actual_object_files=$(find "$work/extracted/object-store" -type f | wc -l | tr -d ' ')
actual_keycloak_files=$(find "$work/extracted/keycloak" -type f | wc -l | tr -d ' ')
if [[ ! "$expected_object_files" =~ ^[0-9]+$ || "$actual_object_files" != "$expected_object_files" ]]; then
echo "Object-store file count does not match the manifest" >&2
exit 6
fi
if [[ ! "$expected_keycloak_files" =~ ^[1-9][0-9]*$ || "$actual_keycloak_files" != "$expected_keycloak_files" ]]; then
echo "Keycloak file count does not match the manifest" >&2
exit 6
fi
docker cp "$work/extracted/redis/dump.rdb" "$redis_container:$redis_verify_path" >/dev/null
docker exec "$redis_container" redis-check-rdb "$redis_verify_path" >/dev/null
docker exec "$redis_container" rm -f -- "$redis_verify_path"
printf 'backup_restore_verified archive=%s database_evidence=%s object_files=%s keycloak_files=%s redis_rdb=valid\n' \
"$archive" "$actual" "$actual_object_files" "$actual_keycloak_files"
+4 -1
View File
@@ -32,7 +32,10 @@ server {
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https: http://localhost:*; img-src 'self' data: blob: https:; media-src 'self' blob: https: http://localhost:*; style-src 'self'; script-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self' https: http://localhost:*" always;
# Ant Design v6 generates component styles at runtime and also relies on
# safe element-level layout styles. Scripts remain restricted to self;
# only CSS is allowed inline.
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https: http://localhost:*; img-src 'self' data: blob: https:; media-src 'self' blob: https: http://localhost:*; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self' https: http://localhost:*" always;
}
location = /runtime-config.js {
Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+69
View File
@@ -0,0 +1,69 @@
# 故事与记忆设计 QA
- source visual truth: `C:\Users\19926\AppData\Local\Temp\codex-clipboard-1f63ed7f-0c86-4167-9ac0-3b8755196941.png`
- source implementation code: `C:\Users\19926\.codex\attachments\8ae4c28d-4e71-4dfa-aaac-c01ce9b49597\pasted-text.txt`(第 410 行起“故事与记忆-重置版”)
- earlier failing implementation: `C:\Users\19926\AppData\Local\Temp\codex-clipboard-c4313a8a-bc8f-49d6-9a82-69137a0984d9.png`
- implementation screenshot: `E:\Codex\story\platform\design-qa-story-implementation.png`
- combined comparison: `E:\Codex\story\platform\design-qa-story-comparison.png`
- browser route: `http://127.0.0.1:5173/?section=stories`
- browser CSS viewport: 1348 x 936 CSS px, device scale factor 1
- source pixels: 923 x 655
- implementation pixels: 1333 x 926
- normalization: source 保持 923 x 655;实现截图按宽度缩放至 923 px 并在 923 x 655 面板中居中;两个面板并排写入同一张比较图。由于参考图是设计导出图而不是可控浏览器视口,本次同时以内容区域宽度、相同登录态和相同空记忆状态校验,不对门户外壳的像素比例做伪精确判断。
- state: 已登录的家庭所有者;当前家庭“合成数据一号家庭”;当前长者“测试长者”;5 个真实候选故事;0 条已确认记忆;状态流已连接。
**Findings**
- 没有剩余 P0/P1/P2 视觉问题。
- 参考图只绘制了 4 张示例候选卡,但标题计数为 5;真实实现按现有接口返回 5 条数据,因此第 5 张卡正常换到下一行。该差异是数据驱动的预期行为,不能通过隐藏真实候选来追求静态截图一致。
- 门户保留现有“安心陪伴”顶栏、完整业务导航和登录账户入口;设计 QA 的精确匹配范围是本次修改的故事与记忆主体区。这样不会删除现有路由或改变业务边界。
**Required Fidelity Surfaces**
- Fonts and typography: 标题、区块标题、候选正文、状态与版本号的层级、字重、换行和行高与参考一致;中文继续使用项目既有 `PingFang SC` / `Microsoft YaHei` 回退,不额外引入网络字体。
- Spacing and layout rhythm: 长者筛选与家庭状态合并为一条全宽工具栏;候选区为桌面四列;记忆标题、搜索框和虚线空状态按垂直顺序整行排列;旧的 17rem 左栏已移除。
- Colors and visual tokens: 候选卡恢复暖白底、浅灰边框、琥珀审核状态和轻阴影;不再被全局主按钮规则染成整块砖红色。连接状态仍使用真实语义绿色,而不是照抄参考图的静态红点。
- Image quality and asset fidelity: 主体区没有照片或插画资产;审核、标签、搜索、刷新和空状态使用项目已有 Ant Design 图标库,没有占位符、手绘 SVG 或文本图标替代。
- Copy and content: 标题、说明、筛选标签、故事候选、已确认记忆、搜索占位和空状态文案与设计源一致;候选标题和数量来自真实接口数据。
**Comparison History**
1. Earlier comparison — blocked
- P1: 旧 `.story-layout` 的 `17rem 1fr` 网格仍生效,长者筛选、候选区和记忆区被拆成错误的左右栏。
- P1: 全局客户按钮样式覆盖候选卡,所有故事卡显示为整块砖红色。
- P2: `Layout.Content` 作为 `main` 继承了全局 80px 上下内边距,所有客户页面顶部出现大块空白。
2. Fixes applied
- 将长者筛选放入同一条工作台工具栏;故事主体显式重置为单列文档流。
- 候选卡从全局主按钮规则中排除并固定为桌面四列白卡。
- 客户内容容器显式清除全局 `main` 的宽度、外边距和 80px 内边距,所有客户页主体上移。
- 补齐沙漏、标签和刷新图标,正文恢复常规字重;保留既有审核详情弹窗和数据接口。
3. Post-fix evidence — passed
- 并排比较图中,页面标题、工具栏、四列首行候选、记忆搜索和空状态的结构与视觉层级一致。
- 浏览器实测首张候选可打开“故事详情”弹窗,来源操作存在,关闭按钮可恢复列表状态。
- 浏览器开发日志仅包含 Vite 连接、热更新和 React DevTools 提示,没有 error 级别日志。
**Primary Interactions Tested**
- 打开故事候选详情:通过。
- 关闭故事详情弹窗:通过。
- 详情中的“查看完整原始通话”入口存在:通过。
- 长者下拉、当前家庭下拉和刷新按钮均可见且保持原有事件处理:通过。
**Open Questions**
- 无。
**Implementation Checklist**
- [x] 工具栏合并并横向占满。
- [x] 候选卡恢复四列白卡样式。
- [x] 已确认记忆移到候选区下方并整行占满。
- [x] 修复所有客户页面顶部公共空白。
- [x] 保留真实第 5 条故事和现有审核/记忆接口。
- [x] 类型检查、生产构建、浏览器交互和控制台日志检查通过。
**Follow-up Polish**
- 无阻塞项。
final result: passed
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
release/
*.log
+47
View File
@@ -0,0 +1,47 @@
# Windows 语音终端模拟器
该程序使用Electron和火山引擎Web RTC SDK模拟一台真实语音终端。它采集Windows麦克风、加入平台分配的RTC房间、发布本地音频、订阅并播放豆包智能体音频,并通过设备v2接口完成激活、状态等待、心跳、Token续签和结束通话。进房后还会启动RTC实时字幕,收集长者与智能体的最终话轮并通过现有`turns:batch`设备接口可靠上传;字幕未成功启动时拒绝继续激活通话,避免生成没有聊天记录的完成通话。
创建通话返回的隐私策略允许原录音时,模拟器同时通过RTC原始音频帧回调采集长者`near`和智能体`far`轨道。结束通话后自动封装为单声道WAV PCM16、按平台限制分片、申请短期对象地址直传,并等待后台完整性与容器校验完成。家庭工作台的通话记录会标记“可听聊天语音”,获得独立PLAY授权的账号可在详情中选择“听长者语音”或“听智能体语音”。
## 构建
```powershell
Set-Location .\device-simulator-windows
npm install
npm run build
```
输出:`release/voice-device-simulator-0.1.0-windows-x64.exe`。
连接公网测试服务器时,可以直接双击EXE,也可以使用`launch-cloud-device.ps1`启动。Electron主进程优先读取启动脚本传入的临时环境变量,否则从当前Windows用户的`%LOCALAPPDATA%\DataTerminalVoiceDevice\server-device-secret`读取设备签名密钥。密钥不写入源码、安装包或浏览器存储,也不会回传到渲染进程;输入框留空并显示“已从Windows安全凭据文件加载”即表示可直接检测。
## 自动连续性验证
本地回归时可让模拟器自动启动通话、播放一段测试音频,并把双方最终字幕写入JSON结果文件。该入口只接受本机文件和回环地址,不影响正常交互使用:
```powershell
$env:SIMULATOR_AUTO_TEST = '1'
$env:SIMULATOR_AUTOMATED_AUDIO_PATH = 'C:\path\to\question.wav'
$env:SIMULATOR_AUTO_RESULT_PATH = 'C:\path\to\result.json'
& .\release\voice-device-simulator-0.1.0-windows-x64.exe
```
测试会等待长者与智能体的最终字幕,结束通话后退出;结果文件包含通话ID、最终话轮和通过/失败状态。测试音频支持WAV、MP3和OGG,大小不得超过10 MB。
## 安全边界
- RTC AppKey、火山AK/SK和豆包Access Token只存在本地后端的受保护文件中;
- 模拟器安装包只接收平台签发的短期RTC Token;
- 设备签名密钥只驻留Electron主进程内存,不写入浏览器存储;
- 当前测试构建只允许设备API地址指向`localhost`、`127.0.0.1`或固定测试服务器`https://121.41.46.238`,避免误连其他环境;
- 关闭软件或结束通话时停止音频采集并销毁RTC引擎。
- 未取得`record_near`或`record_far`授权的轨道不会注册PCM回调;`upload_allowed=false`时不会采集或上传任何原录音。
## 实时字幕前提
测试所用RTC AppID必须在火山RTC控制台开启“实时字幕”和“流式语音识别”。流式语音识别凭据来自豆包语音控制台创建的“流式语音识别”应用,包括APP ID、Access Token和Cluster ID;“音视频字幕生成”类型不能替代该应用。未开通时程序会显示`ServiceAccessDenied`并清理本次未激活通话。
最终字幕会在程序的“实时字幕与上传”区域显示。每条最终字幕使用通话内稳定键和严格递增序号上传;网络失败时留在内存队列,并在心跳及结束通话前重试。存在未上传字幕时,程序不会结束并丢弃当前通话。
完整本地启动顺序见[本地真实RTC联调说明](../deploy/local-rtc/README.md)。
+441
View File
@@ -0,0 +1,441 @@
const { app, BrowserWindow, ipcMain, session } = require("electron");
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const tls = require("node:tls");
app.commandLine.appendSwitch("autoplay-policy", "no-user-gesture-required");
app.disableHardwareAcceleration();
let deviceConfig = null;
let desiredConfigRevision = 1;
function createWindow() {
const window = new BrowserWindow({
width: 1220,
height: 820,
minWidth: 980,
minHeight: 700,
backgroundColor: "#eef3f0",
autoHideMenuBar: true,
title: "语音终端模拟器",
webPreferences: {
preload: path.join(__dirname, "preload.cjs"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
const devArgument = process.argv.find((argument) => argument.startsWith("--dev-server-url="));
const devServerURL = devArgument ? devArgument.slice("--dev-server-url=".length) : "";
window.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL) => {
console.error("renderer load failed", { errorCode, errorDescription, validatedURL });
});
window.webContents.on("render-process-gone", (_event, details) => {
console.error("renderer process exited", details);
});
window.webContents.on("console-message", (_event, ...args) => {
const details = args[0];
const message = typeof details === "object" && details ? details.message : String(args[1] ?? details ?? "");
console.log(`[renderer] ${message}`);
});
window.webContents.once("did-finish-load", async () => {
try {
const diagnostics = await window.webContents.executeJavaScript(`JSON.stringify({readyState: document.readyState, bodyLength: document.body.innerText.length, appChildren: document.querySelector('#app')?.childElementCount || 0})`);
console.log(`[renderer-ready] ${diagnostics}`);
} catch (error) {
console.error("renderer diagnostics failed", error);
}
if (process.env.SIMULATOR_AUTO_TEST === "1") {
void runAutomatedSmoke(window);
}
});
if (devServerURL) {
window.loadURL(devServerURL);
} else {
window.loadFile(path.join(__dirname, "..", "dist", "index.html"));
}
const screenshotPath = process.env.SIMULATOR_SCREENSHOT_PATH;
if (screenshotPath) {
window.webContents.once("did-finish-load", () => {
setTimeout(async () => {
const image = await window.webContents.capturePage();
fs.writeFileSync(screenshotPath, image.toPNG());
app.quit();
}, 1200);
});
}
}
async function runAutomatedSmoke(window) {
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
await delay(800);
await window.webContents.executeJavaScript(`document.querySelector('#checkButton')?.click()`);
for (let attempt = 0; attempt < 30; attempt += 1) {
await delay(500);
const state = await window.webContents.executeJavaScript(`JSON.stringify({phase: document.querySelector('#phaseBadge')?.textContent, canStart: !document.querySelector('#startButton')?.disabled, detail: document.querySelector('#apiDetail')?.textContent})`);
console.log(`[auto-test] ${state}`);
const parsed = JSON.parse(state);
if (parsed.canStart) {
await window.webContents.executeJavaScript(`document.querySelector('#startButton')?.click()`);
console.log("[auto-test] start requested");
await collectAutomatedConversationResult(window);
return;
}
if (parsed.phase === "异常") {
return;
}
}
console.error("[auto-test] environment check did not become ready");
}
async function collectAutomatedConversationResult(window) {
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
const deadline = Date.now() + 120_000;
let snapshot = {};
while (Date.now() < deadline) {
await delay(1000);
const encoded = await window.webContents.executeJavaScript(`JSON.stringify({
phase: document.querySelector('#phaseBadge')?.textContent || '',
conversation_id: document.querySelector('#conversationID')?.textContent || '',
subject: [...document.querySelectorAll('.transcript-turn--subject span')].map((item) => item.textContent || ''),
agent: [...document.querySelectorAll('.transcript-turn--agent span')].map((item) => item.textContent || ''),
turns: [...document.querySelectorAll('.transcript-turn')].map((item) => ({speaker: item.classList.contains('transcript-turn--subject') ? 'subject' : 'agent', text: item.querySelector('span')?.textContent || ''})),
events: [...document.querySelectorAll('#eventLog li span')].map((item) => item.textContent || '')
})`);
snapshot = JSON.parse(encoded);
console.log(`[auto-test] conversation ${encoded}`);
const subjectIndex = snapshot.turns?.findIndex((turn) => turn.speaker === "subject") ?? -1;
const hasAgentReply = subjectIndex >= 0 && snapshot.turns.slice(subjectIndex + 1).some((turn) => turn.speaker === "agent");
if (hasAgentReply) {
await window.webContents.executeJavaScript(`document.querySelector('#stopButton')?.click()`);
snapshot.result = "PASS";
break;
}
if (snapshot.phase === "异常") {
snapshot.result = "ERROR";
break;
}
}
if (!snapshot.result) snapshot.result = "TIMEOUT";
const resultPath = process.env.SIMULATOR_AUTO_RESULT_PATH;
if (resultPath) {
fs.writeFileSync(path.resolve(resultPath), JSON.stringify(snapshot, null, 2), "utf8");
}
await delay(4000);
app.quit();
}
function ensureAllowedAPIURL(value) {
const parsed = new URL(String(value || ""));
const loopback = parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" || parsed.hostname === "[::1]";
const deployedTestServer = parsed.protocol === "https:" && parsed.hostname === "121.41.46.238" && parsed.port === "";
if ((!loopback || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) && !deployedTestServer) {
throw new Error("测试设备只允许连接本机平台或已部署的测试服务器");
}
return parsed.origin;
}
function configure(input) {
const apiURL = ensureAllowedAPIURL(input?.apiURL);
const deviceID = String(input?.deviceID || "").trim();
const deviceSecret = String(input?.deviceSecret || "") || loadDefaultDeviceSecret();
if (!/^[0-9a-f-]{36}$/i.test(deviceID)) {
throw new Error("设备 ID 必须是 UUID");
}
if (deviceSecret.length < 32) {
throw new Error("未找到有效的设备密钥。请使用云端设备启动脚本,或在设备签名密钥中输入至少32个字符");
}
deviceConfig = { apiURL, deviceID, deviceSecret };
return { apiURL, deviceID };
}
function loadDefaultDeviceSecret() {
const environmentSecret = String(process.env.SIMULATOR_DEFAULT_DEVICE_SECRET || "").trim();
if (environmentSecret) return environmentSecret;
const candidates = [];
const localAppData = String(process.env.LOCALAPPDATA || "").trim();
const userProfile = String(process.env.USERPROFILE || "").trim();
const portableDirectory = String(process.env.PORTABLE_EXECUTABLE_DIR || "").trim();
if (localAppData) candidates.push(path.join(localAppData, "DataTerminalVoiceDevice", "server-device-secret"));
if (userProfile) candidates.push(path.join(userProfile, "AppData", "Local", "DataTerminalVoiceDevice", "server-device-secret"));
try {
const home = app.getPath("home");
if (home) candidates.push(path.join(home, "AppData", "Local", "DataTerminalVoiceDevice", "server-device-secret"));
} catch { /* app path is unavailable before ready */ }
if (portableDirectory) candidates.push(path.join(portableDirectory, ".server-device-secret"));
candidates.push(path.join(path.dirname(process.execPath), ".server-device-secret"));
for (const secretPath of new Set(candidates)) {
try {
const stats = fs.statSync(secretPath);
if (!stats.isFile() || stats.size < 32 || stats.size > 4096) continue;
const value = fs.readFileSync(secretPath, "utf8").trim();
if (value.length >= 32) return value;
} catch { /* try the next fixed credential location */ }
}
return "";
}
ipcMain.handle("sim:get-runtime-defaults", () => ({
apiURL: process.env.SIMULATOR_DEFAULT_API_URL || "https://121.41.46.238",
deviceID: process.env.SIMULATOR_DEFAULT_DEVICE_ID || "10000000-0000-0000-0000-000000000511",
deviceSecret: "",
deviceSecretConfigured: loadDefaultDeviceSecret().length >= 32,
}));
function nonce() {
return crypto.randomBytes(16).toString("hex");
}
function signedHeaders(method, requestURI, body) {
if (!deviceConfig) throw new Error("请先连接本地平台");
const timestamp = new Date().toISOString();
const requestNonce = nonce();
const digest = crypto.createHash("sha256").update(body).digest("hex");
const canonical = [method.toUpperCase(), requestURI, timestamp, requestNonce, digest].join("\n");
const signature = crypto.createHmac("sha256", deviceConfig.deviceSecret).update(canonical).digest("base64url");
return {
"X-Device-Id": deviceConfig.deviceID,
"X-Device-Timestamp": timestamp,
"X-Device-Nonce": requestNonce,
"X-Device-Content-SHA256": digest,
"X-Device-Signature": signature,
};
}
async function deviceRequest(method, requestPath, payload, idempotencyKey) {
if (!deviceConfig) throw new Error("请先连接本地平台");
const target = new URL(requestPath, `${deviceConfig.apiURL}/`);
const body = payload === undefined ? "" : JSON.stringify(payload);
const headers = signedHeaders(method, `${target.pathname}${target.search}`, body);
headers.Accept = "application/json";
if (body) headers["Content-Type"] = "application/json";
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
const response = await fetch(target, { method, headers, body: body || undefined });
const text = await response.text();
let parsed;
try {
parsed = text ? JSON.parse(text) : {};
} catch {
parsed = { message: text };
}
if (!response.ok) {
const code = parsed?.error?.code || parsed?.code || `HTTP_${response.status}`;
const message = parsed?.error?.message || parsed?.message || "本地平台请求失败";
throw new Error(`${code}: ${message}`);
}
return parsed?.data ?? parsed;
}
ipcMain.handle("sim:configure", (_event, input) => configure(input));
ipcMain.handle("sim:probe", async () => {
const query = new URLSearchParams({
firmware_version: "0.3.0-windows-rtc",
board_rev: "windows-x64",
rtc_sdk_version: "web-4.69.0-electron",
codec: "opus",
sample_rate: "48000",
network: "windows-local",
device_time: new Date().toISOString(),
});
const bootstrap = await deviceRequest("GET", `/device/v2/bootstrap?${query.toString()}`);
const configuration = await deviceRequest("GET", "/device/v2/config");
desiredConfigRevision = Number(configuration.revision || bootstrap?.device?.config_revision || 1);
return { bootstrap, configuration, desiredConfigRevision };
});
const rtcWebEndpoints = [
"access.rtc.volcvideo.com",
"access2-hl.rtc.volcvideo.com",
"common.rtc.volcvideo.com",
"rtcg-access.volcvideos.com",
"rtcg.volcvideos.com",
"web-log-report.rtc.volcvideo.com",
"web-log-report.volcvideos.com",
];
function probeTLSEndpoint(hostname) {
return new Promise((resolve) => {
const socket = tls.connect({ host: hostname, port: 443, servername: hostname, rejectUnauthorized: true });
let finished = false;
const finish = (result) => {
if (finished) return;
finished = true;
socket.destroy();
resolve(result);
};
socket.setTimeout(5_000, () => finish({ hostname, ok: false, error: "连接超时" }));
socket.once("secureConnect", () => finish({ hostname, ok: true }));
socket.once("error", (error) => finish({ hostname, ok: false, error: String(error?.code || error?.message || "连接失败") }));
});
}
ipcMain.handle("sim:probe-rtc-network", async () => {
const endpoints = await Promise.all(rtcWebEndpoints.map(probeTLSEndpoint));
return { endpoints };
});
ipcMain.handle("sim:get-automation-audio", async () => {
const configuredPath = String(process.env.SIMULATOR_AUTOMATED_AUDIO_PATH || "").trim();
if (!configuredPath) return undefined;
const resolvedPath = path.resolve(configuredPath);
const stats = fs.statSync(resolvedPath);
if (!stats.isFile() || stats.size < 1 || stats.size > 10 * 1024 * 1024) {
throw new Error("自动验证音频必须是 1 字节到 10MB 的文件");
}
const extension = path.extname(resolvedPath).toLowerCase();
const mime = extension === ".mp3" ? "audio/mpeg" : extension === ".ogg" ? "audio/ogg" : "audio/wav";
return {
data_url: `data:${mime};base64,${fs.readFileSync(resolvedPath).toString("base64")}`,
source_name: path.basename(resolvedPath),
};
});
ipcMain.handle("sim:create-conversation", async () => deviceRequest("POST", "/device/v2/conversations", {
device_time: new Date().toISOString(),
monotonic_ms: Math.max(0, Math.round(performance.now())),
capabilities: {
rtc: true,
audio_tracks: ["near", "far"],
upload_resume: true,
rtc_credential_renewal: true,
},
network: { type: "windows-local" },
requested_goal: "open_conversation",
}, `windows-create-${nonce()}`));
ipcMain.handle("sim:activate-conversation", async (_event, input) => deviceRequest(
"POST",
`/device/v2/conversations/${encodeURIComponent(input.conversationID)}/activate`,
{
joined_at: new Date().toISOString(),
room_id: input.roomID,
rtc_user_id: input.userID,
rtc_join_result: "success",
config_revision: desiredConfigRevision,
connection_reference: "windows-electron-web-rtc",
},
`windows-activate-${nonce()}`,
));
ipcMain.handle("sim:get-conversation", async (_event, conversationID) => deviceRequest(
"GET",
`/device/v2/conversations/${encodeURIComponent(conversationID)}`,
));
ipcMain.handle("sim:heartbeat", async (_event, input) => deviceRequest(
"POST",
`/device/v2/conversations/${encodeURIComponent(input.conversationID)}/heartbeat`,
{
sequence_no: input.sequenceNo,
device_time: new Date().toISOString(),
monotonic_ms: Math.max(0, Math.round(performance.now())),
conversation_status: "ACTIVE",
rtc: { joined: true, uplink: true, downlink: true },
network: { type: "windows-local" },
firmware_version: "0.3.0-windows-rtc",
reported_config_revision: desiredConfigRevision,
rtc_credential_expires_at: input.credentialExpiresAt,
recording_queue: { pending_segments: 0, failed_segments: 0 },
command_results: [],
},
));
ipcMain.handle("sim:submit-transcript-batch", async (_event, input) => {
const conversationID = String(input?.conversationID || "");
const turns = Array.isArray(input?.turns) ? input.turns : [];
if (!conversationID || turns.length === 0) {
throw new Error("通话编号和最终话轮不能为空");
}
const payload = { turns };
const digest = crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 32);
return deviceRequest(
"POST",
`/device/v2/conversations/${encodeURIComponent(conversationID)}/turns:batch`,
payload,
`windows-turns-${digest}`,
);
});
ipcMain.handle("sim:stop-conversation", async (_event, input) => deviceRequest(
"POST",
`/device/v2/conversations/${encodeURIComponent(input.conversationID)}/stop`,
{
reason: input.reason || "windows_user_hangup",
device_time: new Date().toISOString(),
last_heartbeat_sequence: input.lastHeartbeatSequence || 0,
rtc_left: true,
recording: { near_segments: Number(input.nearSegments || 0), far_segments: Number(input.farSegments || 0) },
},
`windows-stop-${nonce()}`,
));
ipcMain.handle("sim:upload-conversation-recording", async (_event, input) => {
const conversationID = String(input?.conversationID || "");
const supplied = Array.isArray(input?.segments) ? input.segments : [];
if (!conversationID || supplied.length === 0) throw new Error("通话编号和录音分片不能为空");
const segments = supplied.map((segment) => {
const bytes = Buffer.from(segment.bytes);
if (!bytes.length || bytes.length > 64 * 1024 * 1024) throw new Error("单个录音分片必须在 1 字节到 64MiB 之间");
return {
track: segment.track,
sequence_no: Number(segment.sequence_no),
codec: segment.codec,
content_type: segment.content_type,
duration_ms: Number(segment.duration_ms),
expected_size: bytes.length,
expected_digest: crypto.createHash("sha256").update(bytes).digest("hex"),
bytes,
};
});
const manifest = { segments: segments.map(({ bytes: _bytes, ...segment }) => segment) };
const manifestDigest = crypto.createHash("sha256").update(JSON.stringify(manifest)).digest("hex").slice(0, 40);
const batch = await deviceRequest(
"POST",
`/device/v2/conversations/${encodeURIComponent(conversationID)}/upload-batches`,
manifest,
`windows-media-${manifestDigest}`,
);
for (const local of segments) {
const remote = batch.segments?.find((segment) => segment.track === local.track && Number(segment.sequence_no) === local.sequence_no);
if (!remote) throw new Error(`平台未返回${local.track}轨道第${local.sequence_no}段录音`);
if (remote.status === "VERIFIED" || remote.status === "VERIFYING") continue;
if (!remote.upload_url) throw new Error(`平台未签发${local.track}轨道第${local.sequence_no}段上传地址`);
const response = await fetch(remote.upload_url, { method: "PUT", headers: { "Content-Type": local.content_type }, body: local.bytes });
if (!response.ok) throw new Error(`录音对象上传失败:HTTP ${response.status}`);
}
await deviceRequest("POST", `/device/v2/upload-batches/${encodeURIComponent(batch.id)}/complete`, {});
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) {
const current = await deviceRequest("GET", `/device/v2/upload-batches/${encodeURIComponent(batch.id)}`);
if (current.status === "COMPLETED") return current;
if (current.status === "FAILED" || current.status === "EXPIRED") {
const rejected = current.segments?.find((segment) => segment.status === "REJECTED");
throw new Error(rejected?.last_error_code || current.last_error_code || `录音批次${current.status}`);
}
await new Promise((resolve) => setTimeout(resolve, 750));
}
throw new Error("录音已上传,但等待服务器校验超时");
});
app.whenReady().then(() => {
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => permission === "media");
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => callback(permission === "media"));
createWindow();
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
@@ -0,0 +1,16 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("deviceSimulator", {
getRuntimeDefaults: () => ipcRenderer.invoke("sim:get-runtime-defaults"),
configure: (input) => ipcRenderer.invoke("sim:configure", input),
probe: () => ipcRenderer.invoke("sim:probe"),
probeRTCNetwork: () => ipcRenderer.invoke("sim:probe-rtc-network"),
getAutomationAudio: () => ipcRenderer.invoke("sim:get-automation-audio"),
createConversation: () => ipcRenderer.invoke("sim:create-conversation"),
activateConversation: (input) => ipcRenderer.invoke("sim:activate-conversation", input),
getConversation: (conversationID) => ipcRenderer.invoke("sim:get-conversation", conversationID),
heartbeat: (input) => ipcRenderer.invoke("sim:heartbeat", input),
submitTranscriptBatch: (input) => ipcRenderer.invoke("sim:submit-transcript-batch", input),
stopConversation: (input) => ipcRenderer.invoke("sim:stop-conversation", input),
uploadConversationRecording: (input) => ipcRenderer.invoke("sim:upload-conversation-recording", input),
});
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' data:; style-src 'self'; connect-src 'self' data: https://121.41.46.238 https://*.rtc.volcvideo.com https://*.volcvideos.com wss: http://127.0.0.1:* http://localhost:*; media-src 'self' blob:; worker-src 'self' blob: data:; img-src 'self' data:;" />
<title>语音终端模拟器</title>
</head>
<body>
<main id="app"></main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +1,20 @@
@echo off
setlocal
set "SECRET_FILE=%~dp0.server-device-secret"
set "DEVICE_EXE=%~dp0voice-device-simulator-0.1.0-windows-x64.exe"
if not exist "%SECRET_FILE%" (
echo Cloud device credential is missing: %SECRET_FILE%
pause
exit /b 1
)
if not exist "%DEVICE_EXE%" (
echo Voice device simulator is missing: %DEVICE_EXE%
pause
exit /b 1
)
set /p SIMULATOR_DEFAULT_DEVICE_SECRET=<"%SECRET_FILE%"
set "SIMULATOR_DEFAULT_API_URL=https://121.41.46.238"
set "SIMULATOR_DEFAULT_DEVICE_ID=10000000-0000-0000-0000-000000000511"
start "" "%DEVICE_EXE%"
@@ -0,0 +1,25 @@
[CmdletBinding()]
param()
$ErrorActionPreference = "Stop"
$secretPath = Join-Path $env:LOCALAPPDATA "DataTerminalVoiceDevice\server-device-secret"
$executablePath = Join-Path $PSScriptRoot "voice-device-simulator-0.1.0-windows-x64.exe"
if (-not (Test-Path -LiteralPath $secretPath -PathType Leaf)) {
throw "Cloud device credential is missing: $secretPath"
}
if (-not (Test-Path -LiteralPath $executablePath -PathType Leaf)) {
throw "Voice device simulator is missing: $executablePath"
}
$env:SIMULATOR_DEFAULT_API_URL = "https://121.41.46.238"
$env:SIMULATOR_DEFAULT_DEVICE_ID = "10000000-0000-0000-0000-000000000511"
$env:SIMULATOR_DEFAULT_DEVICE_SECRET = [IO.File]::ReadAllText($secretPath).Trim()
try {
Start-Process -FilePath $executablePath
}
finally {
Remove-Item Env:SIMULATOR_DEFAULT_API_URL -ErrorAction SilentlyContinue
Remove-Item Env:SIMULATOR_DEFAULT_DEVICE_ID -ErrorAction SilentlyContinue
Remove-Item Env:SIMULATOR_DEFAULT_DEVICE_SECRET -ErrorAction SilentlyContinue
}
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "data-terminal-voice-device-simulator",
"version": "0.1.0",
"private": true,
"description": "Windows real-audio device simulator for the data terminal cloud platform",
"main": "electron/main.cjs",
"scripts": {
"dev": "concurrently -k \"vite --host 127.0.0.1 --port 5177\" \"wait-on http://127.0.0.1:5177 && electron . --dev-server-url=http://127.0.0.1:5177\"",
"typecheck": "tsc --noEmit",
"build:renderer": "vite build",
"start": "npm run build:renderer && electron .",
"build": "npm run typecheck && npm run build:renderer && electron-builder --win portable"
},
"dependencies": {
"@volcengine/rtc": "4.69.0"
},
"devDependencies": {
"concurrently": "^9.2.1",
"electron": "44.0.0",
"electron-builder": "26.15.3",
"typescript": "^5.9.2",
"vite": "^7.1.3",
"wait-on": "^8.0.4"
},
"build": {
"appId": "cn.dataterminal.voice-device-simulator",
"productName": "语音终端模拟器",
"artifactName": "voice-device-simulator-${version}-windows-${arch}.${ext}",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"electron/**/*",
"package.json"
],
"win": {
"target": [
{
"target": "portable",
"arch": ["x64"]
}
]
}
}
}
+79
View File
@@ -0,0 +1,79 @@
type SimulatorConfiguration = {
apiURL: string;
deviceID: string;
deviceSecret: string;
};
type SimulatorRuntimeDefaults = SimulatorConfiguration & {
deviceSecretConfigured: boolean;
};
type RTCGrant = {
app_id: string;
room_id: string;
user_id: string;
agent_user_id: string;
token: string;
expires_at: string;
};
type CreatedConversation = {
conversation_id: string;
status: string;
rtc: RTCGrant;
privacy: {
retention_mode: "none" | "transcript_only" | "transcript_and_audio";
record_near: boolean;
record_far: boolean;
upload_allowed: boolean;
};
session: {
engine_mode: string;
heartbeat_interval_sec: number;
allocation_expires_at: string;
};
};
type DeviceFinalTurn = {
turn_key: string;
sequence_no: number;
speaker: "subject" | "agent";
start_at: string;
end_at: string;
final_text: string;
interrupted: boolean;
};
interface Window {
deviceSimulator: {
getRuntimeDefaults(): Promise<SimulatorRuntimeDefaults>;
configure(input: SimulatorConfiguration): Promise<{ apiURL: string; deviceID: string }>;
probe(): Promise<Record<string, unknown>>;
probeRTCNetwork(): Promise<{ endpoints: Array<{ hostname: string; ok: boolean; error?: string }> }>;
getAutomationAudio(): Promise<{ data_url: string; source_name: string } | undefined>;
createConversation(): Promise<CreatedConversation>;
activateConversation(input: { conversationID: string; roomID: string; userID: string }): Promise<Record<string, unknown>>;
getConversation(conversationID: string): Promise<{ status: string; action: string; failure_code?: string }>;
heartbeat(input: { conversationID: string; sequenceNo: number; credentialExpiresAt: string }): Promise<{
actions?: Array<{ type: string; rtc?: RTCGrant }>;
desired_config_revision?: number;
}>;
submitTranscriptBatch(input: { conversationID: string; turns: DeviceFinalTurn[] }): Promise<{
conversation_id: string;
accepted_count: number;
duplicate_count: number;
}>;
stopConversation(input: { conversationID: string; reason: string; lastHeartbeatSequence: number; nearSegments: number; farSegments: number }): Promise<Record<string, unknown>>;
uploadConversationRecording(input: {
conversationID: string;
segments: Array<{
track: "near" | "far";
sequence_no: number;
codec: "wav_pcm_s16le";
content_type: "audio/wav";
duration_ms: number;
bytes: Uint8Array;
}>;
}): Promise<{ id: string; status: string; segments: Array<{ id: string; track: "near" | "far"; sequence_no: number; status: string }> }>;
};
}
+806
View File
@@ -0,0 +1,806 @@
import VERTC, {
AudioSourceType,
AudioFrameData,
IRTCEngine,
LocalAudioPropertiesInfo,
MediaType,
RemoteAudioPropertiesInfo,
RoomProfileType,
StreamIndex,
SUBTITLE_MODE,
SubtitleEvent,
SubtitleEventType,
SubtitleMessage,
} from "@volcengine/rtc";
import { PCMTrackRecorder, RecordedAudioSegment } from "./recording";
VERTC.setLogConfig({ logLevel: "trace", LogfileSize: 20 });
import "./style.css";
const EngineEventsTypes = VERTC.events;
type Phase = "idle" | "checking" | "ready" | "joining" | "waiting" | "active" | "stopping" | "error";
const state: {
phase: Phase;
engine?: IRTCEngine;
conversation?: CreatedConversation;
credentialExpiresAt?: string;
heartbeatSequence: number;
heartbeatTimer?: number;
statusTimer?: number;
startedAt?: number;
durationTimer?: number;
agentJoined: boolean;
agentSpeaking: boolean;
microphoneVolume: number;
agentVolume: number;
subtitleStarted: boolean;
transcriptSequence: number;
transcriptFirstSeen: Map<string, number>;
collectedTurnKeys: Set<string>;
pendingTurns: DeviceFinalTurn[];
transcriptUpload?: Promise<void>;
subtitleStartWaiter?: { resolve: () => void; reject: (error: Error) => void };
automationAudio?: HTMLAudioElement;
automationAudioContext?: AudioContext;
automationAudioTrack?: MediaStreamTrack;
automationAudioObjectURL?: string;
nearRecorder?: PCMTrackRecorder;
farRecorder?: PCMTrackRecorder;
} = {
phase: "idle",
heartbeatSequence: 0,
agentJoined: false,
agentSpeaking: false,
microphoneVolume: 0,
agentVolume: 0,
subtitleStarted: false,
transcriptSequence: 0,
transcriptFirstSeen: new Map(),
collectedTurnKeys: new Set(),
pendingTurns: [],
};
const app = document.querySelector<HTMLElement>("#app")!;
app.innerHTML = `
<section class="shell">
<header class="hero">
<div>
<p class="eyebrow">WINDOWS · REAL RTC DEVICE</p>
<h1>语音终端模拟器</h1>
<p class="subtitle">使用真实麦克风和扬声器,验证“终端入房—豆包进房—双向语音—打断—结束”的完整链路。</p>
</div>
<div class="overall" id="overallStatus"><span class="pulse"></span><strong>尚未连接</strong></div>
</header>
<section class="status-grid" aria-label="链路状态">
<article class="status-card"><span>云端平台</span><strong id="apiStatus">未检测</strong><small id="apiDetail">121.41.46.238</small></article>
<article class="status-card"><span>RTC 房间</span><strong id="rtcStatus">未加入</strong><small id="rtcDetail">等待开始</small></article>
<article class="status-card"><span>豆包智能体</span><strong id="agentStatus">未进房</strong><small id="agentDetail">等待 StartVoiceChat</small></article>
<article class="status-card"><span>通话时长</span><strong id="duration">00:00</strong><small id="modeDetail">处理方式:—</small></article>
</section>
<section class="content-grid">
<article class="panel setup-panel">
<div class="panel-heading"><div><p class="eyebrow">DEVICE SETUP</p><h2>测试设备配置</h2></div><span class="safe" id="credentialStatus">正在检查凭据</span></div>
<label>云端平台地址<input id="apiURL" value="https://121.41.46.238" autocomplete="off" /></label>
<label>模拟设备 ID<input id="deviceID" value="10000000-0000-0000-0000-000000000511" autocomplete="off" /></label>
<label>设备签名密钥<input id="deviceSecret" type="password" value="" autocomplete="off" /></label>
<div class="row">
<label>麦克风<select id="microphone"><option value="">默认麦克风</option></select></label>
<label>扬声器<select id="speaker"><option value="">默认扬声器</option></select></label>
</div>
<button class="secondary" id="checkButton">检测平台和音频设备</button>
<p class="hint">RTC AppKey、火山 AK/SK 和豆包 Token 配置在云端后台,本软件不会读取或保存它们。</p>
</article>
<article class="panel call-panel">
<div class="panel-heading"><div><p class="eyebrow">LIVE CONVERSATION</p><h2>真实双向语音</h2></div><span class="phase-badge" id="phaseBadge">待机</span></div>
<div class="conversation-orb" id="conversationOrb"><div class="orb-core"><span id="orbLabel">准备开始</span></div></div>
<div class="meters">
<div><div class="meter-label"><span>我的麦克风</span><strong id="micValue">0</strong></div><div class="meter"><i id="micMeter"></i></div></div>
<div><div class="meter-label"><span>豆包语音</span><strong id="agentValue">0</strong></div><div class="meter agent"><i id="agentMeter"></i></div></div>
</div>
<div class="call-actions">
<button class="primary" id="startButton" disabled>开始语音聊天</button>
<button class="danger" id="stopButton" disabled>结束通话</button>
</div>
<dl class="session-info">
<div><dt>通话编号</dt><dd id="conversationID">—</dd></div>
<div><dt>RTC 房间</dt><dd id="roomID">—</dd></div>
<div><dt>终端用户</dt><dd id="userID">—</dd></div>
<div><dt>智能体用户</dt><dd id="agentUserID">—</dd></div>
<div><dt>聊天语音</dt><dd id="recordingStatus">等待通话</dd></div>
</dl>
</article>
</section>
<section class="panel log-panel">
<div class="panel-heading"><div><p class="eyebrow">EVENT STREAM</p><h2>实时事件</h2></div><div><button class="text-button" id="downloadRtcLog">导出 RTC 日志</button><button class="text-button" id="clearLog">清空</button></div></div>
<ol id="eventLog" aria-live="polite"><li><time>系统</time><span>模拟器已启动,等待检测本地环境。</span></li></ol>
</section>
<section class="panel transcript-panel">
<div class="panel-heading"><div><p class="eyebrow">LIVE TRANSCRIPT</p><h2>实时字幕与上传</h2></div><span class="phase-badge" id="transcriptStatus">未启动</span></div>
<ol id="liveTranscript" aria-live="polite"><li class="transcript-empty">通话开始后,这里会显示双方的最终字幕。</li></ol>
</section>
</section>
`;
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const controls = {
apiURL: $<HTMLInputElement>("apiURL"),
deviceID: $<HTMLInputElement>("deviceID"),
deviceSecret: $<HTMLInputElement>("deviceSecret"),
microphone: $<HTMLSelectElement>("microphone"),
speaker: $<HTMLSelectElement>("speaker"),
check: $<HTMLButtonElement>("checkButton"),
start: $<HTMLButtonElement>("startButton"),
stop: $<HTMLButtonElement>("stopButton"),
};
void window.deviceSimulator.getRuntimeDefaults().then((defaults) => {
controls.apiURL.value = defaults.apiURL;
controls.deviceID.value = defaults.deviceID;
controls.deviceSecret.value = defaults.deviceSecret;
if (defaults.deviceSecretConfigured) {
controls.deviceSecret.placeholder = "已从Windows安全凭据文件加载";
setText("credentialStatus", "凭据已安全加载");
} else {
controls.deviceSecret.placeholder = "请输入至少32字符的设备密钥";
setText("credentialStatus", "凭据未加载");
}
}).catch((error) => log(`读取测试设备启动配置失败:${errorMessage(error)}`, "error"));
function setText(id: string, value: string) {
$(id).textContent = value;
}
function log(message: string, level: "info" | "success" | "error" = "info") {
console.log(`[${level}] ${message}`);
const item = document.createElement("li");
item.className = level;
const time = document.createElement("time");
time.textContent = new Date().toLocaleTimeString("zh-CN", { hour12: false });
const content = document.createElement("span");
content.textContent = message;
item.append(time, content);
$("eventLog").prepend(item);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function setPhase(phase: Phase, detail?: string) {
state.phase = phase;
const labels: Record<Phase, string> = {
idle: "待机", checking: "检测中", ready: "已就绪", joining: "正在入房", waiting: "等待豆包", active: "通话中", stopping: "结束中", error: "异常",
};
setText("phaseBadge", labels[phase]);
setText("overallStatus", detail || labels[phase]);
$("overallStatus").className = `overall phase-${phase}`;
$("conversationOrb").className = `conversation-orb phase-${phase}${state.agentSpeaking ? " speaking" : ""}`;
setText("orbLabel", detail || labels[phase]);
controls.start.disabled = phase !== "ready";
controls.stop.disabled = !["joining", "waiting", "active", "error"].includes(phase) || !state.conversation;
controls.check.disabled = ["joining", "waiting", "active", "stopping"].includes(phase);
}
async function configureAndProbe() {
setPhase("checking", "正在检测");
try {
await window.deviceSimulator.configure({
apiURL: controls.apiURL.value,
deviceID: controls.deviceID.value,
deviceSecret: controls.deviceSecret.value,
});
const supported = await VERTC.isSupported();
if (!supported) throw new Error("当前 Electron 环境不支持火山 Web RTC SDK");
const permission = await VERTC.enableDevices({ audio: true, video: false });
if (!permission.audio) throw permission.audioExceptionError || new Error("未获得麦克风权限");
await refreshAudioDevices();
await window.deviceSimulator.probe();
const rtcNetwork = await window.deviceSimulator.probeRTCNetwork();
const blockedEndpoints = rtcNetwork.endpoints.filter((endpoint) => !endpoint.ok);
if (blockedEndpoints.length > 0) {
const details = blockedEndpoints.map((endpoint) => `${endpoint.hostname}(${endpoint.error || "连接失败"})`).join("、");
throw new Error(`RTC 网络检测失败:${details}。请关闭代理/VPN、放行 TCP 443,或改用手机热点后重试`);
}
setText("apiStatus", "连接正常");
setText("apiDetail", controls.apiURL.value);
setPhase("ready", "可以开始通话");
log(`环境检测通过,RTC Web SDK ${VERTC.getSdkVersion()},火山 RTC 接入域名可达。`, "success");
} catch (error) {
setText("apiStatus", "检测失败");
setText("apiDetail", errorMessage(error));
setPhase("error", "检测失败");
controls.start.disabled = true;
log(`环境检测失败:${errorMessage(error)}`, "error");
}
}
async function refreshAudioDevices() {
const [microphones, speakers] = await Promise.all([
VERTC.enumerateAudioCaptureDevices(),
VERTC.enumerateAudioPlaybackDevices(),
]);
fillDeviceOptions(controls.microphone, microphones, "默认麦克风");
fillDeviceOptions(controls.speaker, speakers, "默认扬声器");
}
function fillDeviceOptions(select: HTMLSelectElement, devices: MediaDeviceInfo[], fallback: string) {
const previous = select.value;
select.replaceChildren(new Option(fallback, ""));
devices.forEach((device, index) => select.add(new Option(device.label || `${fallback} ${index + 1}`, device.deviceId)));
if ([...select.options].some((option) => option.value === previous)) select.value = previous;
}
function registerRTCEvents(engine: IRTCEngine) {
engine.on(EngineEventsTypes.onSubtitleStateChanged, (event: SubtitleEvent) => {
if (event.event === SubtitleEventType.STARTED) {
state.subtitleStarted = true;
state.subtitleStartWaiter?.resolve();
setText("transcriptStatus", "识别中");
log("RTC 实时字幕已启动,双方最终话轮将自动上传。", "success");
return;
}
if (event.event === SubtitleEventType.STOPPED) {
state.subtitleStarted = false;
state.subtitleStartWaiter?.reject(new Error("RTC 实时字幕在启动期间被停止"));
setText("transcriptStatus", "已停止");
return;
}
if (event.event === SubtitleEventType.ERROR) {
state.subtitleStarted = false;
state.subtitleStartWaiter?.reject(new Error(`RTC 实时字幕启动失败:${event.errorCode || "UNKNOWN"}${event.errorMessage ? ` · ${event.errorMessage}` : ""}`));
setText("transcriptStatus", "识别失败");
log(`RTC 实时字幕错误:${event.errorCode || "UNKNOWN"}${event.errorMessage ? ` · ${event.errorMessage}` : ""}`, "error");
}
});
engine.on(EngineEventsTypes.onSubtitleMessageReceived, (messages: SubtitleMessage[]) => {
collectSubtitleMessages(messages);
});
engine.on(EngineEventsTypes.onUserJoined, (event) => {
const userID = event.userInfo.userId;
log(`远端用户已进房:${userID}`, "success");
if (userID === state.conversation?.rtc.agent_user_id) {
state.agentJoined = true;
setText("agentStatus", "已进房");
setText("agentDetail", userID);
}
});
engine.on(EngineEventsTypes.onUserLeave, (event) => {
const userID = event.userInfo.userId;
log(`远端用户已离房:${userID}`);
if (userID === state.conversation?.rtc.agent_user_id) {
state.agentJoined = false;
setText("agentStatus", "已离房");
}
});
engine.on(EngineEventsTypes.onUserPublishStream, async (event) => {
if ((event.mediaType & MediaType.AUDIO) === 0) return;
log(`检测到远端音频流:${event.userId}`, "success");
try {
await engine.subscribeStream(event.userId, MediaType.AUDIO);
await engine.play(event.userId, MediaType.AUDIO);
if (event.userId === state.conversation?.rtc.agent_user_id) {
setText("agentStatus", "语音已连接");
setText("agentDetail", "正在接收豆包音频");
}
} catch (error) {
log(`播放远端音频失败:${errorMessage(error)}`, "error");
}
});
engine.on(EngineEventsTypes.onLocalAudioPropertiesReport, (reports: LocalAudioPropertiesInfo[]) => {
const volume = Math.max(0, ...reports.map((item) => item.audioPropertiesInfo.linearVolume || 0));
updateVolume("mic", volume);
});
engine.on(EngineEventsTypes.onRemoteAudioPropertiesReport, (reports: RemoteAudioPropertiesInfo[]) => {
const agentID = state.conversation?.rtc.agent_user_id;
const volume = Math.max(0, ...reports.filter((item) => !agentID || item.streamKey.userId === agentID).map((item) => item.audioPropertiesInfo.linearVolume || 0));
state.agentSpeaking = volume > 5;
updateVolume("agent", volume);
setPhase(state.phase, state.phase === "active" ? (state.agentSpeaking ? "豆包正在说话" : "请开始说话") : undefined);
});
engine.on(EngineEventsTypes.onConnectionStateChanged, (event) => {
const names: Record<number, string> = {
0: "准备连接",
1: "已断开",
2: "连接中",
3: "已连接",
4: "重连中",
5: "已重连",
6: "连接丢失",
};
const level = event.state === 1 || event.state === 6 ? "error" : "info";
log(`RTC 连接状态:${String(event.state)}(${names[event.state] || "未知"})${event.reason !== undefined ? `,原因 ${String(event.reason)}` : ""}`, level);
});
engine.on(EngineEventsTypes.onError, (event) => {
log(`RTC 错误:${event.errorCode}`, "error");
});
engine.on(EngineEventsTypes.onTokenWillExpire, () => {
log("RTC Token 即将过期,等待心跳续签。", "info");
});
}
function subtitleIdentity(message: SubtitleMessage) {
const conversation = state.conversation;
if (!conversation) return undefined;
if (message.userId === conversation.rtc.user_id) return { speaker: "subject" as const, label: "长者" };
if (message.userId === conversation.rtc.agent_user_id) return { speaker: "agent" as const, label: "豆包" };
return undefined;
}
function subtitleKey(message: SubtitleMessage) {
return `${message.userId}:${message.sequence}`;
}
function collectSubtitleMessages(messages: SubtitleMessage[]) {
const now = Date.now();
for (const message of messages) {
const identity = subtitleIdentity(message);
const text = message.text.trim();
if (!identity || !text || message.mode !== SUBTITLE_MODE.ASR_ONLY) continue;
const sourceKey = subtitleKey(message);
if (!state.transcriptFirstSeen.has(sourceKey)) state.transcriptFirstSeen.set(sourceKey, now);
if (!message.definite || state.collectedTurnKeys.has(sourceKey)) continue;
state.collectedTurnKeys.add(sourceKey);
state.transcriptSequence += 1;
const turn: DeviceFinalTurn = {
turn_key: `rtc-subtitle-${identity.speaker}-${message.sequence}`,
sequence_no: state.transcriptSequence,
speaker: identity.speaker,
start_at: new Date(state.transcriptFirstSeen.get(sourceKey) || now).toISOString(),
end_at: new Date(now).toISOString(),
final_text: text,
interrupted: false,
};
state.pendingTurns.push(turn);
renderTranscriptTurn(turn, identity.label);
}
void flushTranscriptTurns();
}
function renderTranscriptTurn(turn: DeviceFinalTurn, label: string) {
const list = $("liveTranscript");
list.querySelector(".transcript-empty")?.remove();
const item = document.createElement("li");
item.className = `transcript-turn transcript-turn--${turn.speaker}`;
const speaker = document.createElement("strong");
speaker.textContent = label;
const text = document.createElement("span");
text.textContent = turn.final_text;
item.append(speaker, text);
list.append(item);
}
async function flushTranscriptTurns() {
if (state.transcriptUpload) return state.transcriptUpload;
if (!state.conversation || state.pendingTurns.length === 0) return;
const conversationID = state.conversation.conversation_id;
const batch = state.pendingTurns.slice(0, 500);
state.transcriptUpload = (async () => {
try {
const result = await window.deviceSimulator.submitTranscriptBatch({ conversationID, turns: batch });
state.pendingTurns.splice(0, batch.length);
const uploaded = state.transcriptSequence - state.pendingTurns.length;
setText("transcriptStatus", state.subtitleStarted ? `识别中 · 已上传 ${uploaded}` : `已上传 ${uploaded}`);
log(`最终字幕已上传:新增 ${result.accepted_count} 条,重复 ${result.duplicate_count} 条。`, "success");
} catch (error) {
setText("transcriptStatus", `待重试 ${state.pendingTurns.length} 条`);
log(`最终字幕上传失败,将在心跳或结束通话时重试:${errorMessage(error)}`, "error");
} finally {
state.transcriptUpload = undefined;
}
})();
await state.transcriptUpload;
if (state.pendingTurns.length > 0 && state.pendingTurns.length !== batch.length) void flushTranscriptTurns();
}
async function flushTranscriptTurnsWithRetry(attempts: number) {
for (let attempt = 1; attempt <= attempts && state.pendingTurns.length > 0; attempt += 1) {
await flushTranscriptTurns();
if (state.pendingTurns.length === 0) return;
if (attempt < attempts) await new Promise((resolve) => window.setTimeout(resolve, attempt * 500));
}
}
async function startSubtitleAndWait(engine: IRTCEngine) {
let timeoutID: number | undefined;
const stateEvent = new Promise<void>((resolve, reject) => {
state.subtitleStartWaiter = { resolve, reject };
});
const timeout = new Promise<void>((_resolve, reject) => {
timeoutID = window.setTimeout(() => reject(new Error("等待 RTC 实时字幕启动超时")), 10_000);
});
try {
await engine.startSubtitle({ mode: SUBTITLE_MODE.ASR_ONLY });
await Promise.race([stateEvent, timeout]);
} finally {
if (timeoutID) window.clearTimeout(timeoutID);
state.subtitleStartWaiter = undefined;
}
}
function updateVolume(kind: "mic" | "agent", raw: number) {
const value = Math.max(0, Math.min(100, Math.round(raw)));
if (kind === "mic") state.microphoneVolume = value;
else state.agentVolume = value;
$(`${kind}Meter`).style.width = `${value}%`;
setText(`${kind}Value`, String(value));
}
async function startConversation() {
setPhase("joining", "正在申请房间");
controls.start.disabled = true;
try {
const created = await window.deviceSimulator.createConversation();
state.conversation = created;
state.credentialExpiresAt = created.rtc.expires_at;
state.heartbeatSequence = 0;
state.agentJoined = false;
state.subtitleStarted = false;
state.transcriptSequence = 0;
state.transcriptFirstSeen.clear();
state.collectedTurnKeys.clear();
state.pendingTurns = [];
state.transcriptUpload = undefined;
state.nearRecorder = undefined;
state.farRecorder = undefined;
$("liveTranscript").innerHTML = '<li class="transcript-empty">正在等待双方的最终字幕。</li>';
renderSession(created);
log(`平台已分配 RTC 房间 ${created.rtc.room_id}。`, "success");
const engine = VERTC.createEngine(created.rtc.app_id);
state.engine = engine;
registerRTCEvents(engine);
engine.enableAudioPropertiesReport({ interval: 300 });
configureRecording(engine, created);
const automatedAudio = await prepareAutomationAudio(engine);
if (!automatedAudio) await engine.startAudioCapture(controls.microphone.value || undefined);
if (controls.speaker.value) await engine.setAudioPlaybackDevice(controls.speaker.value);
log("正在连接火山 RTC 信令并加入房间。", "info");
await Promise.race([
engine.joinRoom(created.rtc.token, created.rtc.room_id, { userId: created.rtc.user_id }, {
isAutoPublish: true,
isAutoSubscribeAudio: true,
isAutoSubscribeVideo: false,
roomProfileType: RoomProfileType.chat,
}),
new Promise<never>((_resolve, reject) => window.setTimeout(() => reject(new Error("RTC 入房超过 15 秒。请关闭代理/VPN、允许本程序通过 Windows 防火墙,或改用手机热点后重试")), 15_000)),
]);
setText("rtcStatus", "已加入");
setText("rtcDetail", created.rtc.room_id);
log("终端已加入 RTC 房间并发布麦克风。", "success");
setText("transcriptStatus", "正在启动");
await startSubtitleAndWait(engine);
await window.deviceSimulator.activateConversation({
conversationID: created.conversation_id,
roomID: created.rtc.room_id,
userID: created.rtc.user_id,
});
setPhase("waiting", "正在拉入豆包");
log("平台已提交 StartVoiceChat,等待豆包智能体激活。", "success");
await waitUntilActive(created.conversation_id);
state.startedAt = Date.now();
setPhase("active", "请开始说话");
startTimers(created.session.heartbeat_interval_sec || 15);
log("豆包语音任务已激活,可以开始双向聊天。", "success");
if (automatedAudio) {
await new Promise((resolve) => window.setTimeout(resolve, 4_000));
await state.automationAudioContext?.resume();
await state.automationAudio?.play();
log("自动验证音频已通过 RTC 自定义音轨发送。", "success");
}
} catch (error) {
setPhase("error", "启动失败");
log(`启动通话失败:${errorMessage(error)}`, "error");
await releaseRTC();
if (state.conversation) {
try {
await window.deviceSimulator.stopConversation({
conversationID: state.conversation.conversation_id,
reason: "windows_start_failed",
lastHeartbeatSequence: state.heartbeatSequence,
nearSegments: 0,
farSegments: 0,
});
} catch (cleanupError) {
log(`清理失败通话时发生错误:${errorMessage(cleanupError)}`, "error");
}
}
resetSession();
}
}
function configureRecording(engine: IRTCEngine, conversation: CreatedConversation) {
const privacy = conversation.privacy;
if (!privacy.upload_allowed || (!privacy.record_near && !privacy.record_far)) {
setText("recordingStatus", "本次未授权录音");
log("本次通话的隐私策略未授权保存原录音,不采集音频帧。", "info");
return;
}
if (privacy.record_near) {
state.nearRecorder = new PCMTrackRecorder();
engine.setAudioFrameCallback(StreamIndex.STREAM_INDEX_MAIN, undefined, (frame: AudioFrameData) => state.nearRecorder?.append(frame), 4096);
}
if (privacy.record_far) {
state.farRecorder = new PCMTrackRecorder();
engine.setAudioFrameCallback(StreamIndex.STREAM_INDEX_MAIN, conversation.rtc.agent_user_id, (frame: AudioFrameData) => state.farRecorder?.append(frame), 4096);
}
setText("recordingStatus", "录音中");
log(`已按授权开始采集${privacy.record_near ? "长者近端" : ""}${privacy.record_near && privacy.record_far ? "和" : ""}${privacy.record_far ? "智能体远端" : ""} PCM 音频。`, "success");
}
function finishRecording(engine: IRTCEngine | undefined, conversation: CreatedConversation) {
try { engine?.setAudioFrameCallback(StreamIndex.STREAM_INDEX_MAIN, undefined, undefined); } catch { /* no-op */ }
try { engine?.setAudioFrameCallback(StreamIndex.STREAM_INDEX_MAIN, conversation.rtc.agent_user_id, undefined); } catch { /* no-op */ }
const segments = [
...(state.nearRecorder?.finish("near") ?? []),
...(state.farRecorder?.finish("far") ?? []),
];
state.nearRecorder = undefined;
state.farRecorder = undefined;
return segments;
}
async function prepareAutomationAudio(engine: IRTCEngine) {
const asset = await window.deviceSimulator.getAutomationAudio();
if (!asset) return false;
const comma = asset.data_url.indexOf(",");
if (comma < 0) throw new Error("自动验证音频编码无效");
const metadata = asset.data_url.slice(0, comma);
const mime = metadata.match(/^data:([^;]+)/)?.[1] || "audio/wav";
const bytes = Uint8Array.from(atob(asset.data_url.slice(comma + 1)), (value) => value.charCodeAt(0));
const objectURL = URL.createObjectURL(new Blob([bytes], { type: mime }));
const audio = new Audio(objectURL);
audio.preload = "auto";
await new Promise<void>((resolve, reject) => {
audio.addEventListener("canplaythrough", () => resolve(), { once: true });
audio.addEventListener("error", () => reject(new Error(`无法加载自动验证音频:${asset.source_name}`)), { once: true });
audio.load();
});
const context = new AudioContext({ sampleRate: 48_000 });
const source = context.createMediaElementSource(audio);
const destination = context.createMediaStreamDestination();
source.connect(destination);
const track = destination.stream.getAudioTracks()[0];
if (!track) throw new Error("自动验证音频没有可发布的音轨");
await engine.setAudioSourceType(StreamIndex.STREAM_INDEX_MAIN, AudioSourceType.AUDIO_SOURCE_TYPE_EXTERNAL);
await engine.setExternalAudioTrack(StreamIndex.STREAM_INDEX_MAIN, track);
state.automationAudio = audio;
state.automationAudioContext = context;
state.automationAudioTrack = track;
state.automationAudioObjectURL = objectURL;
log(`已准备自动验证音频:${asset.source_name}。`, "success");
return true;
}
function renderSession(created: CreatedConversation) {
setText("conversationID", created.conversation_id);
setText("roomID", created.rtc.room_id);
setText("userID", created.rtc.user_id);
setText("agentUserID", created.rtc.agent_user_id);
setText("modeDetail", `处理方式:${created.session.engine_mode}`);
setText("agentStatus", "等待进房");
setText("agentDetail", created.rtc.agent_user_id);
}
async function waitUntilActive(conversationID: string) {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const result = await window.deviceSimulator.getConversation(conversationID);
if (result.action === "continue" && result.status === "ACTIVE") return;
if (result.action === "leave_room") throw new Error(result.failure_code || `智能体启动失败:${result.status}`);
await new Promise((resolve) => window.setTimeout(resolve, 600));
}
throw new Error("等待豆包进房超时(30秒)");
}
function startTimers(intervalSeconds: number) {
stopTimers();
state.durationTimer = window.setInterval(() => {
if (!state.startedAt) return;
const seconds = Math.floor((Date.now() - state.startedAt) / 1000);
setText("duration", `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`);
}, 1000);
state.heartbeatTimer = window.setInterval(() => void sendHeartbeat(), Math.max(5, intervalSeconds) * 1000);
void sendHeartbeat();
}
async function sendHeartbeat() {
if (!state.conversation || !state.credentialExpiresAt || state.phase !== "active") return;
try {
state.heartbeatSequence += 1;
const result = await window.deviceSimulator.heartbeat({
conversationID: state.conversation.conversation_id,
sequenceNo: state.heartbeatSequence,
credentialExpiresAt: state.credentialExpiresAt,
});
for (const action of result.actions || []) {
if (action.type === "renew_rtc_credential" && action.rtc?.token) {
await state.engine?.updateToken(action.rtc.token);
state.credentialExpiresAt = action.rtc.expires_at;
log("RTC Token 已通过设备心跳完成续签。", "success");
}
}
await flushTranscriptTurns();
} catch (error) {
log(`设备心跳失败:${errorMessage(error)}`, "error");
}
}
async function stopConversation(reason = "windows_user_hangup") {
if (!state.conversation) return;
setPhase("stopping", "正在结束通话");
stopTimers();
const conversation = state.conversation;
let recordingResult = "等待通话";
try {
if (state.subtitleStarted) state.engine?.stopSubtitle();
await new Promise((resolve) => window.setTimeout(resolve, 800));
await flushTranscriptTurnsWithRetry(3);
if (state.pendingTurns.length > 0) {
log(`仍有 ${state.pendingTurns.length} 条最终字幕未上传,通话暂不结束并继续自动重试。`, "error");
try {
setText("transcriptStatus", "正在恢复识别");
await state.engine?.startSubtitle({ mode: SUBTITLE_MODE.ASR_ONLY });
} catch (error) {
log(`恢复实时字幕失败:${errorMessage(error)}`, "error");
}
setPhase("active", "字幕待上传");
startTimers(conversation.session.heartbeat_interval_sec || 15);
return;
}
setText("recordingStatus", "正在封装录音");
const recordingSegments = finishRecording(state.engine, conversation);
await releaseRTC();
await window.deviceSimulator.stopConversation({
conversationID: conversation.conversation_id,
reason,
lastHeartbeatSequence: state.heartbeatSequence,
nearSegments: recordingSegments.filter((segment) => segment.track === "near").length,
farSegments: recordingSegments.filter((segment) => segment.track === "far").length,
});
log("RTC 已离房,平台已提交 StopVoiceChat。", "success");
if (recordingSegments.length > 0) {
try {
setText("recordingStatus", "等待通话结束");
await waitUntilTerminal(conversation.conversation_id);
setText("recordingStatus", "正在上传");
const totalBytes = recordingSegments.reduce((sum, segment) => sum + segment.bytes.byteLength, 0);
log(`正在上传 ${recordingSegments.length} 个聊天语音分片(${formatRecordingBytes(totalBytes)})。`, "info");
const batch = await uploadRecordingWithRetry(conversation.conversation_id, recordingSegments, 3);
setText("recordingStatus", "已上传并校验");
recordingResult = "本次已上传";
log(`聊天语音已上传并通过服务器校验,批次 ${batch.id}。`, "success");
} catch (uploadError) {
setText("recordingStatus", "上传失败");
recordingResult = "本次上传失败";
log(`聊天语音上传失败:${errorMessage(uploadError)}`, "error");
}
} else if (conversation.privacy.upload_allowed) {
setText("recordingStatus", "没有可上传音频");
recordingResult = "本次没有音频";
log("本次没有收到可封装的 PCM 音频帧,未创建录音上传批次。", "info");
} else {
recordingResult = "本次未授权录音";
}
resetSession();
setText("recordingStatus", recordingResult);
setPhase("ready", "可以再次通话");
} catch (error) {
log(`结束通话失败:${errorMessage(error)}`, "error");
resetSession();
setPhase("ready", "已释放本地音频");
}
}
async function waitUntilTerminal(conversationID: string) {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const result = await window.deviceSimulator.getConversation(conversationID);
if (result.status === "COMPLETED" || result.status === "FAILED") return;
if (result.status === "EXPIRED") throw new Error("通话已过期,平台不接受录音上传");
await new Promise((resolve) => window.setTimeout(resolve, 750));
}
throw new Error("等待通话进入结束状态超时");
}
async function uploadRecordingWithRetry(conversationID: string, segments: RecordedAudioSegment[], attempts: number) {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await window.deviceSimulator.uploadConversationRecording({ conversationID, segments });
} catch (error) {
lastError = error;
if (attempt < attempts) {
log(`录音上传第 ${attempt} 次尝试失败,正在重试:${errorMessage(error)}`, "error");
await new Promise((resolve) => window.setTimeout(resolve, attempt * 1000));
}
}
}
throw lastError;
}
function formatRecordingBytes(value: number) {
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
}
async function releaseRTC() {
state.automationAudio?.pause();
state.automationAudioTrack?.stop();
if (state.automationAudioObjectURL) URL.revokeObjectURL(state.automationAudioObjectURL);
if (state.automationAudioContext) {
try { await state.automationAudioContext.close(); } catch { /* no-op */ }
}
state.automationAudio = undefined;
state.automationAudioTrack = undefined;
state.automationAudioContext = undefined;
state.automationAudioObjectURL = undefined;
const engine = state.engine;
state.engine = undefined;
if (!engine) return;
try { engine.stopSubtitle(); } catch { /* no-op */ }
try { await engine.unpublishStream(MediaType.AUDIO); } catch { /* no-op */ }
try { await engine.leaveRoom(); } catch { /* no-op */ }
try { await engine.stopAudioCapture(); } catch { /* no-op */ }
try { VERTC.destroyEngine(engine); } catch { /* no-op */ }
}
function stopTimers() {
if (state.heartbeatTimer) window.clearInterval(state.heartbeatTimer);
if (state.statusTimer) window.clearInterval(state.statusTimer);
if (state.durationTimer) window.clearInterval(state.durationTimer);
state.heartbeatTimer = undefined;
state.statusTimer = undefined;
state.durationTimer = undefined;
}
function resetSession() {
state.conversation = undefined;
state.credentialExpiresAt = undefined;
state.startedAt = undefined;
state.agentJoined = false;
state.agentSpeaking = false;
state.subtitleStarted = false;
state.transcriptSequence = 0;
state.transcriptFirstSeen.clear();
state.collectedTurnKeys.clear();
state.pendingTurns = [];
state.transcriptUpload = undefined;
state.subtitleStartWaiter = undefined;
state.nearRecorder = undefined;
state.farRecorder = undefined;
setText("rtcStatus", "未加入");
setText("rtcDetail", "等待开始");
setText("agentStatus", "未进房");
setText("agentDetail", "等待 StartVoiceChat");
setText("duration", "00:00");
updateVolume("mic", 0);
updateVolume("agent", 0);
setText("transcriptStatus", "未启动");
setText("recordingStatus", "等待通话");
$("liveTranscript").innerHTML = '<li class="transcript-empty">通话开始后,这里会显示双方的最终字幕。</li>';
}
controls.check.addEventListener("click", () => void configureAndProbe());
controls.start.addEventListener("click", () => void startConversation());
controls.stop.addEventListener("click", () => void stopConversation());
$("clearLog").addEventListener("click", () => $("eventLog").replaceChildren());
$("downloadRtcLog").addEventListener("click", () => {
VERTC.downloadLog();
log("已导出火山 RTC SDK 日志,请在 Windows“下载”目录查找最新日志文件。", "success");
});
window.addEventListener("beforeunload", () => {
stopTimers();
void releaseRTC();
});
setPhase("idle", "尚未连接");
+128
View File
@@ -0,0 +1,128 @@
export type AudioFrame = {
channels: Float32Array[];
channelCount: number;
sampleRate: number;
};
export type RecordedAudioSegment = {
track: "near" | "far";
sequence_no: number;
codec: "wav_pcm_s16le";
content_type: "audio/wav";
duration_ms: number;
bytes: Uint8Array;
};
const WAV_HEADER_SIZE = 44;
const MAX_SEGMENT_DURATION_MS = 9 * 60 * 1000;
const MAX_SEGMENT_SIZE = 64 * 1024 * 1024;
export class PCMTrackRecorder {
private sampleRate = 0;
private chunks: Float32Array[] = [];
private sampleCount = 0;
append(frame: AudioFrame) {
if (!Number.isFinite(frame.sampleRate) || frame.sampleRate < 8_000 || frame.sampleRate > 192_000) return;
const availableChannels = frame.channels.slice(0, Math.max(1, frame.channelCount)).filter((channel) => channel.length > 0);
if (availableChannels.length === 0) return;
const frameLength = Math.min(...availableChannels.map((channel) => channel.length));
if (frameLength < 1) return;
const mono = new Float32Array(frameLength);
for (let index = 0; index < frameLength; index += 1) {
let value = 0;
for (const channel of availableChannels) value += channel[index];
mono[index] = value / availableChannels.length;
}
if (this.sampleRate === 0) this.sampleRate = frame.sampleRate;
const normalized = frame.sampleRate === this.sampleRate ? mono : resampleLinear(mono, frame.sampleRate, this.sampleRate);
if (normalized.length === 0) return;
this.chunks.push(normalized);
this.sampleCount += normalized.length;
}
finish(track: "near" | "far"): RecordedAudioSegment[] {
if (this.sampleRate === 0 || this.sampleCount === 0) return [];
const durationLimit = Math.floor(this.sampleRate * MAX_SEGMENT_DURATION_MS / 1000);
const sizeLimit = Math.floor((MAX_SEGMENT_SIZE - WAV_HEADER_SIZE) / 2);
const samplesPerSegment = Math.max(1, Math.min(durationLimit, sizeLimit));
const segments: RecordedAudioSegment[] = [];
for (let offset = 0, sequence = 0; offset < this.sampleCount; offset += samplesPerSegment, sequence += 1) {
const length = Math.min(samplesPerSegment, this.sampleCount - offset);
const durationMS = Math.floor(length * 1000 / this.sampleRate);
if (durationMS < 1) continue;
segments.push({
track,
sequence_no: sequence,
codec: "wav_pcm_s16le",
content_type: "audio/wav",
duration_ms: durationMS,
bytes: encodeMonoWAV(this.chunks, offset, length, this.sampleRate),
});
}
this.chunks = [];
this.sampleCount = 0;
this.sampleRate = 0;
return segments;
}
}
function resampleLinear(input: Float32Array, sourceRate: number, targetRate: number) {
const outputLength = Math.max(1, Math.round(input.length * targetRate / sourceRate));
const output = new Float32Array(outputLength);
const ratio = sourceRate / targetRate;
for (let index = 0; index < outputLength; index += 1) {
const position = index * ratio;
const left = Math.min(input.length - 1, Math.floor(position));
const right = Math.min(input.length - 1, left + 1);
const fraction = position - left;
output[index] = input[left] + (input[right] - input[left]) * fraction;
}
return output;
}
function encodeMonoWAV(chunks: Float32Array[], startSample: number, sampleCount: number, sampleRate: number) {
const dataSize = sampleCount * 2;
const bytes = new Uint8Array(WAV_HEADER_SIZE + dataSize);
const view = new DataView(bytes.buffer);
writeASCII(bytes, 0, "RIFF");
view.setUint32(4, bytes.length - 8, true);
writeASCII(bytes, 8, "WAVE");
writeASCII(bytes, 12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
writeASCII(bytes, 36, "data");
view.setUint32(40, dataSize, true);
let skipped = 0;
let written = 0;
for (const chunk of chunks) {
if (skipped + chunk.length <= startSample) {
skipped += chunk.length;
continue;
}
const chunkStart = Math.max(0, startSample - skipped);
const available = Math.min(chunk.length - chunkStart, sampleCount - written);
for (let index = 0; index < available; index += 1) {
const sample = Math.max(-1, Math.min(1, chunk[chunkStart + index]));
view.setInt16(WAV_HEADER_SIZE + (written + index) * 2, sample < 0 ? Math.round(sample * 32768) : Math.round(sample * 32767), true);
}
written += available;
skipped += chunk.length;
if (written === sampleCount) break;
}
return bytes;
}
function writeASCII(target: Uint8Array, offset: number, value: string) {
for (let index = 0; index < value.length; index += 1) target[offset + index] = value.charCodeAt(index);
}
+83
View File
@@ -0,0 +1,83 @@
:root {
font-family: "Microsoft YaHei UI", "Segoe UI", sans-serif;
color: #112d3f;
background: #eef3f0;
font-synthesis: none;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 980px; min-height: 100vh; background: radial-gradient(circle at 88% 0%, #f5dfbd 0, transparent 30%), linear-gradient(145deg, #edf5f3 0%, #f7f4eb 100%); }
button, input, select { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: not-allowed; opacity: .45; }
.shell { width: min(1160px, calc(100vw - 48px)); margin: 0 auto; padding: 36px 0 48px; }
.hero { display: flex; align-items: flex-start; justify-content: space-between; gap: 32px; margin-bottom: 26px; }
.eyebrow { color: #c8652a; font-size: 12px; font-weight: 800; letter-spacing: .16em; margin: 0 0 9px; }
h1 { margin: 0; font-family: Georgia, "Microsoft YaHei UI", serif; font-size: 44px; letter-spacing: -.035em; }
h2 { margin: 0; font-size: 23px; }
.subtitle { max-width: 760px; color: #66808e; line-height: 1.8; margin: 12px 0 0; }
.overall { display: flex; align-items: center; min-width: 150px; justify-content: center; padding: 13px 18px; border-radius: 999px; background: rgba(255,255,255,.82); box-shadow: 0 12px 30px rgba(36,64,69,.1); color: #647a82; }
.overall.phase-ready, .overall.phase-active { color: #1a7d57; }
.overall.phase-error { color: #b63d2e; }
.status-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 16px; }
.status-card, .panel { background: rgba(255,255,255,.9); border: 1px solid rgba(23,61,69,.08); box-shadow: 0 14px 40px rgba(34,61,63,.08); }
.status-card { min-height: 118px; padding: 22px; border-radius: 20px; }
.status-card span { display: block; color: #778b93; font-size: 14px; }
.status-card strong { display: block; margin: 14px 0 6px; font-size: 21px; }
.status-card small { display: block; color: #8da0a6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.content-grid { display: grid; grid-template-columns: .95fr 1.35fr; gap: 16px; }
.panel { border-radius: 24px; padding: 28px; }
.panel-heading { display: flex; justify-content: space-between; gap: 18px; align-items: flex-start; margin-bottom: 24px; }
.safe, .phase-badge { color: #287858; background: #e8f5ed; border-radius: 999px; padding: 8px 12px; font-size: 12px; font-weight: 700; }
label { display: block; color: #617985; font-size: 13px; margin-bottom: 16px; }
input, select { width: 100%; border: 1px solid #d7e2de; border-radius: 12px; padding: 12px 13px; margin-top: 7px; color: #153244; background: #f9fbfa; outline: none; }
input:focus, select:focus { border-color: #d6783f; box-shadow: 0 0 0 3px rgba(214,120,63,.12); }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.secondary, .primary, .danger { border: 0; border-radius: 13px; padding: 13px 18px; font-weight: 800; }
.secondary { width: 100%; color: #173b4d; background: #e9f0ee; }
.primary { flex: 1; color: white; background: #d36f32; box-shadow: 0 10px 24px rgba(211,111,50,.22); }
.danger { color: #9d382c; background: #f7e6e2; }
.hint { margin: 14px 0 0; color: #8a9aa0; font-size: 12px; line-height: 1.65; }
.conversation-orb { width: 180px; height: 180px; margin: 6px auto 24px; border-radius: 50%; display: grid; place-items: center; background: radial-gradient(circle, rgba(216,117,56,.17) 0 34%, rgba(31,88,91,.08) 35% 54%, transparent 55%); transition: .3s ease; }
.orb-core { width: 108px; height: 108px; border-radius: 50%; display: grid; place-items: center; text-align: center; padding: 12px; color: white; background: linear-gradient(145deg, #173b4d, #0d2536); box-shadow: 0 18px 34px rgba(14,43,57,.25); }
.conversation-orb.phase-active { animation: breathe 2.8s ease-in-out infinite; }
.conversation-orb.speaking { background: radial-gradient(circle, rgba(76,173,126,.25) 0 34%, rgba(31,116,91,.13) 35% 62%, transparent 63%); }
@keyframes breathe { 50% { transform: scale(1.045); } }
.meters { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.meter-label { display: flex; justify-content: space-between; color: #6c828b; font-size: 12px; margin-bottom: 7px; }
.meter { height: 8px; border-radius: 999px; background: #e4ebe8; overflow: hidden; }
.meter i { display: block; width: 0; height: 100%; background: linear-gradient(90deg, #d36f32, #f0ad58); transition: width .18s linear; }
.meter.agent i { background: linear-gradient(90deg, #2f8d67, #6fc591); }
.call-actions { display: flex; gap: 10px; margin: 24px 0; }
.session-info { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 22px; padding-top: 18px; border-top: 1px solid #edf1ef; }
.session-info div { min-width: 0; }
.session-info dt { color: #8b9ba1; font-size: 11px; }
.session-info dd { margin: 4px 0 0; font-family: Consolas, monospace; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.log-panel { margin-top: 16px; padding-bottom: 18px; }
.log-panel .panel-heading { margin-bottom: 12px; }
.text-button { border: 0; color: #748991; background: transparent; }
#eventLog { list-style: none; margin: 0; padding: 0; max-height: 180px; overflow: auto; }
#eventLog li { display: grid; grid-template-columns: 82px 1fr; gap: 12px; padding: 9px 4px; border-bottom: 1px solid #edf1ef; color: #526c78; font-size: 13px; }
#eventLog time { color: #9aa8ad; font-family: Consolas, monospace; }
#eventLog li.success span { color: #247252; }
#eventLog li.error span { color: #ae3b2f; }
.transcript-panel { margin-top: 16px; }
.transcript-panel .panel-heading { margin-bottom: 14px; }
#liveTranscript { display: grid; gap: 10px; margin: 0; padding: 0; max-height: 260px; overflow: auto; list-style: none; }
.transcript-empty { color: #829198; font-size: 13px; }
.transcript-turn { display: grid; gap: 4px; max-width: 80%; padding: 11px 14px; border-radius: 15px; background: #f1f6f4; }
.transcript-turn--agent { justify-self: end; background: #edf3fb; }
.transcript-turn strong { color: #315966; font-size: 12px; }
.transcript-turn span { color: #263f49; font-size: 14px; line-height: 1.55; }
@media (max-width: 1050px) {
.status-grid { grid-template-columns: repeat(2, 1fr); }
.content-grid { grid-template-columns: 1fr; }
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true
},
"include": ["src/**/*.ts", "vite.config.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
export default defineConfig({
base: "./",
build: {
outDir: "dist",
emptyOutDir: true,
sourcemap: true,
},
});
@@ -0,0 +1,21 @@
云端语音测试设备使用说明
1. 将整个 ZIP 解压到普通文件夹,不要只从压缩包内直接运行。
2. 双击“启动云端测试设备.cmd”,不要直接双击 EXE。
3. 首次运行时允许 Windows 麦克风访问。
4. 点击“检测平台和音频设备”,检测通过后即可开始语音聊天。
云端后台:https://121.41.46.238
测试设备:10000000-0000-0000-0000-000000000511
网络需要允许 HTTPS 443、WSS 以及火山 RTC 所需的出站网络连接。
如果检测平台时提示 RTC 网络检测失败,或入房超过 15 秒:
- 关闭 VPN、系统代理和网络加速器;
- 在 Windows 防火墙/杀毒软件中允许本程序联网;
- 优先改用手机热点复测,以排除公司、校园或酒店网络限制;
- 需要放行 TCP 443 到火山 RTC Web SDK 4.69.0 使用的 *.rtc.volcvideo.com 和 *.volcvideos.com。
如果手机热点、关闭VPN/代理后仍然无法入房,请在失败后点击页面底部“导出 RTC 日志”,把 Windows“下载”目录中最新生成的 RTC 日志文件交给开发人员分析。
安全提示:本测试包包含服务器测试设备凭据,仅限受控测试使用,请勿公开上传或转发。测试完成后请删除其他电脑上的解压目录和 ZIP 文件。
+28 -3
View File
@@ -17,11 +17,17 @@
| 故事修订 | 候选故事、人工正文、原因、强ETag | 新增人工修订版本并复制来源关系,不覆盖模型版本;终态故事禁止再改 | 新的`story_revision`和`IN_REVIEW`状态 |
| 故事确认 | 当前故事、审核原因、强ETag | 必须有原话来源、具备审核权限且个性化记忆授权当前有效;确认故事及候选事实 | `CONFIRMED`故事和`PENDING`记忆 |
| 故事拒绝 | 当前故事、审核原因、强ETag | 拒绝故事及仍为候选状态的事实,不创建记忆 | `REJECTED`故事 |
| 事实候选细粒度审核 | 单条事实、修订内容、敏感级别、问题类型、原因、强ETag | 事实可独立编辑、确认、拒绝或停用;每次变化保存不可覆盖版本,不要求整篇故事一起处理 | 新的`fact_candidate_revision`与质量反馈 |
| 记忆索引 | 待索引记忆版本 | 再次校验记忆为当前版本、状态有效且授权仍有效;不满足条件时清空向量并标记`STALE` | `ACTIVE`或`STALE`索引 |
| 生产向量化 | 普通敏感级别的当前记忆正文或新通话检索语句 | API与Worker使用同一方舟Embedding模型;请求1536维float向量并校验维度和有限数值;网络、超时、408/425/429/5xx可靠重试 | `ark:<model-id>`索引或固定边界查询向量 |
| 记忆修订与停用 | 当前记忆、正文或状态、原因、强ETag | 正文变化创建新版本并立即使旧索引失效;停用后不再召回;重新启用必须重新校验授权并重新索引;`PENDING_DELETE/SUPERSEDED/DELETED`均禁止修改 | 新记忆版本或状态 |
| 记忆永久删除 | 当前记忆、删除原因、强ETag | 事务内先进入`PENDING_DELETE`并清空活动向量,使新通话立即停止召回;该状态不可通过修改接口恢复;Outbox后台把索引标记删除、清除所有记忆版本正文并记录完成审计 | `DELETED`记忆和索引 |
| 新通话记忆检索 | 服务端已确定的客户组织、长者和通话目标 | SQL同时限制租户、长者、当前授权、活动记忆、当前版本、普通敏感级别、有效期和活动索引;最多5条 | 冻结到`context_snapshot.memory_versions`的版本引用和最小背景摘要 |
| 通话中记忆工具 | 供应商持有的单通话短期签名令牌、自然语言查询 | 令牌只绑定服务端既有通话和任务;租户、家庭和长者边界从数据库反查,模型不能传入或扩大;单条240字、总计800字、最多5条;Embedding失败返回空结果并标记降级,不中断通话 | `/internal/v1/tools/memory-search`、`memory_tool_call`和实际使用追踪 |
| 可选供应商原生记忆 | 当前人工确认记忆、有效授权、普通敏感级别、受控HTTP适配器 | 数据库始终是主数据;开启后用Outbox同步,修订先清理旧引用,停用、授权撤回、单条删除和主体删除均可靠删除并对账;关闭功能后通话继续走数据库检索 | `provider`索引的`PENDING/ACTIVE/FAILED/DELETED`状态 |
| 内容质量反馈 | 故事、事实或记忆的人工确认、修改、拒绝、停用及九类标准问题 | 不复制私密正文;按生成模型和提示词版本聚合候选数、审核动作和问题类型;跨租户统一拒绝 | 维护端“内容质量”与`content_review_signal` |
| 跨通话连续上下文 | 最近已完成通话的就绪摘要、当前授权快照、交流偏好 | 只有`story_extraction`和`personalized_memory`在新通话冻结快照中同时有效时,才按同租户同长者读取最近3次摘要;读取结果每条最多240字,供应商上下文按每条140字压缩且总计不超过1000字;撤回任一授权后不再注入 | 冻结到新通话`context_snapshot.sanitized_summary`的短期连续上下文 |
| 避免主动提及 | 长者档案`conversation_preferences.avoided_topics` | 最多8项、每项50字;从下一次新通话起进入冻结快照;仅限制智能体主动开启话题,长者主动提及时温和转向,紧急安全响应不受限制 | 新通话的交流边界 |
| 授权撤回 | `personalized_memory`授权 | 在同一事务内停用该长者的活动记忆,清空活动/待处理索引向量并标记失效 | `DISABLED`记忆、`STALE`索引 |
## 3. 业务流程
@@ -51,6 +57,10 @@ flowchart TD
S --> U[创建下一次通话]
U --> V[数据库固定边界检索]
V --> W[冻结记忆版本和背景摘要]
S --> X{供应商记忆功能已验证并开启?}
X -- 是 --> Y[可靠同步外部引用]
X -- 否 --> Z[只使用数据库检索]
Y --> AA[停用/撤回/删除时外部删除对账]
```
供应商停止事件和最终转写事件可能存在先后竞争。停止事件会发布内容整理待办;如果话轮尚未到达,处理器返回可重试错误,由Outbox退避重试,避免把“暂无话轮”错误当成永久空内容。
@@ -59,6 +69,10 @@ flowchart TD
记忆索引也先在无事务状态读取当前版本和授权,事务外生成Embedding,返回后才锁定记忆与索引行重新校验;修订、停用或撤回授权发生在模型调用期间时,旧结果不会被激活。新通话创建前仅在设备绑定、语音授权和个性化记忆授权均可用时生成查询向量;事务内仍以固定租户、长者、授权、状态和敏感级别条件检索。Embedding临时不可用时,本次通话以`pgvector-degraded-v1`记录降级且不注入记忆,不阻断基本语音通话。普通顺序幂等重放和同键正文冲突会先读取已完成幂等记录,不重复调用Embedding;并发首请求仍由数据库唯一键保证只创建一个通话。
通话中工具令牌由平台在启动智能体时签发,令牌只包含通话编号、固定供应商任务编号和过期时间。工具接口先重新读取权威通话、任务、租户和长者关系,再执行与通话前召回相同的数据库边界;请求正文没有租户、家庭或长者字段。查询摘要、结果数量、延迟和降级状态进入最小审计,原始查询和记忆正文不进入普通日志。供应商原生记忆是可选增强,不取代该工具或通话前数据库召回;外部服务失败时,本地主数据和本地召回仍可继续工作。
最近通话摘要是短期连续上下文,不会自动升级为长期记忆,也不会绕过故事人工审核。平台在每次开房时重新检查并冻结当前授权,只读取同一客户组织、同一长者、已完成通话的最新就绪摘要;隐私删除或授权撤回后的摘要不会继续进入后续通话。纯端到端豆包模式把这份冻结上下文写入`S2SConfig.ProviderParams.dialog.system_role`,级联模式继续写入`LLMConfig.SystemMessages`,两种链路使用同一上下文快照。
## 4. 接口设计
| 方法与路径 | 权限 | 并发/校验 | 说明 |
@@ -70,10 +84,13 @@ flowchart TD
| `PATCH /api/v1/stories/{id}` | `story.review` | 必须`If-Match`;正文和原因必填 | 创建人工修订版本 |
| `POST /api/v1/stories/{id}/confirm` | `story.review` | 必须`If-Match`;来源与授权门禁 | 确认故事并创建记忆 |
| `POST /api/v1/stories/{id}/reject` | `story.review` | 必须`If-Match`;原因必填 | 拒绝候选 |
| `PATCH /api/v1/stories/{story_id}/facts/{fact_id}` | `story.review` | 必须`If-Match`;单条事实状态机、原因及可选标准问题类型 | 独立编辑、确认、拒绝或停用事实 |
| `GET /api/v1/subjects/{id}/memories` | `subject.read` | 过滤已删除记录;可用`q`在固定长者边界内搜索 | 已确认记忆列表 |
| `GET /api/v1/memories/{id}` | `subject.read` | 返回强ETag | 当前记忆版本和索引状态 |
| `PATCH /api/v1/memories/{id}` | `story.review` | 必须`If-Match`;正文/状态至少一项且原因必填;删除中及历史终态拒绝修改 | 修订、停用或重新启用记忆 |
| `DELETE /api/v1/memories/{id}` | `story.review` | 必须`If-Match`;删除原因必填;返回202 | 可靠受理永久删除任务 |
| `POST /internal/v1/tools/memory-search` | 单通话短期Bearer令牌 | 固定通话/任务/租户/长者边界;限流、长度和结果上限 | 通话中受控记忆检索,失败安全降级 |
| `GET /api/v1/ops/content-quality` | `ops_viewer`及以上 | 必须选择有权客户组织;不返回正文 | 按模型和提示词版本聚合审核反馈 |
统一冲突码包括`STORY_SOURCE_REQUIRED`、`PERSONALIZED_MEMORY_CONSENT_REQUIRED`和`STORY_REVIEW_STATE_CONFLICT`。跨租户或非家庭成员访问统一返回403,不借404差异泄露资源是否存在。
@@ -87,11 +104,15 @@ flowchart TD
| `story_revision` | 不可覆盖的故事版本 | 故事内修订号唯一;区分模型/人工作者;保存基础版本和原因 |
| `story_source` | 故事版本到原话的追溯关系 | 版本、话轮和文本偏移组合唯一 |
| `fact_candidate` | 可审核事实候选 | 区分长者陈述与模型推断;保存源话轮、敏感级别和状态 |
| `fact_candidate_revision` | 事实不可覆盖版本 | 每条事实版本号唯一;保留内容、状态、原因和标准问题类型 |
| `curated_memory` | 长期记忆聚合根 | 当前版本、状态、有效期、敏感级别和强版本号 |
| `memory_version` | 不可覆盖的记忆版本 | 记忆内版本号唯一;确认人、确认时间、原因、来源和内容摘要 |
| `memory_index_entry` | 记忆检索索引状态 | 记忆版本和索引类型唯一;向量维度1536;状态可失效和删除 |
| `conversation_memory_use` | 实际记忆使用追踪 | 按通话、版本和使用方式唯一,不复制正文 |
| `memory_tool_call` | 通话中工具可观测性 | 只保存查询摘要、数量、状态、延迟和引擎模式 |
| `content_review_signal` | 内容质量反馈 | 按租户和长者隔离;保存资源、动作、问题类型、模型和提示词版本,不复制正文 |
V8迁移已经在独立空数据库执行`up → down -all → up`,最终为`8|dirty=false`,九张内容与记忆表均存在。测试数据库随后已删除。
内容与记忆数据库结构目前覆盖到V41。迁移在独立临时数据库执行`up → down 1 → up`并核对最终版本与`dirty=false`,测试数据库随后删除,不对开发或生产数据库做回滚验证。
## 6. 安全、隐私与可观测性
@@ -102,17 +123,21 @@ V8迁移已经在独立空数据库执行`up → down -all → up`,最终为`8
- 只有冻结快照允许保存字幕时才落`conversation_turn`;故事整理和个性化记忆分别使用独立授权门禁。
- 故事、事实和记忆均保留租户、长者和来源边界;所有网页接口从OIDC主体重新解析家庭成员关系,不信任客户端传入租户。
- 人工修订、确认、拒绝和授权撤回记录审计事件;写操作使用强ETag防止并发覆盖。
- 通话创建已接入内部数据库召回并冻结实际使用的记忆版本;调用方不能传入自由租户或长者过滤条件。当前尚未开放通话中工具检索接口。
- 通话创建和通话中工具均使用服务端固定的租户、长者、授权和版本边界;供应商或模型不能传入自由租户、家庭或长者过滤条件。
- 供应商原生记忆默认关闭,只有完成真实账号的数据条款、隔离、同步、删除和容量验证后才配置`PROVIDER_MEMORY_MODE=managed_http`;数据库仍是唯一主数据。
## 7. 验证结果
- Go全仓测试通过,OpenAPI 0.10.0可生成TypeScript类型,React生产构建通过。
- 新增迁移已在隔离数据库完成升库与数据库组合测试;OpenAPI 0.70.0可生成TypeScript类型,React类型检查和生产构建通过。
- 本地内容策略单元测试覆盖验证码、银行卡号、高敏感医疗词和普通生活经历。
- 方舟兼容适配器轻量验证覆盖Bearer鉴权、请求路径、结构化响应校验、HTTP 429重试分类;真实PostgreSQL组合验证模型调用期间不持有通话行锁,模型返回后正常生成候选,敏感话轮不会调用外部模型。
- 方舟Embedding适配器轻量验证覆盖Bearer鉴权、`/embeddings`请求、模型/维度参数、1536维结果和错误维度拒绝;真实PostgreSQL组合验证向量生成期间不持有索引行锁,返回后可激活索引。
- 端到端通话冒烟验证最终话轮去重、两类整理结果、故事来源、人工确认前零记忆、确认后记忆生成及索引生效;下一次通话冻结1个记忆版本。
- 记忆修订生成第2版并重新索引;停用后索引立即失效且新通话召回0条;重新启用再次进入索引流程。
- 永久删除验证覆盖`PENDING_DELETE → DELETED`、删除中重新激活稳定拒绝、向量清理、数据库正文替换为删除标记和删除后新通话召回0条。
- 通话中记忆工具验证覆盖短期令牌签名、固定通话/任务边界、跨长者不可选、结果长度上限、实际使用追踪和Embedding异常降级。
- 供应商记忆适配器验证覆盖Bearer鉴权、幂等同步、旧版本删除、单条删除、主体隐私删除前对账和失败状态保留;当前只使用模拟HTTP/内存适配器,真实供应商账号仍待外部验收。
- 内容质量反馈验证覆盖九类问题、事实不可覆盖版本、按模型/提示词聚合和跨租户拒绝;维护端只展示汇总,不显示故事、事实或记忆正文。
- React故事与记忆页以页面内管理区提供正文修订、启停和永久删除;未修改正文、空原因、未确认删除均不能提交,删除中和历史终态只读,不再依赖浏览器弹窗。
- OIDC、SaaS、设备、成员和通话五组冒烟全部通过;测试结束后内容表残留为0,Outbox无待处理、处理中或死信事件。
- 当前方舟内容与Embedding协议均使用本地模拟端点验证,尚未使用业务方真实API Key核验模型效果、召回质量、额度和数据条款;真实语音供应商与真实设备也仍待验收。
+2 -2
View File
@@ -437,9 +437,9 @@
## 9. 当前实施状态与后续顺序
截至2026-08-25,本地工程已经完成阶段1—7的本地开发与验证闭环。1000活跃通话、50 RPS持续30分钟、500回调/秒、500条SSE、10000媒体分片、100并发删除、隔离数据库恢复、进程/Redis恢复、供应商故障、安全扫描、真实Chrome/Edge兼容、全仓检查、跨功能烟测和无测试残留对账均已完成。后续顺序为:
截至2026-08-26,本地工程已经完成阶段1—7的本地开发与验证闭环,并在阿里云单机环境部署标准HTTPS、OIDC、加密本机备份、主机健康签名上报和八项发布候选技术门禁。生产快照不能用演示部署模式、未验证的异地备份、未配置的外部告警或仍在运行的Mock服务冒充可上线。1000活跃通话、50 RPS持续30分钟、500回调/秒、500条SSE、10000媒体分片、100并发删除、隔离数据库恢复、进程/Redis恢复、供应商故障、安全扫描、真实Chrome/Edge兼容、全仓检查、跨功能烟测和无测试残留对账均已完成。后续顺序为:
1. 外部基础设施探针、运营预检台账、联调问题门禁、可归档技术验收快照、试点日/周证据和v3候选证据包均已完成;生产内容安全已具备本地前置拦截、托管HTTP策略、输入/输出双检和安全失败语义;远程CI供应链已具备绑定提交、运行、14项检查、双镜像摘要和三份SBOM的不可豁免门禁。`PILOT`周期、远程CI、真机固件、真实终端RTC六通矩阵、真实Agent生成/Embedding/内容安全质量、真实告警网关、云KMS/HSM、四类生产等价恢复、七类生产基础设施和Safari精简矩阵均不可由本地模拟报告放行。下一步在目标环境逐项替换证据索引中的`PENDING`。
2. 待真实火山RTC/豆包、大模型/Embedding账号、终端硬件和生产资源到位后,完成阶段8真机联调、家庭试点、预生产恢复与灰度发布。
2. OSS异地备份已完成无长期AK的IMDSv2/ECS RAM角色适配;待云账号侧绑定角色、创建私有Bucket后执行真实上传回读验收。随后待真实火山RTC/豆包、大模型/Embedding账号、终端硬件、外部告警接收器和其余生产资源到位,完成阶段8真机联调、家庭试点、预生产恢复与灰度发布。
实际完成证据与未完成项持续维护在[开发状态与门禁记录](development-status.md)中;本计划只定义执行顺序和退出条件,不用计划状态替代测试证据。
+10 -3
View File
@@ -1,7 +1,7 @@
# 开发状态与门禁记录
> 更新日期:2026-08-25
> 当前接口契约版本:0.64.0
> 更新日期:2026-08-26
> 当前接口契约版本:0.68.0
> 当前主里程碑:M8目标环境联调、试点与上线验收(外部条件待提供)
## 阶段状态
@@ -15,7 +15,7 @@
| 第五阶段:SaaS核心业务开发 | 本地业务闭环完成 | 已完成家庭成员邀请与角色协作、长者、稳定事实、五类分项授权、设备读取/配置/绑定、可靠命令、签名固件灰度/暂停/回滚和逐设备升级进度、通话查询、双轨录音状态/授权/播放下载的数据库、服务、HTTP契约与React工作台;服务端隔离、ETag、审计、纵向烟测及Chrome/Edge兼容检查均通过 | 固件生产签名设施、macOS Safari和类生产容量仍待外部环境验证 |
| 第六阶段:Agent内容与记忆闭环 | 本地开发闭环完成,生产模型协议已接入 | 最终话轮、可切换本地/火山方舟内容生成器与Embedding、事务外模型调用、摘要、开放话题、故事/事实候选、人工版本审核、授权门禁、已确认记忆、索引失效、固定边界召回及React审核页面均已实现;端到端烟测通过 | 真实方舟API Key、内容效果与召回质量验收、模型评测和真实内容安全策略待完成 |
| 第七阶段:生产加固 | 本地证据持续收敛 | 记忆可靠删除、隐私访问/导出/删除、运营配置灰度、用量费用额度、告警处置闭环、可重试HMAC告警Webhook及不污染正式告警的接收器联通测试、三类Grafana仪表盘、证书到期监测、OIDC/方舟/RTC外部基础设施探针、本地密钥轮换、生产配置拒绝启动门禁、全部本地专项容量、隔离恢复、供应商故障、安全扫描及Chrome/Edge兼容均通过;主要业务核心包已达到70%覆盖率,供应商客户端达到关键分支85%目标 | 全仓覆盖率及HTTP公共层仍低于需求目标;真实告警网关和值班组映射、生产等价容量、云密钥托管和Safari仍待完成 |
| 第八阶段:联调试点与上线 | 本地准备完成,外部联调待执行 | 外部依赖预检、联调问题门禁、候选验收快照、阻断告警定位、接收器联通测试、内容安全TLS预检和按业务时区生成的试点日/周报均已形成可执行入口;本地真实OIDC、Worker、HMAC验签接收、六项技术门禁及日/周报下载通过 | 远程CI、真实投诉证据、告警网关和值班组映射、真实供应商/真机联调、家庭试点、预生产演练和生产发布 |
| 第八阶段:联调试点与上线 | 本地准备完成,外部联调待执行 | 外部依赖预检、联调问题门禁、候选验收快照、阻断告警定位、接收器联通测试、内容安全TLS预检和按业务时区生成的试点日/周报均已形成可执行入口;本地真实OIDC、Worker、HMAC验签接收、八项技术门禁及日/周报下载通过;生产快照不能再用演示模式、未启用异地备份、未配置外部告警或仍运行Mock服务冒充可上线 | 远程CI、真实投诉证据、告警网关和值班组映射、真实供应商/真机联调、家庭试点、预生产演练和生产发布 |
## 本轮验证记录
@@ -180,6 +180,9 @@
| 媒体清单容量 | 10批×1000分片准确落库和关联;批量SQL优化后平均0.12秒、p95/最大0.16秒,API容器内存峰值15.8MiB |
| 隐私删除并发 | 100个并发受理全部成功、HTTP失败率0%、提交p95 385.15ms;11.58秒内600个子任务、100个墓碑和100个主体冻结全部对账 |
| PostgreSQL隔离恢复 | 实际`pg_dump`/`pg_restore`到随机隔离库,迁移`22|false`、66张表、190个外键和关键计数一致;本地约5.14秒,临时库/备份已清理 |
| 阿里云单机加密备份与恢复 | 目标服务器已安装每日systemd备份任务,覆盖PostgreSQL、认证Redis RDB、MinIO全部桶和暂停后的Keycloak数据目录;归档使用AES-256-CBC/PBKDF2加密、逐文件SHA-256并保留14天。首次实际归档成功,数据库从V38备份恢复到随机隔离库后`38|false|2|3|73|215`六项证据一致,归档内Redis RDB格式有效、Keycloak 3个文件一致、临时恢复库为0;同机备份尚不作为跨故障域生产恢复证据 |
| 阿里云单机健康监测 | 目标服务器已安装五分钟systemd健康检查,结构化状态覆盖部署模式、实际应用运行环境、身份服务模式、外部告警配置、HTTPS、宿主机公网监听端口、OIDC签发地址、核心容器、模拟服务、本机/异地备份状态与摘要、备份执行结果、三个运行timer、短期IP证书剩余时间和磁盘阈值。监听策略只允许非回环TCP 22/80/443,回环数据库端口不误报,额外端口会进入critical。演示正常路径28项全部healthy;生产模式要求三个业务进程实际运行在`prod`、Keycloak不使用`start-dev`并退出模拟服务。可选Webhook仅在状态变化时发送带时间戳和HMAC-SHA256的脱敏状态;真实接收器尚未配置,因此不作为值班通知送达证据 |
| 阿里云OSS异地备份适配 | 目标服务器已按官方SHA-256安装ossutil 2.3.0,新增每日OSS同步任务、加密归档及摘要上传、远端回读复算、原子状态和健康强制门禁。ECS正式路径现使用IMDSv2和`EcsRamRole`自动短期凭据,不保存长期AK;隔离配置模式验证上传/回读2个文件并得到`verified`,目标实例缺角色时退出4并记录`failed/ecs_ram_role_missing`。2026-08-26实测地域`cn-hangzhou`且角色元数据404;当前仍无RAM角色和Bucket,正式任务保持`disabled/not_enabled`且健康状态为`not_required`,不能作为真实异地备份证据 |
| 进程与Redis恢复 | API/Callbacks/Worker重启恢复;停机期间Outbox仅执行一次;Redis 14个热状态键清空后由签名设备请求重建,PostgreSQL权威计数不变 |
| 供应商故障恢复 | V23持久化按租户/供应商的可用状态;连续临时失败暂停新启动但停止任务继续尝试;演练确认暂停后新外呼0、停止尝试2次、历史接口200、CRITICAL告警出现,恢复后全部收敛;V23单版本回退/重升通过 |
| 安全专项 | Go 1.26.6容器`govulncheck`可达漏洞0、npm高危审计0、最终镜像HIGH/CRITICAL已修复漏洞0、Gitleaks无泄漏;HTTP认证/授权/注入式输入/1MiB正文/回调签名/Nonce重放/安全头/日志过滤烟测通过 |
@@ -246,6 +249,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项缺口,临时文件已清理。 |
| 托管内容安全外部预检与告警 | OpenAPI 0.64.0把`content_safety_tls`纳入外部依赖查询和候选验收快照。生产Worker仅执行DNS、TCP和受信TLS握手,不发送待分类正文;结果复用依赖可用性指标、数据库脱敏状态和运营诊断页面,连续5分钟不可用进入统一CRITICAL告警,记录缺失/失败/过期均可阻断候选技术快照但不驱逐Pod。按低强度策略执行App/探针/诊断定向测试、前端类型生成/类型检查/生产构建和Prometheus规则校验。 |
| 云端主机健康状态接入 | OpenAPI 0.65.0新增独立HMAC签名的主机健康回调、30天脱敏快照表和运营只读状态页面。五分钟周期上报覆盖公网入口、容器、备份、证书、磁盘和定时器;无签名、过期、重复检查及综合状态不一致报告被拒绝,页面超过15分钟未更新明确标记过期。该能力不替代整机、Callbacks或数据库失联时必须使用的外部探针。 |
| 云端主机健康发布门禁 | OpenAPI 0.66.0把最新主机健康快照纳入发布候选技术验收:无上报、超过15分钟、综合预警或严重异常均阻断,只有当前有效且23项检查全部正常才通过,其中包括宿主机公网监听端口策略。快照仅归档状态、时间和检查数量,不复制磁盘、证书、监听地址或备份明细值。 |
| 生产基础设施强制门禁 | OpenAPI 0.67.0把生产基础设施状态作为第八项技术门禁。`PRODUCTION`快照必须从当前主机健康报告同时证明`production`部署模式、OSS异地备份上传回读校验不过期、外部告警接收器已配置及两个Mock服务均已退出;演示机继续明确上报`demo`,因此生产快照会被如实阻断。`LAB/STAGING`标记为不适用,不影响开发联调。前端同步展示部署模式、告警和Mock状态;该门禁只证明配置与当前进程状态,不替代真实告警送达演练和生产基础设施一致性报告。 |
| 生产运行时完整性门禁 | OpenAPI 0.68.0继续收紧第八项门禁:主机健康脚本直接读取API、Callbacks和Worker容器的`APP_ENV`及Keycloak实际启动命令。生产快照新增`application_runtime_ready`和`identity_service_ready`,只有三个进程均为`prod`且身份服务不使用`start-dev`才可通过;只修改`HOST_DEPLOYMENT_PROFILE`会同时触发运行环境和身份模式严重检查。演示路径保持`local/development`且功能可用,前端用业务化中文展示两项状态。 |
| 阶段8外部执行轻量预检 | 新增`scripts/stage8-preflight.ps1`,以单一只读入口核对候选Git提交、远程仓库、Compose本地基线、外部配置变量名称和十类证据索引结构/状态;报告明确不读取密钥值,也不把环境变量或本地服务可用性当作验收证据。默认仅报告缺口,`-Strict`可供自动化阻断未就绪交接;当前已建立本地`main`候选提交,本地运行基线就绪且索引结构有效,仍缺远程仓库,样例构建摘要为占位值,十类外部证据均为`PENDING`。 |
| 候选构建身份贯通 | Compose新增统一候选构建参数,后端二进制和后端/Web镜像OCI标签共享版本、完整Git提交及UTC构建时间;远程CI从OpenAPI版本和运行编号形成候选版本,构建后重新读取两个镜像标签并与`GITHUB_SHA`和构建时间逐项比对,再把候选版本、提交、时间和双镜像摘要写入构建元数据。修复了此前Dockerfile虽支持注入但Compose/CI未传参、运行版本仍为`dev/unknown`的问题。 |
+5 -1
View File
@@ -1,7 +1,7 @@
# 设备 API 兼容性约定
> 当前设备接口主版本:`/device/v2`
> 当前接口契约:OpenAPI 0.64.0
> 当前接口契约:OpenAPI 0.70.0
> 适用对象:云端 API、终端固件、模拟终端和设备联调工具
## 1. 兼容目标
@@ -42,6 +42,10 @@ CI在拉取请求中以目标分支OpenAPI为基线执行破坏性变更检查
| OpenAPI版本 | 设备兼容变更 | 兼容结论 |
|---|---|---|
| 0.68.0 | 生产基础设施状态增加实际应用运行环境与身份服务模式证明;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 |
| 0.67.0 | 发布候选快照增加生产基础设施强制门禁及主机部署模式、异地备份、外部告警、模拟服务状态;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 |
| 0.66.0 | 发布候选技术验收快照增加主机健康当前性门禁;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 |
| 0.65.0 | 新增主机健康签名回调和运营只读查询;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 |
| 0.64.0 | 运营外部预检增加`content_safety_tls`枚举和候选快照第四类可选必需依赖;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 |
| 0.63.0 | 增加设备固件阶段事件查询及云端证据字段;设备已有命令回执仅记录同一结果的不可变事件,未增加旧终端必填字段 | v2兼容,旧终端行为不变 |
| 0.62.0 | 命令结果增加A/B写入、待重启、启动健康和回退可选字段,省略时保持原有单分区结果语义 | v2兼容,旧终端可继续省略 |
+13 -3
View File
@@ -47,13 +47,21 @@ X-Device-Content-SHA256原值
云端先核验时间窗、正文摘要和签名,再使用Redis `SET NX`登记随机数。相同设备和随机数只能使用一次;Redis或数据库不可用时设备鉴权采用fail-closed并返回可重试的503,不绕过身份验证。
本地环境使用静态HMAC密钥模拟联调。量产采用每设备独立HMAC时,密钥应写入硬件安全区并通过Secrets/KMS CSI受控挂载到云端;如硬件与PKI条件允许,仍可进一步切换每设备mTLS证书。不能把本地合成密钥烧录到真实设备。
本地环境允许把既有静态HMAC密钥一次性导入数据库以兼容测试包。量产采用每设备独立HMAC时,设备侧密钥应写入硬件安全区,云端只保存主密钥加密后的密文;如硬件与PKI条件允许,仍可进一步切换每设备mTLS证书。不能把本地合成密钥烧录到真实设备。
### 2.1 逐设备生产密钥解析
dev、stage和prod把设备密钥通过Docker/Kubernetes Secrets或云KMS CSI驱动挂载到API容器的受控只读目录,并设置`DEVICE_SECRET_DIRECTORY`。设备表不保存密钥正文,只保存`device-secret://<name>`引用;解析器只接受单层安全文件名,解析符号链接后仍必须位于配置根目录内,文件最大4KiB且有效密钥至少32字节。
设备表保存公开身份和`credential_ref`,`device_credential`表按设备保存AES-256-GCM加密后的HMAC密钥、独立随机nonce、密钥版本与状态,不保存明文。API使用数据库外独立托管的`DEVICE_CREDENTIAL_MASTER_KEY`解密;生产环境必须通过Docker/Kubernetes Secret或云KMS CSI挂载该主密钥。现有演示环境的`DEVICE_LOCAL_SECRET`只用于首次启动时导入缺失记录,导入不会覆盖已轮换的数据库凭据。
每次验签都按设备记录读取对应文件,因此不同设备使用不同密钥,原子替换挂载文件即可完成轮换,不需要重新构建镜像或重启API。文件缺失、过短、引用格式错误或越界时统一按无效凭据拒绝,响应与日志不暴露文件路径及密钥内容。生产启动门禁拒绝退回单一`DEVICE_LOCAL_SECRET`。
每次验签都按`credential_ref`读取当前活动数据库凭据并执行AEAD认证解密;记录缺失、已吊销、主密钥错误或密文被篡改时统一按无效凭据拒绝,响应与日志不暴露密钥内容。`device-credential`维护命令提供生命周期操作:
```text
device-credential provision <设备公开UUID>
device-credential rotate <设备公开UUID>
device-credential revoke <设备公开UUID>
```
`provision`和`rotate`生成32字节随机密钥,明文只在标准输出显示一次;数据库仅保存密文。轮换提交后旧密钥立即失效,必须通过受控渠道同步更新设备安全区。吊销后设备的所有签名请求立即失败。生产启动门禁拒绝默认主加密密钥。
## 3. 启动参数
@@ -228,6 +236,8 @@ docker compose --profile simulation run --rm sim-device
| `media` | 为`SIM_CONVERSATION_ID`指定且具有当前录音授权的通话生成或读取WAV PCM16文件,直传对象存储,轮询至云端校验完成,并在`local_delete_after`到达后回报本地密文已删除;可设置`SIM_MEDIA_FILE`、`SIM_MEDIA_DURATION_MS`、`SIM_MEDIA_TRACK`、`SIM_MEDIA_VERIFY_TIMEOUT`和`SIM_MEDIA_DELETE_WAIT_MAX` |
| `all` | 依次执行启动、完整通话、字幕、遥测和命令;录音因需要单独授权和可访问的对象存储公开地址而不自动包含 |
Windows真实RTC模拟设备会读取创建通话响应中的`privacy.record_near`、`record_far`和`upload_allowed`。只有相应授权有效时,它才通过RTC SDK音频帧回调分别采集长者近端和智能体远端PCM;结束通话时下混为单声道WAV PCM16,按不超过9分钟且不超过64MiB自动分片。设备提交停止请求并等待通话进入`COMPLETED`或`FAILED`后,自动创建录音上传批次、直传短期对象地址并轮询至云端校验完成。未授权轨道不会采集,上传失败最多自动重试三次并在模拟器事件流中显示稳定错误。
例如执行联合模拟闭环:
```powershell
+2 -2
View File
@@ -58,7 +58,7 @@ powershell -ExecutionPolicy Bypass -File scripts/stage8-preflight.ps1 `
- 发现的问题均进入V36联调问题台账并具备严重级别、根因、修复版本和脱敏回归证据;清单全量汇总显示未关闭HIGH/CRITICAL为0;
- 真实凭据完成一次轮换,旧凭据失效且业务不中断。
- 使用`cmd/kms-rotation-conformance`归档同一候选构建的云提供器、工作负载身份、动态读取和滚动重启两类轮换;报告必须为`PASS/REAL_KMS_HSM`,证据包内`kms_rotation_validation.coverage_complete=true`。
- 以相同候选构建重新生成验收快照并归档,六项技术门禁均通过;若候选构建变化,原快照作废并重新执行受影响场景。
- 以相同候选构建重新生成验收快照并归档,八项技术门禁均通过;若候选构建变化,原快照作废并重新执行受影响场景。
## 3. 家庭试点
@@ -97,7 +97,7 @@ powershell -ExecutionPolicy Bypass -File scripts/stage8-preflight.ps1 `
- 最近两个Chrome/Edge和macOS最近两个Safari版本通过关键流程;
- 使用`cmd/safari-conformance`归档同一候选在真实Safari `CURRENT/PREVIOUS`、两视口和六流程的24项结果;报告必须为`PASS/REAL_SAFARI`,证据包内`safari_compatibility_validation.coverage_complete=true`。
- 核心模块覆盖率和关键分支覆盖率达到需求规定门槛,当前本地未达标项必须在发布前关闭或由风险负责人书面接受。
- 已使用生产候选镜像摘要生成`PRODUCTION_RELEASE`验收快照;运行版本必须等于候选版本且包含提交号和构建时间,六项技术门禁全部通过。
- 已使用生产候选镜像摘要生成`PRODUCTION_RELEASE`验收快照;运行版本必须等于候选版本且包含提交号和构建时间,八项技术门禁全部通过,其中生产基础设施门禁必须证明生产部署模式、三个业务进程实际运行在`prod`、身份服务不是开发模式、异地备份已验证、真实外部告警已配置和Mock服务已退出。
- 使用`cmd/release-evidence`为同一候选版本、镜像摘要和源提交生成最终证据包;十类证据索引不得存在`PENDING`。出现`WAIVED`时必须附有权责任人的书面风险接受,包状态只能进入带豁免的发布评审,不能自动视为批准;八类不可豁免门禁不得使用`WAIVED`。
### 4.2 业务与合规门禁
+9 -7
View File
@@ -83,14 +83,16 @@ docker push "$Registry/migrate:$Version"
| Secret名称 | 必需key | 使用者 |
|---|---|---|
| `voice-platform-common-secrets` | `database-url`、`redis-password` | API、Callbacks、Worker、迁移Job |
| `voice-platform-api-secrets` | `rtc-app-key`、`cursor-signing-secret`、`share-token-signing-secret` | API |
| `voice-platform-callbacks-secrets` | `provider-callback-secret` | Callbacks |
| `voice-platform-api-secrets` | `device-credential-master-key`、`rtc-app-key`、`cursor-signing-secret`、`share-token-signing-secret` | API |
| `voice-platform-callbacks-secrets` | `provider-callback-secret`、`platform-health-callback-secret` | Callbacks |
| `voice-platform-worker-secrets` | `volc-access-key-id`、`volc-secret-access-key`、`voice-chat-template.json` | Worker |
| `voice-platform-model-storage-secrets` | `object-store-access-key`、`object-store-secret-key`、`ark-api-key`、`content-safety-api-key` | API、Worker;内容安全Key仅由Worker引用 |
| `voice-platform-model-storage-secrets` | `object-store-access-key`、`object-store-secret-key`、`ark-api-key`、`content-safety-api-key`、`memory-tool-signing-secret` | API、Worker;内容安全Key仅由Worker引用,记忆工具签名密钥由两者共享 |
| `voice-platform-device-secrets` | 每台设备一个key,例如`terminal-001` | API |
| `voice-platform-tls` | `tls.crt`、`tls.key` | Ingress |
游标签名密钥和分享签名密钥必须各不少于32字节且互不相同。供应商回调密钥不少于32字节。数据库连接串和Redis密码由应用通过`*_FILE`读取;应用错误日志不会输出Secret内容。
游标签名密钥、分享签名密钥和通话内记忆工具签名密钥必须各不少于32字节且互不相同。供应商回调密钥不少于32字节。数据库连接串和Redis密码由应用通过`*_FILE`读取;应用错误日志不会输出Secret内容。
如启用可选的供应商原生记忆,把`PROVIDER_MEMORY_MODE`设为`managed_http`,在环境专属ConfigMap中配置正式HTTPS `PROVIDER_MEMORY_BASE_URL`,并通过仅挂载给API和Worker的Secret文件注入`PROVIDER_MEMORY_API_KEY_FILE`。默认`disabled`不会向供应商复制任何已确认记忆。通话内工具还需要在环境专属ConfigMap中设置供应商可访问的HTTPS `MEMORY_TOOL_BASE_URL`;不要填写集群内地址,除非供应商执行端确实运行在同一受控网络。
每台量产设备在数据库中的`credential_ref`应写为`device-secret://terminal-001`,对应`voice-platform-device-secrets`中的`terminal-001`文件。名称只允许安全的单层文件名。Kubernetes Secret或CSI卷原子更新后,API会在下一次设备验签时读取新值,无需重启。
@@ -118,10 +120,10 @@ kubectl -n voice-platform get secret
| SecretProviderClass | 必须挂载的文件名 |
|---|---|
| `voice-platform-common` | `database-url`、`redis-password` |
| `voice-platform-api` | `rtc-app-key`、`cursor-signing-secret`、`share-token-signing-secret` |
| `voice-platform-callbacks` | `provider-callback-secret` |
| `voice-platform-api` | `device-credential-master-key`、`rtc-app-key`、`cursor-signing-secret`、`share-token-signing-secret` |
| `voice-platform-callbacks` | `provider-callback-secret`、`platform-health-callback-secret` |
| `voice-platform-worker` | `volc-access-key-id`、`volc-secret-access-key`、`voice-chat-template.json` |
| `voice-platform-model-storage` | `object-store-access-key`、`object-store-secret-key`、`ark-api-key`、`content-safety-api-key` |
| `voice-platform-model-storage` | `object-store-access-key`、`object-store-secret-key`、`ark-api-key`、`content-safety-api-key`、`memory-tool-signing-secret` |
| `voice-platform-devices` | 每台设备一个文件,例如`terminal-001` |
提供器对象应通过`objectAlias`或等价字段映射为上述准确文件名。Deployment挂载整个目录,不使用`subPath`,以便CSI原子轮换能够更新容器看到的符号链接。ServiceAccount已按API、Callbacks、Worker和Web拆分;现场应只给前三个需要密钥的ServiceAccount附加相应工作负载身份,并按最小权限授予密钥读取能力。
+121
View File
@@ -0,0 +1,121 @@
# 阿里云OSS异地备份适配与门禁
## 1. 目标
本能力把`platform-backup.service`生成并校验过的加密归档复制到阿里云OSS,上传后重新下载到受控临时目录并复算SHA-256,只有源归档、远端回读文件和远端摘要文件三者一致才记录`verified`。
本地备份已经加密,OSS仍应使用私有Bucket、最小权限RAM角色、服务端加密、版本控制、生命周期和必要的跨区域复制。仓库不保存AccessKey、STS令牌或ossutil配置正文。
## 2. 当前环境状态
目标ECS已安装并校验`ossutil 2.3.0`,客户端压缩包使用阿里云文档公布的Linux x86_64 SHA-256验证。2026-08-26通过IMDSv2实测实例地域为`cn-hangzhou`,RAM角色元数据返回404;当前实例没有绑定RAM角色,也没有配置OSS Bucket,因此:
- `OFFSITE_BACKUP_ENABLED=false`;
- 定时器已安装并保持活动,但任务只记录`disabled/not_enabled`;
- 健康监测记录`offsite_backup=healthy/not_required`;
- 不把当前状态声明为异地备份完成。
## 3. 上传与验证流程
```text
选择最新本机加密归档
→ 验证本机外层SHA-256
→ 上传归档到OSS私有路径
→ 上传SHA-256旁路文件
→ 从OSS重新下载归档与摘要
→ 重新计算下载文件SHA-256
→ 三方摘要一致
→ 原子写入platform-offsite-backup-v1状态
```
上传和下载均固定为单任务、单文件并发,避免在当前2核小规格服务器上争抢资源。大于ossutil阈值的文件使用断点续传,检查点保存在`/var/lib/data-terminal-cloud-offsite/checkpoints`。
状态文件位于`/var/lib/data-terminal-cloud-offsite/status.json`,只包含源文件名、OSS对象引用、SHA-256、大小、验证时间和稳定原因,不包含凭据。
## 4. 安装
```bash
cd /opt/data-terminal-cloud/current
chmod 0755 deploy/server/install-ossutil.sh \
deploy/server/install-offsite-backup.sh \
deploy/server/sync-backup-offsite.sh
deploy/server/install-offsite-backup.sh
systemctl list-timers platform-offsite-backup.timer --no-pager
```
安装器固定下载`ossutil 2.3.0`并校验官方SHA-256;已有同版本时不会重复下载。若服务器没有`unzip`,使用Python标准库解压。
## 5. OSS准备
业务方或云管理员需要在阿里云控制台完成:
1. 创建私有OSS Bucket,确定地域、版本控制、服务端加密和生命周期策略;
2. 优先给ECS绑定最小权限实例RAM角色,避免长期AccessKey落盘;
3. 授权该角色仅能向指定Bucket前缀上传、读取和查询备份对象;
4. 如需地域级灾备,为目标Bucket配置跨区域复制;
5. 推荐直接使用脚本的`ecs_ram_role`模式;该模式通过IMDSv2确认角色存在,再让ossutil自动取得短期STS凭据,不需要AccessKey或ossutil凭据文件。
当前脚本需要上传后回读,因此至少要具备指定前缀的写入和读取权限。Bucket创建、RAM授权和跨区域复制属于云资源变更,不能通过服务器root账号替代完成。
## 6. 启用
编辑`/etc/data-terminal-cloud/offsite-backup.env`:
```bash
OFFSITE_BACKUP_ENABLED=true
OFFSITE_BACKUP_REQUIRED=true
OFFSITE_OSS_BUCKET=your-private-backup-bucket
OFFSITE_OSS_PREFIX=data-terminal-cloud/121.41.46.238
OFFSITE_OSS_AUTH_MODE=ecs_ram_role
OFFSITE_OSS_REGION=cn-hangzhou
OFFSITE_OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
OFFSITE_OSS_CONFIG_FILE=/etc/data-terminal-cloud/ossutilconfig
```
`OFFSITE_OSS_CONFIG_FILE`只在`OFFSITE_OSS_AUTH_MODE=config`时读取,用于兼容受控配置文件或隔离测试;阿里云ECS正式路径使用`ecs_ram_role`。脚本强制通过IMDSv2获取元数据令牌,不回退IMDSv1。角色缺失时任务以`ecs_ram_role_missing`失败,不会继续读取本机归档或尝试匿名访问OSS。
然后执行:
```bash
systemctl start platform-offsite-backup.service
systemctl show platform-offsite-backup.service -p Result -p ExecMainStatus
python3 -m json.tool /var/lib/data-terminal-cloud-offsite/status.json
systemctl start platform-health-monitor.service
```
期望异地状态为`verified`,健康状态中的`offsite_backup`为`healthy`。启用或设为必需后,缺状态、上传失败、回读失败、摘要不一致、源归档不匹配或超过36小时都会令健康检查进入critical。
## 7. 定时与保留
本机备份默认在02:40执行并带最多45分钟随机延迟;OSS同步默认在04:10执行并带最多30分钟随机延迟。OSS侧历史保留应由Bucket生命周期和版本控制管理,脚本不会主动删除远端对象,避免错误配置扩大为批量删除。
## 8. 失败语义
| 原因 | 含义 |
|---|---|
| `ossutil_missing` | 官方客户端未安装 |
| `curl_missing` | ECS RAM角色模式缺少IMDSv2请求客户端 |
| `oss_config_missing` | 身份配置不可读 |
| `ecs_metadata_v2_unavailable` | 无法取得IMDSv2元数据令牌 |
| `ecs_ram_role_missing` | ECS未绑定实例RAM角色或角色名称无效 |
| `invalid_oss_region` / `invalid_oss_endpoint` | OSS地域或端点配置不合法 |
| `local_backup_missing` | 没有本机加密备份 |
| `local_checksum_failed` | 本机归档已损坏 |
| `archive_upload_failed` | OSS归档上传失败 |
| `checksum_upload_failed` | OSS摘要上传失败 |
| `archive_download_verification_failed` | 远端归档无法回读 |
| `checksum_download_verification_failed` | 远端摘要无法回读 |
| `remote_checksum_mismatch` | 回读内容与源归档不一致 |
失败状态会原子写入状态文件并使systemd任务失败;本机已验证备份不会被删除。
## 9. 已执行验证
- 禁用状态:任务成功记录`disabled/not_enabled`,不访问OSS;
- 隔离兼容目标:上传归档和摘要、回读并复算,状态为`verified`且远端有2个文件;
- 缺身份配置:退出4并记录`failed/oss_config_missing`;
- ECS RAM角色缺失:在目标阿里云实例通过IMDSv2实测退出4并记录`failed/ecs_ram_role_missing`;
- 兼容配置模式:隔离OSS替身完成上传、摘要上传、双回读和三方摘要一致校验;
- 健康门禁:设为必需但缺远端状态时退出1并记录`offsite_backup=critical/status_missing`。
隔离兼容目标只验证脚本控制流与摘要闭环,不是阿里云真实Bucket证据。真实门禁必须在配置RAM角色和私有Bucket后重新执行。
+12 -1
View File
@@ -10,6 +10,8 @@ V19—V20及V34已经形成内部运营配置的本地纵向闭环:运营身
## 2. 运营角色与租户边界
维护人员使用独立OIDC账号登录`/maintenance`,不得复用家庭用户身份。账号表以`account_kind`建立强制边界:`customer`只能使用`platform_role=customer`并通过家庭成员关系取得业务权限;`operator`不得具有家庭成员关系,只能使用下表的维护角色。研发环境提供`operator`和`operator-approver`两个合成账号,生产环境必须由独立员工身份源、MFA和入离职流程维护。
| 角色 | 权限 |
|---|---|
| `customer` | 无内部运营接口权限 |
@@ -18,7 +20,16 @@ V19—V20及V34已经形成内部运营配置的本地纵向闭环:运营身
| `ops_approver` | 具备操作员能力,并可审批发布、回滚、暂停和恢复投放 |
| `ops_admin` | 平台管理员能力;仍受创建人与审批人分离约束 |
运营角色保存在`user_account.platform_role`,不会信任OIDC令牌中的自报角色。每次运营请求用OIDC签名身份与`X-Tenant-Id`共同解析当前客户组织的活动账号;家庭角色不会隐式获得运营权限,跨客户组织的试验对象和设备编号均由服务端拒绝。生产环境仍需由身份系统强制MFA和受控授权流程。
运营角色保存在`user_account.platform_role`,账号类别保存在`user_account.account_kind`,不会信任OIDC令牌中的自报角色。每次运营请求用OIDC签名身份与`X-Tenant-Id`共同解析当前客户组织的活动operator账号;家庭角色不会隐式获得运营权限,跨客户组织的试验对象和设备编号均由服务端拒绝。生产环境仍需由身份系统强制MFA和受控授权流程。
### 2.1 运维设备管理
`/maintenance?area=devices`提供租户内设备资产清单,展示设备公开编号、型号、状态、密钥版本、最后上报时间及当前家庭/长者归属。`ops_viewer`和`ops_approver`只读;`ops_operator`与`ops_admin`可登记、修改、调整归属、轮换密钥和删除设备。
- 登记时服务端生成UUID设备编号、不可逆随机序列摘要和32字节随机HMAC密钥;设备、影子、可选绑定、加密凭据和审计记录在同一事务提交。
- 密钥明文只在创建或轮换响应出现一次,网页立即下载为本地凭据文件;服务端数据库只保留AES-256-GCM密文,不提供历史密钥下载接口。
- 编辑使用强ETag防止覆盖并发修改;家庭和长者必须来自同一客户组织且保持有效。
- 删除采用软删除,设备进入`revoked`、当前密钥立即吊销、当前绑定关闭;历史通话、绑定历史和审计记录不物理删除。
## 3. 配置与投放状态
+59
View File
@@ -0,0 +1,59 @@
# 云端主机健康状态接入开发说明
## 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.68.0。最新快照同时进入发布候选技术验收门禁:缺失、超过15分钟、warning或critical都会阻断,只有当前healthy快照通过。`PRODUCTION`快照还执行独立的生产基础设施门禁:主机必须明确上报`production`部署模式,API、Callbacks和Worker实际容器环境必须均为`prod`,身份服务不得使用Keycloak开发启动模式,异地备份必须已完成远端回读校验且不过期,外部告警接收器必须配置,两个模拟服务必须停止。`LAB`和`STAGING`保留该门禁字段但标记为不适用,不影响现有开发与联调。
## 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
HOST_DEPLOYMENT_PROFILE=demo
```
不得仅把`HOST_DEPLOYMENT_PROFILE`改为`production`来制造放行结果。监控脚本会读取三个业务进程的实际`APP_ENV`和Keycloak启动命令。切换前应先使用能通过服务端生产配置校验的`APP_ENV=prod`部署,停用身份服务开发模式和模拟服务,启用并验证OSS异地备份,配置真实外部告警接收器及HMAC密钥,再执行一次主机健康检查并重新生成生产快照。
启用后执行:
```bash
systemctl start platform-health-monitor.service
systemctl show platform-health-monitor.service -p Result -p ExecMainStatus
```
验收至少确认:回调返回201/200;数据库迁移版本为39且`dirty=false`;运营查询返回最新观测时间;无签名、旧时间戳、聚合状态不一致均被拒绝;下一周期再次上报后快照时间前进。
+2 -2
View File
@@ -4,7 +4,7 @@
`cmd/release-evidence`用于阶段8联调、试点和生产发布前,把同一候选版本的技术验收快照、外部验收状态、真机固件一致性报告、接口契约、数据库迁移、部署清单、关键交付文档和可用SBOM封装为一个带SHA-256清单的ZIP。`PILOT`包还必须携带同一客户组织、同一业务时区、上一完整自然周的7份日报和1份周报。它解决证据散落、候选身份不一致、真机场景缺口被模拟结果掩盖、试点周期证据缺失以及归档后无法判断文件是否被替换的问题。
证据包不是发布批准。生成器只在技术验收快照六项门禁全部通过时工作,但外部证据可以保持`PENDING`,此时包内状态固定为`EXTERNAL_VALIDATION_PENDING`。真实设备、供应商、KMS、告警接收器、Safari或业务签署不能用本地模拟结果替代。
证据包不是发布批准。生成器只在技术验收快照八项门禁全部通过时工作;其中生产快照必须额外证明生产部署模式、业务进程实际`prod`环境、身份服务非开发模式、已验证异地备份、外部告警配置和模拟服务退出。外部证据可以保持`PENDING`,此时包内状态固定为`EXTERNAL_VALIDATION_PENDING`。真实设备、供应商、KMS、告警接收器、Safari或业务签署不能用本地模拟结果替代。
## 2. 输入设计
@@ -143,4 +143,4 @@ go test ./internal/releaseevidence ./cmd/release-evidence
.\scripts\smoke-release-evidence.ps1
```
烟测使用真实OIDC生成绑定同一候选版本和API镜像摘要的`PILOT`技术验收快照、上一完整自然周的7份日报和1份周报,构造十项全部`PENDING`的外部索引,生成并解包临时ZIP,核对OpenAPI 0.64.0、v3清单、候选一致性、周期证据、摘要、待验计数和真机固件四场景缺口,随后清理临时文件、审计和幂等记录。该结果只证明打包链路可用,不证明十项外部验收、真机固件或投诉证据已经完成。
烟测使用真实OIDC生成绑定同一候选版本和API镜像摘要的`PILOT`技术验收快照、上一完整自然周的7份日报和1份周报,构造十项全部`PENDING`的外部索引,生成并解包临时ZIP,核对OpenAPI 0.68.0、v3清单、候选一致性、周期证据、摘要、待验计数和真机固件四场景缺口,随后清理临时文件、审计和幂等记录。技术验收快照还要求主机健康上报在15分钟内且没有warning/critical;生产类型另要求生产部署标记、实际应用环境、身份服务模式、异地备份、外部告警和Mock退出全部通过。该结果只证明打包链路可用,不证明十项外部验收、真机固件或投诉证据已经完成。
+11 -8
View File
@@ -33,8 +33,8 @@
| P0160 | 分片短期上传地址 | `internal/media/service.go`、对象存储适配器、V14 | 创建上传批次和返回待上传分片地址 | 清单、授权、幂等、对象引用和直传烟测 | 本地完成 |
| P0162 | 上传完成后校验 | `internal/media/processor.go`、V14/V15 | 上传批次完成和状态查询 | 校验值、对象存在、状态收敛和模拟本地删除闭环 | 本地完成 |
| P0164 | 播放和下载分别授权 | `internal/media/access.go`、`recording_access_grant` | 录音访问授权、撤销及分片访问接口 | 所有者不隐式获得权限、播放/下载能力分离用例 | 本地完成 |
| P0167 | 故事和事实候选核对 | `internal/saas/content_service.go`、`internal/extraction`、V8 | 故事列表、详情、修订、确认和拒绝接口及审核页面 | 来源必需、ETag、角色、版本留痕和真实数据库组合用例 | 本地完成 |
| P0169 | 已确认记忆管理 | `internal/saas/content_service.go`、`internal/memorylifecycle`、V8 | 记忆查询、页面内修订/启停/删除和状态详情 | 授权门禁、索引失效、删除中不可恢复、可靠删除、召回排除及真实登录页面交互 | 本地完成 |
| P0167 | 故事和事实候选核对 | `internal/saas/content_service.go`、`internal/extraction`、V8/V41 | 故事列表、详情、修订、确认和拒绝;单条事实编辑/确认/拒绝/停用及审核页面 | 来源必需、ETag、角色、故事与事实不可覆盖版本、九类问题标签和真实数据库组合用例 | 本地完成 |
| P0169 | 已确认记忆管理 | `internal/saas/content_service.go`、`internal/memorylifecycle`、`internal/providermemory`、V8/V41 | 记忆查询、页面内修订/启停/删除、本地与外部同步状态详情 | 授权门禁、索引失效、删除中不可恢复、本地/外部可靠删除、召回排除及数据库组合测试 | 本地完成;真实供应商记忆账号待验 |
| P0174 | 数据访问、导出、更正、撤回和删除请求 | `internal/privacy`、`internal/dataexport`、V9/V10 | 隐私请求、导出任务和进度接口 | 多子系统删除、导出加密引用、失败/重试和清理烟测 | 本地完成,法务口径待业务确认 |
| P0176 | 状态实时推送且不推敏感正文 | `internal/webevents`、V16、React 2秒合并刷新 | `GET /api/v1/events`及React SSE客户端 | 重连游标、资源隔离、状态事件字段、连续8事件请求上界和运行烟测 | 本地完成 |
| P0183 | 通话用量、费用和额度 | `internal/billing`、V21 | 通话用量、价格、额度和费用汇总接口 | 回调幂等计量、额度门禁、价格版本冻结和烟测 | 本地完成,真实账单对账待验 |
@@ -53,7 +53,9 @@
| P0212 | 每通话选择一种语音模式 | `internal/opsconfig`、`internal/orchestration`、组合优先及模式冻结 | Agent组合/旧路由投放和通话创建 | 三模板选择、缺失配置拒绝、组合路由解析和数据库到供应商传播用例 | 本地完成,三种真实模式待验 |
| P0214 | 管理人设、音色、模型和路由配置 | `internal/opsconfig`、`internal/orchestration`、V19/V20/V33 | 五类配置、评测、资源引用、灰度、回滚及供应商启动交接 | Agent组合UUID、同租户、组件类型、已发布状态、运行时冻结和组件参数传递 | 本地完成,真实资源兼容性待验 |
| P0217 | 最小必要背景资料 | `internal/orchestration/repository.go`、版本化背景快照 | 通话创建内部资料准备 | 白名单字段、非白名单排除、无效类型失败和真实数据库用例 | 本地完成 |
| P0219 | 自有数据库检索记忆 | `internal/retrieval`、pgvector、V8 | 创建通话前固定边界召回 | 当前授权、版本、敏感级别、跨长者隔离和非法limit用例 | 本地完成,真实Embedding质量待验 |
| P0219 | 自有数据库检索记忆和通话中工具 | `internal/retrieval`、`internal/memorytool`、pgvector、V8/V41 | 创建通话前固定边界召回及`POST /internal/v1/tools/memory-search` | 当前授权、版本、敏感级别、跨长者隔离、单通话任务令牌、长度/数量上限、使用追踪和失败降级 | 本地完成,真实Embedding与供应商工具调用待验 |
| P0221 | 验证并可选使用供应商原生记忆 | `internal/providermemory`、`memory_index_entry(index_type=provider)`、可靠Outbox和V41状态展示 | `PROVIDER_MEMORY_MODE=managed_http`可选适配器;家庭端显示外部同步状态 | 幂等同步、修订清旧、停用/撤回/单条删除/主体删除对账、失败状态和本地降级组合测试 | 本地适配完成,真实供应商账号的数据条款、隔离、容量和删除待验后方可开启 |
| P0223 | 通话中按需调用自有记忆检索工具 | `internal/memorytool`、单通话HMAC令牌、实际使用追踪、V41 | `POST /internal/v1/tools/memory-search`,供应商启动模板注入固定工具地址和短期令牌 | 模型不可传租户/家庭/长者、固定通话任务、限流、240/800字边界、最多5条、超时降级且不中断通话 | 本地完成,真实豆包工具调用待验 |
| P0226 | 可靠接收供应商通知 | `internal/voiceprovider/signature.go`、Callback处理和Outbox | `POST /callbacks/v1/vertc/voice-chat` | 先验签、重放、同键冲突、乱序和快速确认用例 | 本地完成 |
| P0228 | 最终字幕和对话轮次 | `internal/orchestration/transcript_batch.go`、V8 | 供应商最终字幕和设备`turns:batch` | 轮次业务键、整批回滚、授权留存和重复补交用例 | 本地完成 |
| P0230 | 摘要和开放话题 | `internal/extraction/generator.go`、`processor.go` | 通话完成后的可靠内容整理任务 | 事务外模型调用、话轮指纹、重复版本和敏感拦截用例 | 本地完成,真实模型质量待验 |
@@ -62,10 +64,11 @@
| P0238 | 长者友好表达 | 版本化PERSONA配置、背景节奏字段和固定评测集 | 运营配置、供应商启动模板、`cmd/agent-eval` | 本地规则及模拟评测通过 | 本地完成,真实听筒体验待验 |
| P0240 | 身份、人设和边界一致 | `internal/opsconfig`、服务端授权/上下文边界、提示注入拦截 | 版本化PERSONA/AGENT_BUNDLE、冻结快照和供应商系统消息 | 配置校验、租户/长者边界、发布人设传递和敏感提示用例 | 本地完成,真实模型红队待验 |
| P0245 | 摘要忠于原始对话 | `internal/extraction`、来源话轮和内容指纹 | 摘要/开放话题接口 | 仅长者最终话轮、来源保存、敏感拦截和虚构门槛 | 本地完成,真实内容评分待验 |
| P0247 | 候选可核对可修改 | V8故事、故事修订和事实候选 | 故事详情、修订、确认和拒绝接口 | 来源跳转、版本、ETag和终态重复操作拒绝 | 本地完成 |
| P0249 | 已确认记忆准确使用 | `internal/retrieval`、Embedding适配器、记忆索引生命周期 | 记忆检索和通话背景快照 | 当前版本/授权/敏感级别过滤、跨长者隔离和停用删除排除 | 本地完成,真实召回质量待验 |
| P0252 | 固定评测集和质量指标 | `internal/agenteval`、`internal/opsconfig`、`cmd/agent-eval-collect`、`cmd/agent-eval`、`cmd/agent-eval-register`、V33及版本化JSON夹具 | 权限内通话/话轮采集、脱敏观测、评测报告及运营配置`/evaluations`标准证据幂等登记 | 十类覆盖、唯一通话、必需评分、机器指标推导、报告防篡改、服务端标准证据校验、通用证据不得放行Agent组合、201/200重放和模拟10/10报告 | 本地完成,真实账号评测待验 |
| P0247 | 候选可核对可修改 | V8/V41故事、故事修订、事实候选与事实修订 | 故事详情、修订、确认和拒绝;单条事实细粒度审核接口 | 来源跳转、不可覆盖版本、ETag、九类问题标签和终态重复操作拒绝 | 本地完成 |
| P0249 | 已确认记忆准确使用 | `internal/retrieval`、`internal/memorytool`、`internal/providermemory`、Embedding适配器和记忆索引生命周期 | 通话前背景快照、通话中工具、可选供应商同步 | 当前版本/授权/敏感级别过滤、跨长者隔离、停用/撤回/删除清理、失败回退本地数据库 | 本地完成,真实召回质量与供应商数据条款待验 |
| P0252 | 固定评测集和质量反馈指标 | `internal/agenteval`、`internal/opsconfig`、`content_review_signal`、维护端内容质量页、`cmd/agent-eval-*`、V33/V41及版本化JSON夹具 | 权限内通话/话轮采集、脱敏观测、评测报告、标准证据登记及`GET /api/v1/ops/content-quality` | 十类评测覆盖、九类人工问题反馈、按模型/提示词聚合、跨租户拒绝、报告防篡改及模拟10/10报告 | 本地完成,真实账号评测待验 |
| P0254 | Agent配置发布、灰度和回滚 | `internal/opsconfig`、`internal/orchestration`、运营配置工作台和投放台账 | 双人审批、租户/长者/设备组灰度、自定义版本族、不可变回滚、组合优先选择 | 组件引用复核、评测/资源门禁、配置类型选择、范围与激活时间优先级、并发上限、新通话冻结及供应商启动交接 | 本地完成,真实灰度待验 |
| P0256 | 用户核对到模型优化的反馈闭环 | `fact_candidate_revision`、`content_review_signal`、`internal/opsconfig/content_quality.go`和独立维护端 | 故事、单条事实和记忆审核收集九类问题;`GET /api/v1/ops/content-quality`按模型/提示词聚合 | 不复制私密正文、角色与租户隔离、确认/修改/拒绝/停用计数、九类问题统计和前端生产构建 | 本地完成;训练数据导出、供应商模型再训练和真实A/B效果属于后续受控流程 |
## 4. 关键异常和三方交接场景
@@ -87,9 +90,9 @@
| 来源 | 要求 | 当前处理 |
|---|---|---|
| P0414 | 首期需求、验收用例、缺陷和风险形成对应关系 | 本文完成首期功能到代码、接口、数据和验证的基线;V30问题清单由V36补充严重级别、根因、修复版本、回归证据及未关闭HIGH/CRITICAL发布阻断汇总;OpenAPI 0.50.0—0.55.0可按候选构建导出六项技术门禁及脱敏阻断告警定位快照,绑定候选版本/构建摘要后按业务时区归档日/周运行证据。`cmd/release-evidence`现使用v3清单,把同一候选的快照、接口、迁移、部署、SBOM和十类外部状态形成逐文件摘要;远程CI供应链、真机固件、真实终端RTC、真实Agent模型、真实告警网关、云KMS/HSM轮换、生产等价恢复、生产基础设施和Safari兼容分别要求结构化报告,并把14项CI检查/镜像/SBOM、设备固件场景、语音方式与恢复场景、生成/Embedding/内容安全质量、路由演练、轮换模式、恢复类型、基础设施控制项及Safari矩阵覆盖与缺口写入清单。OpenAPI 0.64.0、V38及九个离线校验器把云端状态与远程构建、外部设备、RTC/语音供应商、模型提供器、网关、密钥提供器、目标环境和浏览器证据绑定;`content_safety_tls`把策略服务网络/证书状态纳入指标、告警、运营页面和候选快照。缺检查、缺制品、缺场景、缺演练、缺质量类别、缺控制项、`PENDING`和本地模拟证据均不会被误报为外部验收完成,整体发布仍必须基于真实联调、灰度和审批记录确认。 |
| P0414 | 首期需求、验收用例、缺陷和风险形成对应关系 | 本文完成首期功能到代码、接口、数据和验证的基线;V30问题清单由V36补充严重级别、根因、修复版本、回归证据及未关闭HIGH/CRITICAL发布阻断汇总;OpenAPI 0.50.0—0.70.0可按候选构建导出八项技术门禁及脱敏阻断告警定位快照,绑定候选版本/构建摘要后按业务时区归档日/周运行证据。第七项门禁要求主机健康快照在15分钟内且全部检查正常;第八项在`PRODUCTION`强制要求生产部署模式、三个业务进程实际`APP_ENV=prod`、身份服务非开发模式、已验证异地备份、外部告警配置及Mock服务退出,在`LAB/STAGING`明确标记不适用。`cmd/release-evidence`现使用v3清单,把同一候选的快照、接口、迁移、部署、SBOM和十类外部状态形成逐文件摘要;远程CI供应链、真机固件、真实终端RTC、真实Agent模型、真实告警网关、云KMS/HSM轮换、生产等价恢复、生产基础设施和Safari兼容分别要求结构化报告,并把14项CI检查/镜像/SBOM、设备固件场景、语音方式与恢复场景、生成/Embedding/内容安全质量、路由演练、轮换模式、恢复类型、基础设施控制项及Safari矩阵覆盖与缺口写入清单。V38及九个离线校验器把云端状态与远程构建、外部设备、RTC/语音供应商、模型提供器、网关、密钥提供器、目标环境和浏览器证据绑定;`content_safety_tls`把策略服务网络/证书状态纳入指标、告警、运营页面和候选快照。缺检查、缺制品、缺场景、缺演练、缺质量类别、缺控制项、`PENDING`和本地模拟证据均不会被误报为外部验收完成,整体发布仍必须基于真实联调、灰度和审批记录确认。 |
| P0445 | 需求编号关联代码、测试、接口或配置变更 | 本文逐项建立关联;后续功能变更必须同步更新本矩阵和`development-status.md`。 |
当前仍不能在本地宣告完成的部分集中为:真实火山RTC与豆包三模式联调、真实方舟内容与Embedding质量、真实听筒的长者交流体验、量产设备/KMS注入、生产对象存储与备份、真机异常恢复、真实账单和生产级内容安全。它们属于阶段8外部验收,不应通过增加本地低价值测试伪造完成证据。
当前仍不能宣告完成的部分集中为:真实火山RTC与豆包三模式联调、真实方舟内容与Embedding质量、真实听筒的长者交流体验、量产设备/KMS注入、跨故障域生产对象存储与备份、真机异常恢复、真实账单和生产级内容安全。阿里云单机环境已经形成加密全组件归档、数据库隔离恢复和Redis RDB校验,但同机归档不能替代托管PITR、异地副本和生产恢复演练。这些剩余项属于阶段8外部验收,不应通过增加本地低价值测试伪造完成证据。
下一批本地开发应优先从矩阵中出现“进行中”的条目选择;若没有本地功能缺口,则只继续完善能够直接降低阶段8联调风险的模拟器、诊断和交付工具,不以覆盖率数字替代业务功能。
+101
View File
@@ -0,0 +1,101 @@
# 单机云端备份与隔离恢复手册
## 1. 适用范围
本手册适用于`deploy/server/compose.override.yaml`部署的单机公网演示环境。目标是在不暴露数据库、Redis、对象存储和身份服务端口的前提下,形成可自动执行、可校验、可追溯的加密备份,并证明PostgreSQL备份能够恢复到独立数据库。
该能力不是跨可用区生产灾备。单机磁盘故障仍可能同时损失在线数据和本机备份,因此加密归档和口令必须另行复制到不同故障域。
## 2. 备份范围
| 组件 | 备份方式 | 当前恢复验证 |
|---|---|---|
| PostgreSQL | `pg_dump`自定义格式,不包含所有者和ACL | 实际恢复到随机隔离数据库,并核对迁移、客户组织、账号、表和外键计数 |
| Redis | 认证执行`SAVE`后复制RDB | `redis-check-rdb`验证归档内RDB格式,不覆盖在线Redis |
| MinIO | 使用容器内`mc mirror`复制全部桶和对象 | 逐文件SHA-256和对象文件数量一致性检查 |
| Keycloak | 暂停容器后复制`/opt/keycloak/data`,随后立即恢复 | 逐文件SHA-256和文件数量检查;当前开发模式H2数据不作为生产OIDC方案 |
备份归档使用`AES-256-CBC + PBKDF2`加密,迭代次数为200000。每个归档旁边保存加密文件自身的SHA-256;归档内部另有逐文件`SHA256SUMS`和不含凭据的`manifest.properties`。
## 3. 自动执行流程
```text
获取独占锁
→ PostgreSQL逻辑转储和证据快照
→ Redis认证SAVE及RDB校验
→ MinIO全桶对象镜像
→ 短暂暂停Keycloak并复制一致性数据目录
→ 逐文件SHA-256
→ 压缩、加密、生成归档摘要
→ PostgreSQL随机隔离库实际恢复
→ Redis RDB及文件清单复核
→ 删除临时数据库和临时明文
→ 按保留期清理旧加密归档
```
任何步骤失败都会令systemd服务失败;未完成的明文暂存目录由退出陷阱删除。备份使用`flock`避免重叠执行,Keycloak无论成功或异常退出都会尝试解除暂停。
## 4. 安装与运行
```bash
cd /opt/data-terminal-cloud/current
chmod 0755 deploy/server/backup.sh deploy/server/verify-backup.sh deploy/server/install-backup.sh
deploy/server/install-backup.sh
systemctl start platform-backup.service
systemctl show platform-backup.service -p Result -p ExecMainStatus
systemctl list-timers platform-backup.timer --no-pager
```
默认每天02:40执行,并附加最长45分钟随机延迟,避免固定时刻资源竞争;默认保留14天。定时任务设置`Persistent=true`,服务器在计划时刻关机时会在恢复后补跑。
可通过环境变量覆盖:
| 变量 | 默认值 | 说明 |
|---|---|---|
| `PLATFORM_ROOT` | `/opt/data-terminal-cloud/current` | 当前发布目录 |
| `BACKUP_ROOT` | `/var/backups/data-terminal-cloud` | 加密归档目录,必须为安全的绝对路径 |
| `BACKUP_PASSPHRASE_FILE` | `/etc/data-terminal-cloud/backup-passphrase` | 仅root可读的独立随机口令 |
| `BACKUP_RETENTION_DAYS` | `14` | 本机备份保留天数 |
| `VERIFY_AFTER_BACKUP` | `false`;systemd中为`true` | 备份后是否立即执行隔离恢复校验 |
## 5. 手工校验与故障定位
校验最新备份:
```bash
/opt/data-terminal-cloud/current/deploy/server/verify-backup.sh
```
校验指定备份:
```bash
/opt/data-terminal-cloud/current/deploy/server/verify-backup.sh \
/var/backups/data-terminal-cloud/platform-YYYYmmddTHHMMSSZ.tar.gz.enc
```
查看任务结果:
```bash
systemctl show platform-backup.service -p Result -p ExecMainStatus -p ActiveState
journalctl -u platform-backup.service -n 100 --no-pager
find /var/backups/data-terminal-cloud -maxdepth 1 -type f -name 'platform-*.enc' -ls
```
一次性服务成功结束后显示`inactive (dead)`是正常状态,应结合`Result=success`和`ExecMainStatus=0`判断。校验脚本不会停止在线业务、覆盖在线数据库或载入Redis快照;随机恢复数据库在成功和失败路径都会清理。
## 6. 安全与恢复边界
- 备份口令不写入仓库、归档、日志或Compose环境文件;默认权限为`0600 root:root`。
- 备份目录默认权限为`0700`,归档和摘要为`0600`。
- 明文转储只存在于同一受控暂存目录,归档加密后立即删除。
- 当前归档留在同一云服务器只提供误操作恢复能力,不提供主机或地域故障保护。
- 生产环境应使用托管PostgreSQL持续备份/PITR、版本化对象存储、外部OIDC、跨故障域副本、KMS托管密钥和受控恢复演练。
- 不能仅凭定时任务成功声称满足某个RPO/RTO;必须结合备份频率、异地复制、真实数据量和受控恢复耗时单独验收。
## 7. 上线前检查
1. 把加密归档复制到不同故障域,并独立验证复制后的SHA-256。
2. 把口令保存到云KMS、密码库或离线托管介质,不与归档放在同一位置。
3. 配置备份失败和长时间未成功告警。
4. 在预生产环境执行PostgreSQL、Redis、对象存储和OIDC的完整恢复演练。
5. 记录实际备份大小、持续时间、恢复时间和业务抽查结果,再确定RPO/RTO。
+146
View File
@@ -0,0 +1,146 @@
# 单机云端健康监测与告警手册
## 1. 目标与边界
`deploy/server/health-monitor.sh`为单机阿里云环境提供五分钟一次的轻量健康检查。它用于发现“部署模式与运行服务不一致、公网入口不可用、宿主机出现未授权公网监听端口、身份签发地址漂移、容器退出、备份过期或损坏、外部告警未配置、证书接近到期、定时任务停止、磁盘空间不足”等直接影响可用性或暴露面的事件。
该脚本不替代Prometheus、Grafana、云监控或真实值班系统。默认只写结构化状态文件和systemd日志;配置外部接收器后,状态发生变化时才发送一次HMAC签名Webhook,避免每五分钟重复通知。
## 2. 检查项
| 检查 | 正常条件 | 失败状态 |
|---|---|---|
| 部署模式 | `HOST_DEPLOYMENT_PROFILE`只能为`demo`或`production` | 非法值直接拒绝执行 |
| 应用运行环境 | API、Callbacks和Worker实际容器`APP_ENV`必须一致;`demo`要求`local`,`production`要求`prod` | `missing`、`mixed`或与部署模式不符的实际值 |
| 身份服务模式 | `demo`可使用开发模式;`production`不得使用Keycloak `start-dev` | `missing`或`development` |
| 外部告警接收器 | `demo`未配置时明确记录`not_configured`;`production`必须配置且HMAC密钥不少于32字节 | `not_configured`、`secret_missing`或`secret_invalid` |
| 公网地址 | `PUBLIC_ORIGIN`为HTTPS | `invalid_https_origin` |
| HTTPS入口 | `/api/v1/version`在10秒内返回成功 | `request_failed` |
| 公网监听端口 | 宿主机非回环TCP监听端口只能属于`22,80,443`;回环地址上的数据库或监控端口不计入公网暴露 | `unexpected_ports=...`、`probe_unavailable`或`invalid_probe_result` |
| OIDC | Discovery中的`issuer`与公网地址完全一致 | `issuer_mismatch`或`request_failed` |
| 容器 | PostgreSQL、Redis、MinIO、Keycloak、API、Callbacks、Worker、Web和Gateway均为运行态;有健康检查的容器必须为healthy | `missing`、容器状态或健康状态 |
| 模拟服务 | `demo`要求模拟语音和模拟告警服务都运行;`production`要求两者都不运行 | `incomplete_demo_stack`或`unexpected_running` |
| 备份 | 最新加密归档不超过36小时且外层SHA-256有效;`production`还要求异地备份启用并完成最新归档的回读验证 | `missing`、`age_seconds=...`、`checksum_failed`或异地备份状态错误 |
| 备份任务 | 最近一次`platform-backup.service`结果成功且退出码为0 | `result=.../exit=...` |
| 定时任务 | 备份和证书续期timer均为enabled/active | 当前启用/活动状态 |
| 证书 | 本地活动证书剩余时间不少于36小时 | `missing`或`remaining_seconds=...` |
| 磁盘 | 低于80%为健康,80%—89%为warning,90%及以上为critical | `used_percent=...` |
状态文件写入`/var/lib/data-terminal-cloud-monitor/status.json`,格式为`platform-server-health-v1`。每个检查只有稳定名称、状态和脱敏值,不包含数据库密码、Webhook地址、证书正文、业务数据或令牌。配置平台健康上报后,同一状态会每五分钟通过独立HMAC密钥发送到Callbacks并在运营联调诊断页面展示,详细协议见[云端主机健康状态接入开发说明](platform-health-reporting.md)。
## 3. 状态模型
```text
全部检查健康 → healthy,退出0
没有critical但存在warning → warning,退出0
任一检查critical → critical,退出1
```
systemd oneshot任务成功结束后显示`inactive (dead)`属于正常行为,应使用以下字段判断:
```bash
systemctl show platform-health-monitor.service -p Result -p ExecMainStatus -p ActiveState
```
期望结果为`Result=success`、`ExecMainStatus=0`。状态为critical时服务会显示失败,timer仍会在下一个周期继续执行。
## 4. 安装与运行
```bash
cd /opt/data-terminal-cloud/current
chmod 0755 deploy/server/health-monitor.sh deploy/server/install-monitoring.sh
deploy/server/install-monitoring.sh
systemctl start platform-health-monitor.service
systemctl list-timers platform-health-monitor.timer --no-pager
python3 -m json.tool /var/lib/data-terminal-cloud-monitor/status.json
```
定时任务在开机两分钟后首次执行,之后每五分钟执行并附加最多30秒随机延迟。`Persistent=true`确保错过计划时间后补跑。
## 5. 可选Webhook通知
安装脚本创建:
- `/etc/data-terminal-cloud/monitor.env`:`0600 root:root`;
- `/etc/data-terminal-cloud/monitor-webhook-secret`:独立随机HMAC密钥,`0600 root:root`。
编辑配置:
```bash
ALERT_WEBHOOK_URL=https://receiver.example.com/platform-health
ALERT_WEBHOOK_SECRET_FILE=/etc/data-terminal-cloud/monitor-webhook-secret
```
平台内部状态上报与外部告警分开配置;内部上报每个周期发送,外部告警仍只在综合状态变化时发送:
```dotenv
PLATFORM_HEALTH_REPORT_URL=https://121.41.46.238/callbacks/v1/platform-health
PLATFORM_HEALTH_REPORT_SECRET_FILE=/etc/data-terminal-cloud/platform-health-callback-secret
```
只有总体状态发生变化时才发送JSON状态文件。请求包含:
- `Content-Type: application/json`;
- `X-Platform-Timestamp`:Unix秒;
- `X-Platform-Signature: sha256=<hex>`;
- 签名正文:`HMAC-SHA256(secret, timestamp + "." + raw_body)`。
接收端应限制时间偏差、按时间戳和签名去重并使用恒定时间比较。Webhook地址和密钥不会进入状态文件或日志。正式配置前应先在接收端完成验签联通测试。
## 6. 参数
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `HOST_DEPLOYMENT_PROFILE` | `demo` | 主机部署模式;生产切换必须与异地备份、告警接收器和模拟服务退出同时完成 |
| `BACKUP_MAX_AGE_SECONDS` | `129600` | 最新备份最大年龄,36小时 |
| `CERTIFICATE_MIN_SECONDS` | `129600` | 证书最小剩余时间,36小时 |
| `DISK_WARNING_PERCENT` | `80` | 磁盘警告阈值 |
| `DISK_CRITICAL_PERCENT` | `90` | 磁盘严重阈值 |
| `EXPECTED_PUBLIC_TCP_PORTS` | `22,80,443` | 宿主机允许绑定非回环地址的TCP端口,逗号分隔 |
| `MONITOR_STATE_DIR` | `/var/lib/data-terminal-cloud-monitor` | 原子状态文件目录 |
| `MONITOR_CONFIG_FILE` | `/etc/data-terminal-cloud/monitor.env` | 可选通知配置 |
| `TLS_CERTIFICATE_FILE` | 根据`PUBLIC_ORIGIN`主机名推导 | 当前活动证书文件 |
阈值只能使用非负整数,磁盘警告阈值必须小于严重阈值。端口必须位于1—65535;修改允许端口前必须同步复核阿里云安全组,不能用扩大允许列表掩盖错误的数据库或缓存端口映射。普通调整无需重建镜像,但应记录变更理由并重新执行一次手工检查。
## 7. 验收与排障
正常路径:
```bash
/opt/data-terminal-cloud/current/deploy/server/health-monitor.sh
```
不改变线上状态的备份过期失败分支:
```bash
test_dir=$(mktemp -d /var/tmp/platform-monitor-test-XXXXXX)
MONITOR_STATE_DIR="$test_dir" MONITOR_CONFIG_FILE=/dev/null \
BACKUP_MAX_AGE_SECONDS=0 \
/opt/data-terminal-cloud/current/deploy/server/health-monitor.sh
```
期望退出1,状态为critical,唯一人工注入的严重检查为`backup_freshness`。测试目录验证后应精确删除。
监听策略解析器的正常、回环忽略和异常端口分支可独立验证:
```bash
python3 -B deploy/server/test_public_listener_policy.py
```
常用排障命令:
```bash
journalctl -u platform-health-monitor.service -n 100 --no-pager
systemctl list-timers platform-health-monitor.timer platform-backup.timer certbot-ip-renew.timer --no-pager
docker compose -f compose.yaml -f deploy/server/compose.override.yaml --env-file deploy/server/.env ps -a
```
生产分支可在不改变线上容器的情况下验证。临时配置`HOST_DEPLOYMENT_PROFILE=production`运行检查时,当前演示栈必须因`application_environment=local`、`identity_service_mode=development`、外部告警、异地备份及模拟服务同时进入critical;测试状态目录应与正式目录隔离。该检查读取容器实际环境和Keycloak启动命令,不能只改主机配置文件伪造生产结果。
## 8. 下一步生产化
1. 配置真实告警网关、主值班组和升级组,并完成HMAC验签联通测试。
2. 将状态接入云监控或Prometheus长期存储,建立可用率和告警送达趋势。
3. 启用现有OSS异地备份任务,确认上传、回读和SHA-256复算成功,并把最近验证时间纳入健康状态。
4. 使用外部OIDC、托管数据库/Redis/对象存储后,把资源级高可用、PITR和容量指标纳入同一状态模型。
+4
View File
@@ -73,12 +73,16 @@
| `GET /api/v1/ops/quota-policies` | 运营查看及以上 | 查询活动与历史额度 | 客户组织隔离 |
| `POST /api/v1/ops/quota-policies` | 运营审批及以上 | 创建并激活策略版本 | `Idempotency-Key`、事务审计 |
| `GET /api/v1/ops/cost-summary` | 运营查看及以上 | 按时间范围聚合费用 | 最大366天、不返回任务编号 |
| `GET /api/v1/ops/usage-overview` | 运营查看及以上 | 查看整体通话时长与可用费用 | 客户组织授权;可按家庭、设备筛选 |
| `GET /api/v1/households/{id}/usage-overview` | 活动家庭成员 | 查看本家庭通话时长与可用费用 | 服务端反查家庭关系;只能筛选本家庭设备 |
| `GET /api/v1/conversations/{id}/usage` | 活动家庭成员 | 查看单次通话用量和费用 | 服务端反查家庭关系 |
| `POST /callbacks/v1/vertc/voice-chat` | 签名供应商 | 接收`voice_chat.usage` | HMAC、时间窗、事件幂等 |
| `POST /device/v2/conversations` | 签名设备 | 创建通话并冻结预算 | 创建前统一额度检查 |
所有Web响应使用统一`request_id + data`信封。运营和家庭汇总只返回标准化分类、指标、数量、单位、费用和币种,不返回供应商任务、供应商事件、内部价格版本或费率编号。
通话用量概览以平台已经持久化的`conversation.started_at / ended_at`计算时长,因此不依赖供应商账单接口。费用只有在最终用量已经核价并生成`conversation_cost_line`时才标记为可用;前端默认展示通话时长,在当前筛选范围存在费用行时开放费用视图。家庭端的家庭编号取自URL,但服务端仍以登录身份的活动家庭成员关系重新授权;传入其他家庭或其他家庭设备都会被拒绝。运营端先验证所选客户组织的运营账号,再将家庭、设备筛选限制在该客户组织内。
## 4. 数据库设计
| 表 | 作用 | 关键约束 |
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
# 设备相关外部接口表
范围仅包含设备终端以 HMAC 身份直接调用的 `/device/v2/*` 外部接口;不包含家庭/运营 Web API、内部工具和供应商回调。基于 `openapi/openapi.yaml`(OpenAPI 3.1,版本 0.70.0)生成,并与 `internal/httpapi/router.go` 的实际路由逐项核对。当前共 **16** 个已注册且纳入 OpenAPI 契约的接口。
说明:`*` 表示必填字段;`body` 表示请求体;响应栏只展开成功响应。全部接口都要求设备 HMAC 请求头:`X-Device-Id`、`X-Device-Timestamp`、`X-Device-Nonce`、`X-Device-Content-SHA256`、`X-Device-Signature`,表内不重复。通用错误响应为 `ErrorResponse`。
## 设备端 v2
| 接口名字 | 方法与路径 | 接口描述 | 输入参数 | 输出参数 |
|---|---|---|---|---|
| `getDeviceBootstrap` | `GET /device/v2/bootstrap` | 返回可信设备状态、服务器时间、绑定和最低安全版本 | query `firmware_version*`: string<br>query `board_rev`: string<br>query `rtc_sdk_version`: string<br>query `codec`: string<br>query `sample_rate`: integer<br>query `device_time*`: string(date-time)<br>query `network`: string | 200 DeviceBootstrapResponse{data*:DeviceBootstrapData{clock_skew_ms*:integer(int64), device*:DeviceBootstrap, server_time*:string(date-time)}, request_id*:string} — 返回可信设备状态、服务器时间、绑定和最低安全版本 |
| `pullDeviceCommands` | `GET /device/v2/commands` | 返回未过期的待执行命令并记录投递次数 | query `limit`: integer | 200 DeviceCommandDeliveryListResponse{data*:object{items*:array<DeviceCommandDelivery>}, request_id*:string} — 返回未过期的待执行命令并记录投递次数 |
| `acknowledgeDeviceCommand` | `POST /device/v2/commands/{id}/ack` | 回执已保存 | path `id*`: string(uuid)<br>body* `application/json` DeviceCommandAckRequest{result_summary*:object, status*:string[ACKNOWLEDGED/SUCCEEDED/FAILED]} | 200 DeviceCommandDeliveryResponse{data*:DeviceCommandDelivery{created_at*:string(date-time), delivery_attempts*:integer, expires_at*:string(date-time), id*:string(uuid), payload*:object, status*:DeviceCommandStatus, type*:string[apply_config/reboot/diagnostics/firmware_update]}, request_id*:string} — 回执已保存 |
| `getDeviceConfig` | `GET /device/v2/config` | 返回设备期望配置及当前绑定 | header `If-None-Match`: string | 200 DeviceConfigResponse{data*:DeviceConfiguration{binding*:DeviceBinding, desired_config*:object, device_id*:string(uuid), reported_revision:integer(int64), revision*:integer(int64)}, request_id*:string};headers{ETag:string} — 返回设备期望配置及当前绑定<br>304 无响应体 — 配置未变化 |
| `createDeviceConversation` | `POST /device/v2/conversations` | 分配房间和短期入房凭证;此步骤不启动语音智能体。 | header `Idempotency-Key*`: string<br>body* `application/json` CreateConversationRequest{capabilities*:DeviceCapabilities{audio_tracks*:array<string[near/far]>, rtc*:boolean, rtc_credential_renewal:boolean, upload_resume*:boolean}, device_time*:string(date-time), monotonic_ms:integer(int64), network*:DeviceNetwork{rssi:integer, type*:string}, requested_goal*:string} | 201 CreateConversationResponse{data*:CreatedConversation{conversation_id*:string(uuid), privacy*:ConversationPrivacy, rtc*:RTCGrant, session*:ConversationSession, status*:string}, request_id*:string};headers{Idempotency-Replayed:string[true]} — 房间资料已分配;重复请求返回相同通话和相同确定性短期Token |
| `getDeviceConversationState` | `GET /device/v2/conversations/{id}` | 设备确认入房后读取云端权威通话状态和下一步动作;供应商永久拒绝启动或异步启动失败时返回稳定failure_code及leave_room,响应不包含供应商原始错误。 | path `id*`: string(uuid) | 200 DeviceConversationStateResponse{data*:DeviceConversationState{action*:string[wait/continue/leave_room], conversation_id*:string(uuid), failure_code:string, status*:ConversationStatus}, request_id*:string} — 返回当前设备所属通话的权威状态和设备动作 |
| `activateDeviceConversation` | `POST /device/v2/conversations/{id}/activate` | 设备确认已进入指定RTC房间后,投递固定供应商语音任务。 | path `id*`: string(uuid)<br>header `Idempotency-Key*`: string<br>body* `application/json` ActivateConversationRequest{config_revision*:integer(int64), connection_reference:string, joined_at*:string(date-time), room_id*:string, rtc_join_result*:string, rtc_user_id*:string} | 202 ActivateConversationResponse{data*:ActivatedConversation{accepted_at*:string(date-time), conversation_id*:string(uuid), status*:string[STARTING_AGENT/ACTIVE]}, request_id*:string} — 入房确认已接受;语音智能体正在启动或已经启动 |
| `heartbeatDeviceConversation` | `POST /device/v2/conversations/{id}/heartbeat` | 按严格递增序号刷新设备在线状态和通话短期租约;重复或乱序心跳不会延长租约。 | path `id*`: string(uuid)<br>body* `application/json` HeartbeatConversationRequest{command_results*:array<object>, conversation_status*:string[PREPARING/ROOM_ISSUED/STARTING_AGENT/ACTIVE/STOPPING/COMPLETED/FAILED/EXPIRED], device_time*:string(date-time), firmware_version*:string, last_error:string, monotonic_ms*:integer(int64), network*:DeviceNetwork{rssi:integer, type*:string}, recording_queue*:RecordingQueue{failed_segments*:integer, last_failure_at:string(date-time), last_failure_code:string, pending_segments*:integer}, reported_config_revision*:integer(int64), rtc*:RTCHeartbeat{downlink*:boolean, joined*:boolean, uplink*:boolean}, rtc_credential_expires_at:string(date-time), sequence_no*:integer(int64)} | 200 HeartbeatConversationResponse{data*:HeartbeatResult{actions*:array<HeartbeatAction>, commands*:array<object>, desired_config_revision*:integer(int64), lease_expires_at:string(date-time)}, request_id*:string} — 心跳已接受,或已按序号判定为安全重放/乱序请求 |
| `stopDeviceConversation` | `POST /device/v2/conversations/{id}/stop` | 记录设备挂断并可靠停止固定供应商任务;重复执行不产生第二个外部副作用。 | path `id*`: string(uuid)<br>header `Idempotency-Key*`: string<br>body* `application/json` StopConversationRequest{device_time*:string(date-time), last_heartbeat_sequence*:integer(int64), reason*:string, recording*:StopRecording{far_segments*:integer, near_segments*:integer}, rtc_left*:boolean} | 202 StopConversationResponse{data*:StoppedConversation{accepted_at*:string(date-time), conversation_id*:string(uuid), status*:string[STOPPING/COMPLETED/FAILED/EXPIRED]}, request_id*:string};headers{Idempotency-Replayed:string[true]} — 停止请求已接受,或通话已经处于停止中/终态 |
| `submitDeviceConversationTurns` | `POST /device/v2/conversations/{id}/turns:batch` | 当供应商最终转写缺失时由当前通话所属终端批量补传终稿;平台按turn_key和sequence_no与已有供应商终稿原子去重合并,任一内容冲突会拒绝整批请求。 | path `id*`: string(uuid)<br>header `Idempotency-Key*`: string<br>body* `application/json` DeviceTranscriptBatchRequest{turns*:array<DeviceFinalTurn>} | 200 DeviceTranscriptBatchResponse{data*:DeviceTranscriptBatchResult{accepted_count*:integer, conversation_id*:string(uuid), duplicate_count*:integer}, request_id*:string};headers{Idempotency-Replayed:string[true]} — 整批终稿已合并;完全相同的已有终稿计入duplicate_count而不重复保存 |
| `prepareMediaUploadBatch` | `POST /device/v2/conversations/{id}/upload-batches` | 设备提交near/far录音分片清单;业务API仅返回单对象短期PUT地址,不接收录音字节。 | path `id*`: string(uuid)<br>header `Idempotency-Key*`: string<br>body* `application/json` PrepareMediaUploadBatchRequest{segments*:array<MediaSegmentDeclaration>} | 201 MediaUploadBatchResponse{data*:MediaUploadBatch{conversation_id*:string(uuid), expected_total_size*:integer(int64), expires_at*:string(date-time), id*:string(uuid), last_error_code:string, rejected_count*:integer, segment_count*:integer, segments*:array<MediaSegment>, status*:string[PENDING/UPLOADING/VERIFYING/COMPLETED/FAILED/EXPIRED], verification_attempts*:integer, verified_count*:integer}, request_id*:string} — 返回尚未验证分片的10至20分钟单对象上传地址 |
| `getDeviceFirmwareDownload` | `GET /device/v2/firmware-releases/{id}/download` | 仅向当前灰度目标签发 5 分钟有效的私有下载地址 | path `id*`: string(uuid) | 200 application/json `{download_url*:string(uri), download_until*:string(date-time)}`;header `Cache-Control: no-store` |
| `acknowledgeLocalMediaDelete` | `POST /device/v2/media-segments/{id}/local-delete-ack` | 设备在平台给出的local_delete_after之后删除本地密文并回报结果;该操作不删除云端已验证对象,DELETED重复回报幂等。 | path `id*`: string(uuid)<br>body* `application/json` LocalMediaDeleteReceiptRequest{error_code:string, status*:string[DELETED/FAILED]} | 200 MediaSegmentResponse{data*:MediaSegment{codec*:string, content_type*:string, duration_ms*:integer, expected_digest*:string, expected_size*:integer(int64), id*:string(uuid), last_error_code:string, local_delete_after:string(date-time), local_delete_error_code:string, local_delete_reported_at:string(date-time), local_delete_status:string[DELETED/FAILED], sequence_no*:integer, status*:string[DECLARED/AUTHORIZED/UPLOADED/VERIFYING/VERIFIED/REJECTED/DELETED], track*:string[near/far], upload_until:string(date-time), upload_url:string(uri), verified_at:string(date-time)}, request_id*:string} — 已保存本地删除回执 |
| `submitDeviceTelemetry` | `POST /device/v2/telemetry:batch` | 当前签名设备批量上报状态、有限错误码和命令执行结果;平台以设备内event_key和sequence_no原子去重,更新设备影子、命令状态和与当前设备通话关联的异常回收信号。 | header `Idempotency-Key*`: string<br>body* `application/json` DeviceTelemetryBatchRequest{events*:array<DeviceTelemetryEvent>} | 200 DeviceTelemetryBatchResponse{data*:DeviceTelemetryBatchResult{accepted_count*:integer, actions*:array<DeviceTelemetryAction>, desired_config_revision*:integer(int64), duplicate_count*:integer}, request_id*:string};headers{Idempotency-Replayed:string[true]} — 整批遥测已接收;完全相同的已有事件计入duplicate_count |
| `getDeviceMediaUploadBatch` | `GET /device/v2/upload-batches/{id}` | 返回分片校验结果;只有VERIFIED分片才包含local_delete_after | path `id*`: string(uuid) | 200 MediaUploadBatchResponse{data*:MediaUploadBatch{conversation_id*:string(uuid), expected_total_size*:integer(int64), expires_at*:string(date-time), id*:string(uuid), last_error_code:string, rejected_count*:integer, segment_count*:integer, segments*:array<MediaSegment>, status*:string[PENDING/UPLOADING/VERIFYING/COMPLETED/FAILED/EXPIRED], verification_attempts*:integer, verified_count*:integer}, request_id*:string} — 返回分片校验结果;只有VERIFIED分片才包含local_delete_after |
| `completeMediaUploadBatch` | `POST /device/v2/upload-batches/{id}/complete` | 设备声明本批上传完成,平台可靠投递后台对象校验任务;重复提交不会创建重复任务。 | path `id*`: string(uuid) | 202 MediaUploadBatchResponse{data*:MediaUploadBatch{conversation_id*:string(uuid), expected_total_size*:integer(int64), expires_at*:string(date-time), id*:string(uuid), last_error_code:string, rejected_count*:integer, segment_count*:integer, segments*:array<MediaSegment>, status*:string[PENDING/UPLOADING/VERIFYING/COMPLETED/FAILED/EXPIRED], verification_attempts*:integer, verified_count*:integer}, request_id*:string} — 批次已进入VERIFYING或已经完成 |
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -413,7 +413,7 @@ func (s *Service) resolveActor(ctx context.Context, principal identity.Principal
return actor{}, ErrInvalid
}
var a actor
e := s.pool.QueryRow(ctx, `SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,ua.platform_role FROM user_account ua JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE t.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active'`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&a.tenantID, &a.tenantPublicID, &a.userID, &a.userPublicID, &a.role)
e := s.pool.QueryRow(ctx, `SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,ua.platform_role FROM user_account ua JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE t.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active' AND ua.account_kind='operator'`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&a.tenantID, &a.tenantPublicID, &a.userID, &a.userPublicID, &a.role)
if errors.Is(e, pgx.ErrNoRows) {
return actor{}, ErrAccessDenied
}
+2 -2
View File
@@ -32,8 +32,8 @@ func TestAlertRoutingSilenceAndIncidentLifecycle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
approver := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000102"}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
approver := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000105"}
outsider := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000103"}
tenantID := "10000000-0000-0000-0000-000000000010"
routeKeys := []string{"alerts-integration-route1-" + uuid.NewString(), "alerts-integration-route2-" + uuid.NewString()}
+66 -11
View File
@@ -30,14 +30,17 @@ import (
"data-terminal-cloud/internal/media"
"data-terminal-cloud/internal/memoryindex"
"data-terminal-cloud/internal/memorylifecycle"
"data-terminal-cloud/internal/memorytool"
"data-terminal-cloud/internal/objectstore"
"data-terminal-cloud/internal/observability"
"data-terminal-cloud/internal/opsaccess"
"data-terminal-cloud/internal/opsconfig"
"data-terminal-cloud/internal/opsdevice"
"data-terminal-cloud/internal/orchestration"
"data-terminal-cloud/internal/outbox"
"data-terminal-cloud/internal/pagination"
"data-terminal-cloud/internal/privacy"
"data-terminal-cloud/internal/providermemory"
"data-terminal-cloud/internal/ratelimit"
"data-terminal-cloud/internal/rtc"
"data-terminal-cloud/internal/saas"
@@ -75,11 +78,18 @@ func Run(ctx context.Context, serviceName string) error {
defer func() { _ = redisClient.Close() }()
var exportStore *objectstore.Store
var memoryEmbedding embeddingprovider.Provider
var providerMemoryClient providermemory.Client
if serviceName == "api" || serviceName == "worker" {
memoryEmbedding, err = newMemoryEmbeddingProvider(cfg)
if err != nil {
return fmt.Errorf("create memory embedding provider: %w", err)
}
if cfg.ProviderMemoryMode == "managed_http" {
providerMemoryClient, err = providermemory.NewHTTPClient(cfg.ProviderMemoryBaseURL, cfg.ProviderMemoryAPIKey, cfg.ProviderMemoryTimeout)
if err != nil {
return fmt.Errorf("create provider memory client: %w", err)
}
}
exportStore, err = objectstore.New(cfg.ObjectStoreEndpoint, cfg.ObjectStorePublic, cfg.ObjectStoreAccessKey, cfg.ObjectStoreSecretKey, cfg.ObjectStoreBucket, cfg.ObjectStoreRegion, cfg.ObjectStoreSecure, cfg.ObjectStorePublicSecure)
if err != nil {
return fmt.Errorf("create private export object store: %w", err)
@@ -124,13 +134,31 @@ func Run(ctx context.Context, serviceName string) error {
var alertService *alerts.Service
var diagnosticService *diagnostics.Service
var opsAccessService *opsaccess.Service
var opsDeviceService *opsdevice.Service
var memoryToolService *memorytool.Service
var memoryToolSigner *memorytool.TokenSigner
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" {
memoryToolSigner, err = memorytool.NewTokenSigner([]byte(cfg.MemoryToolSigningSecret))
if err != nil {
return fmt.Errorf("create memory tool signer: %w", err)
}
memoryToolService, err = memorytool.NewService(databasePool, memoryEmbedding, audit.NewRepository())
if err != nil {
return fmt.Errorf("create memory tool service: %w", err)
}
webEventService = webevents.NewService(databasePool)
cursorCodec, cursorErr := pagination.NewCodec([]byte(cfg.CursorSigningSecret))
if cursorErr != nil {
return fmt.Errorf("create pagination cursor codec: %w", cursorErr)
}
saasService = saas.NewService(databasePool, audit.NewRepository(), redisClient, cursorCodec)
saasService.ConfigureProviderMemory(cfg.ProviderMemoryMode == "managed_http")
opsConfigService, err = opsconfig.NewService(databasePool, audit.NewRepository())
if err != nil {
return fmt.Errorf("create operations configuration service: %w", err)
@@ -143,10 +171,6 @@ func Run(ctx context.Context, serviceName string) error {
if err != nil {
return fmt.Errorf("create alert operations service: %w", err)
}
diagnosticService, err = diagnostics.NewService(databasePool, idempotency.NewExecutor(databasePool), audit.NewRepository())
if err != nil {
return fmt.Errorf("create diagnostic service: %w", err)
}
opsAccessService, err = opsaccess.NewService(databasePool, idempotency.NewExecutor(databasePool), audit.NewRepository(), exportStore, 5*time.Minute)
if err != nil {
return fmt.Errorf("create temporary sensitive access service: %w", err)
@@ -164,14 +188,21 @@ func Run(ctx context.Context, serviceName string) error {
}
}
deviceIdentityRepository := deviceauth.NewRepository(databasePool)
var deviceSecretResolver deviceauth.SecretResolver
if cfg.DeviceSecretDirectory != "" {
deviceSecretResolver, err = deviceauth.NewDirectorySecretResolver(cfg.DeviceSecretDirectory)
deviceSecretResolver, err := deviceauth.NewDatabaseSecretResolver(databasePool, cfg.DeviceCredentialMasterKey)
if err != nil {
return fmt.Errorf("create per-device secret resolver: %w", err)
return fmt.Errorf("create database device secret resolver: %w", err)
}
} else {
deviceSecretResolver = deviceauth.StaticSecretResolver{"local-dev://simulated-terminal-1": []byte(cfg.DeviceLocalSecret)}
if len(cfg.DeviceLocalSecret) >= 32 {
if err := deviceSecretResolver.ImportMissingLegacyCredentials(ctx, map[string][]byte{
"local-dev://simulated-terminal-1": []byte(cfg.DeviceLocalSecret),
"local-dev://unbound-terminal-2": []byte(cfg.DeviceLocalSecret),
}); err != nil {
return fmt.Errorf("import legacy device credentials: %w", err)
}
}
opsDeviceService, err = opsdevice.NewService(databasePool, deviceSecretResolver, audit.NewRepository())
if err != nil {
return fmt.Errorf("create operations device service: %w", err)
}
deviceVerifier, err = deviceauth.NewVerifier(
deviceIdentityRepository,
@@ -251,6 +282,7 @@ func Run(ctx context.Context, serviceName string) error {
"tenant": {RatePerSecond: cfg.RateLimitTenantRPS, Burst: cfg.RateLimitTenantBurst},
"device": {RatePerSecond: cfg.RateLimitDeviceRPS, Burst: cfg.RateLimitDeviceBurst},
"provider_start": {RatePerSecond: cfg.ProviderStartRPS, Burst: cfg.ProviderStartBurst},
"memory_tool": {RatePerSecond: cfg.MemoryToolRateRPS, Burst: cfg.MemoryToolRateBurst},
})
if err != nil {
return fmt.Errorf("create rate limiter: %w", err)
@@ -305,6 +337,15 @@ func Run(ctx context.Context, serviceName string) error {
if processorErr = startProcessor.ConfigureStartProtection(limiter, cfg.ProviderMaxActiveTasks); processorErr != nil {
return fmt.Errorf("configure provider start protection: %w", processorErr)
}
if cfg.MemoryToolBaseURL != "" {
workerMemoryToolSigner, signerErr := memorytool.NewTokenSigner([]byte(cfg.MemoryToolSigningSecret))
if signerErr != nil {
return fmt.Errorf("create worker memory tool signer: %w", signerErr)
}
if processorErr = startProcessor.ConfigureMemoryTool(workerMemoryToolSigner, cfg.MemoryToolBaseURL, cfg.MemoryToolTokenTTL); processorErr != nil {
return fmt.Errorf("configure provider memory tool: %w", processorErr)
}
}
outboxWorker.Register("conversation.agent_start_requested", startProcessor.Handle)
outboxWorker.Register("conversation.agent_start_reconcile_requested", startProcessor.Handle)
stopProcessor, processorErr := orchestration.NewStopProcessor(databasePool, providerClient, providerHealth)
@@ -339,6 +380,14 @@ func Run(ctx context.Context, serviceName string) error {
return fmt.Errorf("create memory index processor: %w", processorErr)
}
outboxWorker.Register("memory.index_requested", memoryIndexProcessor.Handle)
if providerMemoryClient != nil {
providerMemoryProcessor, providerMemoryErr := providermemory.NewProcessor(databasePool, providerMemoryClient)
if providerMemoryErr != nil {
return fmt.Errorf("create provider memory processor: %w", providerMemoryErr)
}
outboxWorker.Register("memory.provider_sync_requested", providerMemoryProcessor.HandleSync)
outboxWorker.Register("memory.provider_delete_requested", providerMemoryProcessor.HandleDelete)
}
memoryDeleteProcessor, processorErr := memorylifecycle.NewDeleteProcessor(databasePool)
if processorErr != nil {
return fmt.Errorf("create memory delete processor: %w", processorErr)
@@ -349,7 +398,7 @@ func Run(ctx context.Context, serviceName string) error {
return fmt.Errorf("create privacy access processor: %w", processorErr)
}
outboxWorker.Register("privacy.access_requested", privacyAccessProcessor.Handle)
privacyDeleteProcessor, processorErr := privacy.NewDeleteProcessor(databasePool, redisClient, exportStore)
privacyDeleteProcessor, processorErr := privacy.NewDeleteProcessor(databasePool, redisClient, exportStore, providerMemoryClient)
if processorErr != nil {
return fmt.Errorf("create privacy delete processor: %w", processorErr)
}
@@ -426,6 +475,7 @@ func Run(ctx context.Context, serviceName string) error {
AlertService: alertService,
DiagnosticService: diagnosticService,
OpsAccessService: opsAccessService,
OpsDeviceService: opsDeviceService,
ConversationCreator: conversationCreator,
ConversationActivator: conversationCreator,
DeviceConversationStateReader: conversationCreator,
@@ -435,6 +485,11 @@ func Run(ctx context.Context, serviceName string) error {
ProviderCallbackSecret: []byte(cfg.ProviderCallbackSecret),
ProviderCallbackSkew: cfg.ProviderCallbackSkew,
ProviderCallbackProcessor: providerEventProcessor,
PlatformHealthCallbackSecret: []byte(cfg.PlatformHealthSecret),
PlatformHealthCallbackSkew: cfg.PlatformHealthSkew,
PlatformHealthProcessor: diagnosticService,
MemoryToolService: memoryToolService,
MemoryToolSigner: memoryToolSigner,
})
server := &http.Server{
Addr: cfg.HTTPAddr,
+1
View File
@@ -18,6 +18,7 @@ func TestCallbacksApplicationStartsAndShutsDown(t *testing.T) {
t.Setenv("REDIS_ADDR", "localhost:56379")
t.Setenv("SHUTDOWN_TIMEOUT", "1s")
t.Setenv("DEPENDENCY_TIMEOUT", "1s")
t.Setenv("PLATFORM_HEALTH_CALLBACK_SECRET", strings.Repeat("h", 32))
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
if err := Run(ctx, "callbacks"); err != nil {
+347
View File
@@ -0,0 +1,347 @@
package billing
import (
"context"
"errors"
"sort"
"time"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
const maxUsageOverviewRange = 366 * 24 * time.Hour
type UsageOverviewScope struct {
TenantID string `json:"tenant_id"`
TenantName string `json:"tenant_name"`
HouseholdID string `json:"household_id,omitempty"`
HouseholdName string `json:"household_name,omitempty"`
DeviceID string `json:"device_id,omitempty"`
DeviceName string `json:"device_name,omitempty"`
}
type UsageOverviewDay struct {
Date string `json:"date"`
DurationMS int64 `json:"duration_ms"`
ConversationCount int `json:"conversation_count"`
CostMicros int64 `json:"cost_micros"`
}
type UsageOverviewDevice struct {
ID string `json:"id"`
Name string `json:"name"`
HouseholdID string `json:"household_id"`
HouseholdName string `json:"household_name"`
DurationMS int64 `json:"duration_ms"`
ConversationCount int `json:"conversation_count"`
CostMicros int64 `json:"cost_micros"`
}
type UsageOverviewConversation struct {
ID string `json:"id"`
HouseholdID string `json:"household_id"`
HouseholdName string `json:"household_name"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
SubjectName string `json:"subject_name"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at,omitempty"`
DurationMS int64 `json:"duration_ms"`
CostMicros int64 `json:"cost_micros"`
}
type UsageOverview struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
Timezone string `json:"timezone"`
Scope UsageOverviewScope `json:"scope"`
TotalDurationMS int64 `json:"total_duration_ms"`
TodayDurationMS int64 `json:"today_duration_ms"`
ConversationCount int `json:"conversation_count"`
AverageDurationMS int64 `json:"average_duration_ms"`
ActiveDays int `json:"active_days"`
CurrentStreakDays int `json:"current_streak_days"`
LongestStreakDays int `json:"longest_streak_days"`
CostMicros int64 `json:"cost_micros"`
Currency string `json:"currency"`
CostAvailable bool `json:"cost_available"`
Daily []UsageOverviewDay `json:"daily"`
Devices []UsageOverviewDevice `json:"devices"`
RecentConversations []UsageOverviewConversation `json:"recent_conversations"`
}
type usageOverviewTarget struct {
tenantID, tenantPublicID, tenantName string
householdID, householdPublicID, householdName string
deviceID, devicePublicID, deviceName string
}
type usageOverviewRow struct {
conversationID, householdID, householdName string
deviceID, deviceName, subjectName, status string
startedAt time.Time
endedAt *time.Time
costMicros int64
currency string
hasCost bool
}
func (s *Service) FamilyUsageOverview(ctx context.Context, principal identity.Principal, householdPublicID, devicePublicID, timezone string, from, to time.Time) (UsageOverview, error) {
if _, err := uuid.Parse(householdPublicID); err != nil {
return UsageOverview{}, ErrInvalid
}
target := usageOverviewTarget{}
var role identity.Role
err := s.pool.QueryRow(ctx, `
SELECT t.id::text,t.public_id::text,t.name,h.id::text,h.public_id::text,h.name,hm.role
FROM user_account ua
JOIN tenant t ON t.id=ua.tenant_id AND t.status='active'
JOIN household_member hm ON hm.user_id=ua.id AND hm.tenant_id=ua.tenant_id AND hm.status='active'
JOIN household h ON h.id=hm.household_id AND h.tenant_id=hm.tenant_id AND h.status<>'deleted'
WHERE h.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active'`,
householdPublicID, principal.Issuer, principal.Subject).Scan(
&target.tenantID, &target.tenantPublicID, &target.tenantName,
&target.householdID, &target.householdPublicID, &target.householdName, &role)
if errors.Is(err, pgx.ErrNoRows) || (err == nil && !identity.Can(role, identity.CapabilityHouseholdRead)) {
return UsageOverview{}, ErrAccessDenied
}
if err != nil {
return UsageOverview{}, err
}
if err = s.resolveUsageOverviewDevice(ctx, &target, devicePublicID, true); err != nil {
return UsageOverview{}, err
}
return s.buildUsageOverview(ctx, target, timezone, from, to)
}
func (s *Service) OpsUsageOverview(ctx context.Context, principal identity.Principal, tenantPublicID, householdPublicID, devicePublicID, timezone string, from, to time.Time) (UsageOverview, error) {
currentActor, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_viewer", "ops_operator", "ops_approver", "ops_admin")
if err != nil {
return UsageOverview{}, err
}
target := usageOverviewTarget{tenantID: currentActor.tenantID, tenantPublicID: currentActor.tenantPublicID}
if err = s.pool.QueryRow(ctx, `SELECT name FROM tenant WHERE id=$1::uuid`, target.tenantID).Scan(&target.tenantName); err != nil {
return UsageOverview{}, err
}
if householdPublicID != "" {
if _, parseErr := uuid.Parse(householdPublicID); parseErr != nil {
return UsageOverview{}, ErrInvalid
}
err = s.pool.QueryRow(ctx, `SELECT id::text,public_id::text,name FROM household WHERE tenant_id=$1::uuid AND public_id=$2::uuid AND status<>'deleted'`, target.tenantID, householdPublicID).Scan(&target.householdID, &target.householdPublicID, &target.householdName)
if errors.Is(err, pgx.ErrNoRows) {
return UsageOverview{}, ErrNotFound
}
if err != nil {
return UsageOverview{}, err
}
}
if err = s.resolveUsageOverviewDevice(ctx, &target, devicePublicID, false); err != nil {
return UsageOverview{}, err
}
return s.buildUsageOverview(ctx, target, timezone, from, to)
}
func (s *Service) resolveUsageOverviewDevice(ctx context.Context, target *usageOverviewTarget, devicePublicID string, family bool) error {
if devicePublicID == "" {
return nil
}
if _, err := uuid.Parse(devicePublicID); err != nil {
return ErrInvalid
}
query := `SELECT d.id::text,d.public_id::text,d.model FROM device d WHERE d.tenant_id=$1::uuid AND d.public_id=$2::uuid`
args := []any{target.tenantID, devicePublicID}
if target.householdID != "" {
query += ` AND (
EXISTS(SELECT 1 FROM device_binding b WHERE b.tenant_id=d.tenant_id AND b.device_id=d.id AND b.household_id=$3::uuid AND b.unbound_at IS NULL)
OR EXISTS(SELECT 1 FROM conversation c WHERE c.tenant_id=d.tenant_id AND c.device_id=d.id AND c.household_id=$3::uuid)
)`
args = append(args, target.householdID)
}
err := s.pool.QueryRow(ctx, query, args...).Scan(&target.deviceID, &target.devicePublicID, &target.deviceName)
if errors.Is(err, pgx.ErrNoRows) {
if family {
return ErrAccessDenied
}
return ErrNotFound
}
return err
}
func (s *Service) buildUsageOverview(ctx context.Context, target usageOverviewTarget, timezone string, from, to time.Time) (UsageOverview, error) {
location, err := validateUsageOverviewRange(timezone, from, to)
if err != nil {
return UsageOverview{}, err
}
from = from.UTC()
to = to.UTC()
rows, err := s.pool.Query(ctx, `
SELECT c.public_id::text,h.public_id::text,h.name,d.public_id::text,d.model,sp.preferred_name,c.status,c.started_at,c.ended_at,
COALESCE(cost.cost_micros,0)::bigint,COALESCE(cost.currency,''),COALESCE(cost.has_cost,false)
FROM conversation c
JOIN household h ON h.id=c.household_id AND h.tenant_id=c.tenant_id
JOIN device d ON d.id=c.device_id AND d.tenant_id=c.tenant_id
JOIN subject_profile sp ON sp.id=c.subject_id AND sp.tenant_id=c.tenant_id
LEFT JOIN LATERAL (
SELECT SUM(ccl.cost_micros)::bigint AS cost_micros,MIN(ccl.currency) AS currency,COUNT(*)>0 AS has_cost
FROM conversation_cost_line ccl WHERE ccl.conversation_id=c.id
) cost ON true
WHERE c.tenant_id=$1::uuid AND c.started_at IS NOT NULL AND c.started_at>=$2 AND c.started_at<$3
AND ($4::uuid IS NULL OR c.household_id=$4::uuid)
AND ($5::uuid IS NULL OR c.device_id=$5::uuid)
ORDER BY c.started_at DESC,c.public_id`, target.tenantID, from, to, nullableUUID(target.householdID), nullableUUID(target.deviceID))
if err != nil {
return UsageOverview{}, err
}
defer rows.Close()
result := UsageOverview{
From: from, To: to, Timezone: timezone,
Scope: UsageOverviewScope{TenantID: target.tenantPublicID, TenantName: target.tenantName, HouseholdID: target.householdPublicID, HouseholdName: target.householdName, DeviceID: target.devicePublicID, DeviceName: target.deviceName},
Daily: []UsageOverviewDay{}, Devices: []UsageOverviewDevice{}, RecentConversations: []UsageOverviewConversation{},
}
daily := map[string]*UsageOverviewDay{}
devices := map[string]*UsageOverviewDevice{}
activeDates := map[string]struct{}{}
currencies := map[string]struct{}{}
today := time.Now().In(location).Format("2006-01-02")
for rows.Next() {
var row usageOverviewRow
if err = rows.Scan(&row.conversationID, &row.householdID, &row.householdName, &row.deviceID, &row.deviceName, &row.subjectName, &row.status, &row.startedAt, &row.endedAt, &row.costMicros, &row.currency, &row.hasCost); err != nil {
return UsageOverview{}, err
}
duration := conversationDurationMS(row.startedAt, row.endedAt, to)
date := row.startedAt.In(location).Format("2006-01-02")
day := daily[date]
if day == nil {
day = &UsageOverviewDay{Date: date}
daily[date] = day
}
day.DurationMS += duration
day.ConversationCount++
day.CostMicros += row.costMicros
activeDates[date] = struct{}{}
device := devices[row.deviceID]
if device == nil {
device = &UsageOverviewDevice{ID: row.deviceID, Name: row.deviceName, HouseholdID: row.householdID, HouseholdName: row.householdName}
devices[row.deviceID] = device
}
device.DurationMS += duration
device.ConversationCount++
device.CostMicros += row.costMicros
result.TotalDurationMS += duration
result.ConversationCount++
result.CostMicros += row.costMicros
if date == today {
result.TodayDurationMS += duration
}
if row.hasCost {
result.CostAvailable = true
currencies[row.currency] = struct{}{}
}
if len(result.RecentConversations) < 20 {
result.RecentConversations = append(result.RecentConversations, UsageOverviewConversation{
ID: row.conversationID, HouseholdID: row.householdID, HouseholdName: row.householdName,
DeviceID: row.deviceID, DeviceName: row.deviceName, SubjectName: row.subjectName, Status: row.status,
StartedAt: row.startedAt, EndedAt: row.endedAt, DurationMS: duration, CostMicros: row.costMicros,
})
}
}
if err = rows.Err(); err != nil {
return UsageOverview{}, err
}
if len(currencies) > 1 {
return UsageOverview{}, errors.New("usage overview contains multiple currencies")
}
for currency := range currencies {
result.Currency = currency
}
if result.ConversationCount > 0 {
result.AverageDurationMS = result.TotalDurationMS / int64(result.ConversationCount)
}
for _, day := range daily {
result.Daily = append(result.Daily, *day)
}
sort.Slice(result.Daily, func(i, j int) bool { return result.Daily[i].Date < result.Daily[j].Date })
for _, device := range devices {
result.Devices = append(result.Devices, *device)
}
sort.Slice(result.Devices, func(i, j int) bool {
if result.Devices[i].DurationMS == result.Devices[j].DurationMS {
return result.Devices[i].ID < result.Devices[j].ID
}
return result.Devices[i].DurationMS > result.Devices[j].DurationMS
})
result.ActiveDays = len(activeDates)
result.CurrentStreakDays, result.LongestStreakDays = usageStreaks(activeDates, time.Now().In(location))
return result, nil
}
func validateUsageOverviewRange(timezone string, from, to time.Time) (*time.Location, error) {
if timezone == "" || from.IsZero() || !to.After(from) || to.Sub(from) > maxUsageOverviewRange || to.After(time.Now().UTC().Add(5*time.Minute)) {
return nil, ErrInvalid
}
location, err := time.LoadLocation(timezone)
if err != nil {
return nil, ErrInvalid
}
return location, nil
}
func conversationDurationMS(startedAt time.Time, endedAt *time.Time, rangeEnd time.Time) int64 {
end := rangeEnd
if endedAt != nil && endedAt.Before(end) {
end = *endedAt
}
if end.Before(startedAt) {
return 0
}
return end.Sub(startedAt).Milliseconds()
}
func usageStreaks(active map[string]struct{}, now time.Time) (current, longest int) {
if len(active) == 0 {
return 0, 0
}
dates := make([]time.Time, 0, len(active))
for value := range active {
date, err := time.ParseInLocation("2006-01-02", value, now.Location())
if err == nil {
dates = append(dates, date)
}
}
sort.Slice(dates, func(i, j int) bool { return dates[i].Before(dates[j]) })
streak := 0
for index, date := range dates {
if index == 0 || date.Equal(dates[index-1].AddDate(0, 0, 1)) {
streak++
} else {
streak = 1
}
if streak > longest {
longest = streak
}
}
latest := dates[len(dates)-1]
today, _ := time.ParseInLocation("2006-01-02", now.Format("2006-01-02"), now.Location())
if latest.Before(today.AddDate(0, 0, -1)) {
return 0, longest
}
current = 1
for index := len(dates) - 1; index > 0 && dates[index].Equal(dates[index-1].AddDate(0, 0, 1)); index-- {
current++
}
return current, longest
}
func nullableUUID(value string) any {
if value == "" {
return nil
}
return value
}
+106
View File
@@ -0,0 +1,106 @@
package billing
import (
"context"
"errors"
"os"
"testing"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/identity"
)
func TestValidateUsageOverviewRange(t *testing.T) {
now := time.Now().UTC()
if _, err := validateUsageOverviewRange("Asia/Shanghai", now.Add(-30*24*time.Hour), now); err != nil {
t.Fatalf("valid range: %v", err)
}
for _, test := range []struct {
name string
timezone string
from time.Time
to time.Time
}{
{name: "timezone", timezone: "not/a-zone", from: now.Add(-time.Hour), to: now},
{name: "reversed", timezone: "Asia/Shanghai", from: now, to: now.Add(-time.Hour)},
{name: "too long", timezone: "Asia/Shanghai", from: now.Add(-367 * 24 * time.Hour), to: now},
} {
t.Run(test.name, func(t *testing.T) {
if _, err := validateUsageOverviewRange(test.timezone, test.from, test.to); err == nil {
t.Fatal("expected validation error")
}
})
}
}
func TestUsageStreaks(t *testing.T) {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 29, 18, 0, 0, 0, location)
active := map[string]struct{}{
"2026-08-20": {}, "2026-08-21": {},
"2026-08-26": {}, "2026-08-27": {}, "2026-08-28": {}, "2026-08-29": {},
}
current, longest := usageStreaks(active, now)
if current != 4 || longest != 4 {
t.Fatalf("current=%d longest=%d", current, longest)
}
current, longest = usageStreaks(map[string]struct{}{"2026-08-20": {}}, now)
if current != 0 || longest != 1 {
t.Fatalf("stale current=%d longest=%d", current, longest)
}
}
func TestConversationDurationMS(t *testing.T) {
start := time.Date(2026, 8, 29, 10, 0, 0, 0, time.UTC)
end := start.Add(15*time.Minute + 250*time.Millisecond)
if got := conversationDurationMS(start, &end, start.Add(time.Hour)); got != 900250 {
t.Fatalf("duration=%d", got)
}
if got := conversationDurationMS(start, nil, start.Add(3*time.Minute)); got != 180000 {
t.Fatalf("active duration=%d", got)
}
}
func TestUsageOverviewScopeIsolationIntegration(t *testing.T) {
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := database.Open(ctx, databaseURL, "usage-overview-test")
if err != nil {
t.Fatal(err)
}
defer pool.Close()
service, err := NewService(pool, audit.NewRepository())
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
owner := identity.Principal{Issuer: billingTestIssuer, Subject: "00000000-0000-0000-0000-000000000101"}
var operatorIssuer, operatorSubject string
if err = pool.QueryRow(ctx, `SELECT oidc_issuer,oidc_subject FROM user_account WHERE platform_role='ops_admin' AND account_kind='operator' AND status='active' ORDER BY created_at LIMIT 1`).Scan(&operatorIssuer, &operatorSubject); err != nil {
t.Fatal(err)
}
operator := identity.Principal{Issuer: operatorIssuer, Subject: operatorSubject}
family, err := service.FamilyUsageOverview(ctx, owner, "10000000-0000-0000-0000-000000000211", billingDevicePublicID, "Asia/Shanghai", now.Add(-365*24*time.Hour), now)
if err != nil || family.Scope.HouseholdID != "10000000-0000-0000-0000-000000000211" || family.Scope.DeviceID != billingDevicePublicID {
t.Fatalf("family overview=%#v err=%v", family.Scope, err)
}
if _, err = service.FamilyUsageOverview(ctx, owner, "20000000-0000-0000-0000-000000000211", "", "Asia/Shanghai", now.Add(-24*time.Hour), now); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("cross-household error=%v", err)
}
ops, err := service.OpsUsageOverview(ctx, operator, billingTenantPublicID, "10000000-0000-0000-0000-000000000211", billingDevicePublicID, "Asia/Shanghai", now.Add(-365*24*time.Hour), now)
if err != nil || ops.Scope.TenantID != billingTenantPublicID || ops.Scope.HouseholdID == "" || ops.Scope.DeviceID == "" {
t.Fatalf("ops overview=%#v err=%v", ops.Scope, err)
}
if _, err = service.OpsUsageOverview(ctx, operator, billingOtherTenantID, "", "", "Asia/Shanghai", now.Add(-24*time.Hour), now); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("cross-tenant error=%v", err)
}
}
+1 -1
View File
@@ -376,7 +376,7 @@ func (s *Service) resolveActor(ctx context.Context, principal identity.Principal
return actor{}, ErrInvalid
}
var result actor
err := s.pool.QueryRow(ctx, `SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,ua.platform_role FROM user_account ua JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE t.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active'`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&result.tenantID, &result.tenantPublicID, &result.userID, &result.userPublicID, &result.role)
err := s.pool.QueryRow(ctx, `SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,ua.platform_role FROM user_account ua JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE t.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active' AND ua.account_kind='operator'`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&result.tenantID, &result.tenantPublicID, &result.userID, &result.userPublicID, &result.role)
if errors.Is(err, pgx.ErrNoRows) {
return actor{}, ErrAccessDenied
}
+2 -2
View File
@@ -19,8 +19,8 @@ const (
billingTestIssuer = "http://localhost:58180/realms/voice-local"
billingTenantPublicID = "10000000-0000-0000-0000-000000000010"
billingOtherTenantID = "20000000-0000-0000-0000-000000000010"
billingAdminSubject = "00000000-0000-0000-0000-000000000101"
billingApproverSubject = "00000000-0000-0000-0000-000000000102"
billingAdminSubject = "00000000-0000-0000-0000-000000000104"
billingApproverSubject = "00000000-0000-0000-0000-000000000105"
billingDevicePublicID = "10000000-0000-0000-0000-000000000511"
billingTestSource = "integration://billing-service"
billingTestChangePrefix = "费用集成测试"
+93 -13
View File
@@ -2,6 +2,7 @@ package config
import (
"bytes"
"encoding/hex"
"errors"
"fmt"
"io"
@@ -46,7 +47,7 @@ type Config struct {
ProviderStartBurst int
ProviderMaxActiveTasks int
DeviceLocalSecret string
DeviceSecretDirectory string
DeviceCredentialMasterKey string
DeviceClockSkew time.Duration
RTCAppID string
RTCAppKey string
@@ -59,6 +60,8 @@ type Config struct {
HeartbeatInterval int
ProviderCallbackSecret string
ProviderCallbackSkew time.Duration
PlatformHealthSecret string
PlatformHealthSkew time.Duration
MockProviderBaseURL string
MockProviderAPIKey string
VoiceProvider string
@@ -105,6 +108,15 @@ type Config struct {
ContentSafetyTimeout time.Duration
EmbeddingProvider string
ArkEmbeddingModel string
MemoryToolBaseURL string
MemoryToolSigningSecret string
MemoryToolTokenTTL time.Duration
MemoryToolRateRPS float64
MemoryToolRateBurst int
ProviderMemoryMode string
ProviderMemoryBaseURL string
ProviderMemoryAPIKey string
ProviderMemoryTimeout time.Duration
}
func Load(serviceName string) (Config, error) {
@@ -160,6 +172,10 @@ func Load(serviceName string) (Config, error) {
if err != nil {
return Config{}, err
}
platformHealthSkew, err := durationEnv("PLATFORM_HEALTH_CALLBACK_CLOCK_SKEW", 5*time.Minute)
if err != nil {
return Config{}, err
}
providerRequestTimeout, err := durationEnv("PROVIDER_REQUEST_TIMEOUT", 5*time.Second)
if err != nil {
return Config{}, err
@@ -192,6 +208,14 @@ func Load(serviceName string) (Config, error) {
if err != nil {
return Config{}, err
}
providerMemoryTimeout, err := durationEnv("PROVIDER_MEMORY_TIMEOUT", 5*time.Second)
if err != nil {
return Config{}, err
}
memoryToolTokenTTL, err := durationEnv("MEMORY_TOOL_TOKEN_TTL", 4*time.Hour)
if err != nil {
return Config{}, err
}
objectStoreSecure, err := boolEnv("OBJECT_STORE_SECURE", false)
if err != nil {
return Config{}, err
@@ -202,15 +226,16 @@ func Load(serviceName string) (Config, error) {
}
environment := stringEnv("APP_ENV", EnvironmentLocal)
secretValues, err := loadSecretValues(
"DATABASE_URL", "REDIS_PASSWORD", "DEVICE_LOCAL_SECRET", "RTC_APP_KEY", "RTC_TOKEN_SIGNING_SECRET",
"CURSOR_SIGNING_SECRET", "SHARE_TOKEN_SIGNING_SECRET", "PROVIDER_CALLBACK_SECRET",
"DATABASE_URL", "REDIS_PASSWORD", "DEVICE_LOCAL_SECRET", "DEVICE_CREDENTIAL_MASTER_KEY", "RTC_APP_KEY", "RTC_TOKEN_SIGNING_SECRET",
"CURSOR_SIGNING_SECRET", "SHARE_TOKEN_SIGNING_SECRET", "PROVIDER_CALLBACK_SECRET", "PLATFORM_HEALTH_CALLBACK_SECRET",
"MOCK_PROVIDER_API_KEY", "VOLC_ACCESS_KEY_ID", "VOLC_SECRET_ACCESS_KEY", "VOLC_SESSION_TOKEN",
"OBJECT_STORE_ACCESS_KEY", "OBJECT_STORE_SECRET_KEY", "ALERT_WEBHOOK_SECRET", "ARK_API_KEY", "CONTENT_SAFETY_API_KEY",
"OBJECT_STORE_ACCESS_KEY", "OBJECT_STORE_SECRET_KEY", "ALERT_WEBHOOK_SECRET", "ARK_API_KEY", "CONTENT_SAFETY_API_KEY", "MEMORY_TOOL_SIGNING_SECRET", "PROVIDER_MEMORY_API_KEY",
)
if err != nil {
return Config{}, err
}
deviceLocalSecret := secretValues["DEVICE_LOCAL_SECRET"]
deviceCredentialMasterKey := secretValues["DEVICE_CREDENTIAL_MASTER_KEY"]
rtcTokenSecret := secretValues["RTC_TOKEN_SIGNING_SECRET"]
cursorSigningSecret := secretValues["CURSOR_SIGNING_SECRET"]
shareTokenSecret := secretValues["SHARE_TOKEN_SIGNING_SECRET"]
@@ -224,6 +249,9 @@ func Load(serviceName string) (Config, error) {
if deviceLocalSecret == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
deviceLocalSecret = "local-simulated-device-secret-32-bytes-minimum"
}
if deviceCredentialMasterKey == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
deviceCredentialMasterKey = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
}
if rtcTokenSecret == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
rtcTokenSecret = "local-simulated-rtc-token-secret-32-bytes-minimum"
}
@@ -233,6 +261,10 @@ func Load(serviceName string) (Config, error) {
if shareTokenSecret == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
shareTokenSecret = "local-share-token-signing-secret-32-bytes-minimum"
}
memoryToolSigningSecret := secretValues["MEMORY_TOOL_SIGNING_SECRET"]
if memoryToolSigningSecret == "" && (serviceName == "api" || serviceName == "worker") && (environment == EnvironmentLocal || environment == EnvironmentTest) {
memoryToolSigningSecret = "local-memory-tool-signing-secret-32-bytes-minimum"
}
if providerCallbackSecret == "" && serviceName == "callbacks" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
providerCallbackSecret = "local-provider-callback-secret-32-bytes-minimum"
}
@@ -278,7 +310,7 @@ func Load(serviceName string) (Config, error) {
RecoveryStartingAfter: recoveryStartingAfter,
RecoveryStoppingAfter: recoveryStoppingAfter,
DeviceLocalSecret: deviceLocalSecret,
DeviceSecretDirectory: strings.TrimSpace(os.Getenv("DEVICE_SECRET_DIRECTORY")),
DeviceCredentialMasterKey: deviceCredentialMasterKey,
DeviceClockSkew: deviceClockSkew,
RTCAppID: stringEnv("RTC_APP_ID", "local-public-rtc-app"),
RTCAppKey: secretValues["RTC_APP_KEY"],
@@ -290,6 +322,8 @@ func Load(serviceName string) (Config, error) {
AllocationTTL: allocationTTL,
ProviderCallbackSecret: providerCallbackSecret,
ProviderCallbackSkew: providerCallbackSkew,
PlatformHealthSecret: secretValues["PLATFORM_HEALTH_CALLBACK_SECRET"],
PlatformHealthSkew: platformHealthSkew,
MockProviderBaseURL: stringEnv("MOCK_PROVIDER_BASE_URL", "http://localhost:59200"),
MockProviderAPIKey: secretDefault(secretValues["MOCK_PROVIDER_API_KEY"], "local-mock-provider-key"),
VoiceProvider: stringEnv("VOICE_PROVIDER", "mock"),
@@ -328,6 +362,13 @@ func Load(serviceName string) (Config, error) {
ContentSafetyTimeout: contentSafetyTimeout,
EmbeddingProvider: stringEnv("EMBEDDING_PROVIDER", "local"),
ArkEmbeddingModel: strings.TrimSpace(os.Getenv("ARK_EMBEDDING_MODEL")),
MemoryToolBaseURL: strings.TrimSpace(os.Getenv("MEMORY_TOOL_BASE_URL")),
MemoryToolSigningSecret: memoryToolSigningSecret,
MemoryToolTokenTTL: memoryToolTokenTTL,
ProviderMemoryMode: stringEnv("PROVIDER_MEMORY_MODE", "disabled"),
ProviderMemoryBaseURL: strings.TrimSpace(os.Getenv("PROVIDER_MEMORY_BASE_URL")),
ProviderMemoryAPIKey: secretValues["PROVIDER_MEMORY_API_KEY"],
ProviderMemoryTimeout: providerMemoryTimeout,
}
cfg.RateLimitRPS, err = floatEnv("RATE_LIMIT_RPS", 50)
if err != nil {
@@ -373,6 +414,14 @@ func Load(serviceName string) (Config, error) {
if err != nil {
return Config{}, err
}
cfg.MemoryToolRateRPS, err = floatEnv("MEMORY_TOOL_RATE_RPS", 2)
if err != nil {
return Config{}, err
}
cfg.MemoryToolRateBurst, err = intEnv("MEMORY_TOOL_RATE_BURST", 5)
if err != nil {
return Config{}, err
}
cfg.HeartbeatInterval, err = intEnv("HEARTBEAT_INTERVAL_SECONDS", 15)
if err != nil {
return Config{}, err
@@ -419,14 +468,15 @@ func (c Config) Validate() error {
c.RateLimitUserRPS <= 0 || c.RateLimitUserBurst <= 0 ||
c.RateLimitTenantRPS <= 0 || c.RateLimitTenantBurst <= 0 ||
c.RateLimitDeviceRPS <= 0 || c.RateLimitDeviceBurst <= 0 ||
c.ProviderStartRPS <= 0 || c.ProviderStartBurst <= 0 || c.ProviderMaxActiveTasks <= 0 {
c.ProviderStartRPS <= 0 || c.ProviderStartBurst <= 0 || c.ProviderMaxActiveTasks <= 0 ||
c.MemoryToolRateRPS <= 0 || c.MemoryToolRateBurst <= 0 {
return errors.New("rate limits, bursts and provider task capacity must be positive")
}
if c.ServiceName == "api" && c.DeviceSecretDirectory == "" && len(c.DeviceLocalSecret) < 32 {
return errors.New("DEVICE_LOCAL_SECRET must contain at least 32 characters when DEVICE_SECRET_DIRECTORY is not configured")
if c.ServiceName == "api" {
decodedMasterKey, err := hex.DecodeString(c.DeviceCredentialMasterKey)
if err != nil || len(decodedMasterKey) != 32 {
return errors.New("DEVICE_CREDENTIAL_MASTER_KEY must be 32 bytes encoded as 64 hexadecimal characters")
}
if c.ServiceName == "api" && c.DeviceSecretDirectory != "" && !filepath.IsAbs(c.DeviceSecretDirectory) {
return errors.New("DEVICE_SECRET_DIRECTORY must be an absolute path")
}
if c.DeviceClockSkew <= 0 {
return errors.New("DEVICE_CLOCK_SKEW must be positive")
@@ -451,12 +501,27 @@ func (c Config) Validate() error {
if c.ServiceName == "api" && len(c.ShareTokenSecret) < 32 {
return errors.New("SHARE_TOKEN_SIGNING_SECRET must contain at least 32 characters")
}
if (c.ServiceName == "api" || c.ServiceName == "worker") && (len(c.MemoryToolSigningSecret) < 32 || c.MemoryToolTokenTTL <= 0) {
return errors.New("MEMORY_TOOL_SIGNING_SECRET must contain at least 32 characters and token TTL must be positive")
}
if c.MemoryToolBaseURL != "" && !isAbsoluteHTTPURL(c.MemoryToolBaseURL) {
return errors.New("MEMORY_TOOL_BASE_URL must be an absolute HTTP(S) URL")
}
if c.ProviderMemoryMode != "disabled" && c.ProviderMemoryMode != "managed_http" {
return errors.New("PROVIDER_MEMORY_MODE must be disabled or managed_http")
}
if (c.ServiceName == "api" || c.ServiceName == "worker") && c.ProviderMemoryMode == "managed_http" && (!isAbsoluteHTTPURL(c.ProviderMemoryBaseURL) || c.ProviderMemoryAPIKey == "" || c.ProviderMemoryTimeout <= 0) {
return errors.New("managed provider memory requires an HTTP(S) base URL, API key and positive timeout")
}
if c.RTCAppID == "" || c.RTCTokenTTL <= 0 || c.AllocationTTL <= 0 || c.HeartbeatInterval <= 0 {
return errors.New("RTC app, token TTL, allocation TTL and heartbeat interval must be valid")
}
if c.ServiceName == "callbacks" && len(c.ProviderCallbackSecret) < 32 {
return errors.New("PROVIDER_CALLBACK_SECRET must contain at least 32 characters")
}
if c.ServiceName == "callbacks" && len(c.PlatformHealthSecret) < 32 {
return errors.New("PLATFORM_HEALTH_CALLBACK_SECRET must contain at least 32 characters")
}
if c.ServiceName == "worker" {
if c.VoiceProvider != "mock" && c.VoiceProvider != "volcengine" {
return errors.New("VOICE_PROVIDER must be mock or volcengine")
@@ -470,7 +535,7 @@ func (c Config) Validate() error {
}
}
}
if c.ProviderCallbackSkew <= 0 || c.ProviderRequestTimeout <= 0 || c.ProviderFailureThreshold < 1 || c.ProviderCircuitCooldown <= 0 {
if c.ProviderCallbackSkew <= 0 || c.PlatformHealthSkew <= 0 || c.ProviderRequestTimeout <= 0 || c.ProviderFailureThreshold < 1 || c.ProviderCircuitCooldown <= 0 {
return errors.New("provider callback skew and request timeout must be positive")
}
if c.AlertWebhookTimeout <= 0 {
@@ -559,8 +624,8 @@ func (c Config) validateProduction() error {
}
if c.ServiceName == "api" {
if c.DeviceSecretDirectory == "" {
return errors.New("production API must use DEVICE_SECRET_DIRECTORY for per-device credentials")
if c.DeviceCredentialMasterKey == "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" {
return errors.New("production API must use a non-default DEVICE_CREDENTIAL_MASTER_KEY")
}
if isPlaceholderSecret(c.CursorSigningSecret) || isPlaceholderSecret(c.ShareTokenSecret) {
return errors.New("production API signing secrets must not use local or example values")
@@ -568,6 +633,12 @@ func (c Config) validateProduction() error {
if c.CursorSigningSecret == c.ShareTokenSecret {
return errors.New("production API signing secrets must be distinct")
}
if isPlaceholderSecret(c.MemoryToolSigningSecret) || c.MemoryToolSigningSecret == c.CursorSigningSecret || c.MemoryToolSigningSecret == c.ShareTokenSecret {
return errors.New("production memory tool signing secret must be non-placeholder and distinct")
}
if c.MemoryToolBaseURL != "" && !isHTTPSURL(c.MemoryToolBaseURL) {
return errors.New("production memory tool URL must use HTTPS")
}
if isLocalValue(c.RTCAppID) || c.RTCTokenIssuer != "volcengine" || isPlaceholderSecret(c.RTCAppKey) {
return errors.New("production RTC credentials must use a real app ID, Volcengine token issuer and non-placeholder app key")
}
@@ -578,6 +649,9 @@ func (c Config) validateProduction() error {
if c.ServiceName == "callbacks" && isPlaceholderSecret(c.ProviderCallbackSecret) {
return errors.New("production PROVIDER_CALLBACK_SECRET must not use a local or example value")
}
if c.ServiceName == "callbacks" && isPlaceholderSecret(c.PlatformHealthSecret) {
return errors.New("production PLATFORM_HEALTH_CALLBACK_SECRET must not use a local or example value")
}
if c.ServiceName == "worker" {
if c.VoiceProvider != "volcengine" || len(c.RTCAppID) != 24 || isLocalValue(c.RTCAppID) || !isHTTPSURL(c.VolcRTCBaseURL) || isLocalValue(c.VolcRTCBaseURL) || isPlaceholderSecret(c.VolcAccessKeyID) || isPlaceholderSecret(c.VolcSecretAccessKey) {
return errors.New("production voice provider must use signed Volcengine RTC HTTPS with non-placeholder AK/SK")
@@ -596,6 +670,12 @@ func (c Config) validateProduction() error {
}
}
if c.ServiceName == "api" || c.ServiceName == "worker" {
if isPlaceholderSecret(c.MemoryToolSigningSecret) {
return errors.New("production memory tool signing secret must be non-placeholder")
}
if c.ProviderMemoryMode == "managed_http" && (!isHTTPSURL(c.ProviderMemoryBaseURL) || isLocalValue(c.ProviderMemoryBaseURL) || isPlaceholderSecret(c.ProviderMemoryAPIKey)) {
return errors.New("production provider memory must use managed HTTPS and a non-placeholder API key")
}
if !c.ObjectStoreSecure || !c.ObjectStorePublicSecure {
return errors.New("production object storage must use TLS")
}
+32 -2
View File
@@ -10,6 +10,7 @@ import (
func TestLoadUsesServicePortDefaults(t *testing.T) {
t.Setenv("HTTP_ADDR", "")
t.Setenv("APP_ENV", "test")
t.Setenv("PLATFORM_HEALTH_CALLBACK_SECRET", strings.Repeat("h", 32))
tests := map[string]string{
"api": "0.0.0.0:58080",
@@ -45,6 +46,32 @@ func TestLoadRejectsInvalidDuration(t *testing.T) {
}
}
func TestLoadValidatesProviderMemoryConfiguration(t *testing.T) {
t.Setenv("PROVIDER_MEMORY_MODE", "managed_http")
t.Setenv("PROVIDER_MEMORY_BASE_URL", "https://memory.example.com")
t.Setenv("PROVIDER_MEMORY_API_KEY", strings.Repeat("p", 40))
cfg, err := Load("worker")
if err != nil {
t.Fatal(err)
}
if cfg.ProviderMemoryMode != "managed_http" || cfg.ProviderMemoryTimeout <= 0 {
t.Fatalf("provider memory config=%+v", cfg)
}
t.Setenv("PROVIDER_MEMORY_BASE_URL", "")
if _, err := Load("worker"); err == nil {
t.Fatal("managed provider memory should require an endpoint")
}
// The mode is distributed through the shared ConfigMap. Services that never
// create a provider-memory client must not require the API/Worker-only key.
t.Setenv("PROVIDER_MEMORY_API_KEY", "")
t.Setenv("PROVIDER_CALLBACK_SECRET", strings.Repeat("c", 32))
t.Setenv("PLATFORM_HEALTH_CALLBACK_SECRET", strings.Repeat("h", 32))
if _, err := Load("callbacks"); err != nil {
t.Fatalf("callbacks should ignore provider-memory credentials: %v", err)
}
}
func TestLoadReadsSecretsFromAbsoluteFiles(t *testing.T) {
secret := strings.Repeat("k", 40)
path := filepath.Join(t.TempDir(), "ark-api-key")
@@ -117,12 +144,14 @@ func TestProductionAPIAcceptsHardenedConfiguration(t *testing.T) {
cfg.AuthIssuerURL = "https://identity.example.com/realms/voice"
cfg.AuthJWKSURL = "https://identity.example.com/realms/voice/certs"
cfg.DeviceLocalSecret = strings.Repeat("d", 40)
cfg.DeviceSecretDirectory = t.TempDir()
cfg.DeviceCredentialMasterKey = strings.Repeat("ab", 32)
cfg.RTCTokenSecret = strings.Repeat("t", 40)
cfg.RTCTokenIssuer = "volcengine"
cfg.RTCAppKey = strings.Repeat("v", 40)
cfg.CursorSigningSecret = strings.Repeat("c", 40)
cfg.ShareTokenSecret = strings.Repeat("h", 40)
cfg.MemoryToolSigningSecret = strings.Repeat("m", 40)
cfg.MemoryToolBaseURL = "https://api.example.com/internal/v1/tools/memory-search"
cfg.RTCAppID = "661e7ae5543cf00000000000"
cfg.ObjectStoreEndpoint = "objects.internal.example.com:9000"
cfg.ObjectStorePublic = "objects.example.com"
@@ -150,7 +179,7 @@ func TestProductionRejectsReusedSigningSecrets(t *testing.T) {
cfg.AuthIssuerURL = "https://identity.example.com/realms/voice"
cfg.AuthJWKSURL = "https://identity.example.com/realms/voice/certs"
cfg.DeviceLocalSecret = strings.Repeat("x", 40)
cfg.DeviceSecretDirectory = t.TempDir()
cfg.DeviceCredentialMasterKey = strings.Repeat("cd", 32)
cfg.RTCTokenSecret = strings.Repeat("x", 40)
cfg.RTCTokenIssuer = "volcengine"
cfg.RTCAppKey = strings.Repeat("v", 40)
@@ -196,6 +225,7 @@ func TestProductionWorkerRequiresRealArkContentGenerator(t *testing.T) {
cfg.ObjectStoreSecretKey = strings.Repeat("s", 40)
cfg.ObjectStoreSecure = true
cfg.ObjectStorePublicSecure = true
cfg.MemoryToolSigningSecret = strings.Repeat("m", 40)
if err = cfg.Validate(); err == nil || !strings.Contains(err.Error(), "content generation") {
t.Fatalf("Validate() error=%v, want local content generator production gate", err)
}
+178
View File
@@ -0,0 +1,178 @@
package deviceauth
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const databaseCredentialKeyBytes = 32
var ErrCredentialAlreadyExists = errors.New("active device credential already exists")
type DatabaseSecretResolver struct {
pool *pgxpool.Pool
aead cipher.AEAD
}
func NewDatabaseSecretResolver(pool *pgxpool.Pool, masterKeyHex string) (*DatabaseSecretResolver, error) {
if pool == nil {
return nil, errors.New("device credential database is required")
}
key, err := hex.DecodeString(strings.TrimSpace(masterKeyHex))
if err != nil || len(key) != databaseCredentialKeyBytes {
return nil, errors.New("device credential master key must be 32 bytes encoded as 64 hexadecimal characters")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("create device credential cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("create device credential AEAD: %w", err)
}
return &DatabaseSecretResolver{pool: pool, aead: aead}, nil
}
func (r *DatabaseSecretResolver) Resolve(ctx context.Context, reference string) ([]byte, error) {
var encrypted, nonce []byte
var version int64
err := r.pool.QueryRow(ctx, `
SELECT encrypted_secret,nonce,key_version
FROM device_credential
WHERE credential_ref=$1 AND status='active'`, strings.TrimSpace(reference),
).Scan(&encrypted, &nonce, &version)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrCredentialInvalid
}
if err != nil {
return nil, fmt.Errorf("read device credential: %w", err)
}
secret, err := r.aead.Open(nil, nonce, encrypted, credentialAAD(reference, version))
if err != nil || len(secret) < 32 {
return nil, ErrCredentialInvalid
}
return secret, nil
}
// ImportMissingLegacyCredentials moves existing deployment credentials into
// the database without replacing an already imported or rotated credential.
func (r *DatabaseSecretResolver) ImportMissingLegacyCredentials(ctx context.Context, credentials map[string][]byte) error {
for reference, secret := range credentials {
if len(secret) < 32 {
return ErrCredentialInvalid
}
var deviceID, tenantID string
err := r.pool.QueryRow(ctx, `SELECT id::text,tenant_id::text FROM device WHERE credential_ref=$1`, reference).Scan(&deviceID, &tenantID)
if errors.Is(err, pgx.ErrNoRows) {
continue
}
if err != nil {
return fmt.Errorf("resolve legacy device credential %s: %w", reference, err)
}
nonce := make([]byte, r.aead.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return fmt.Errorf("generate device credential nonce: %w", err)
}
const version int64 = 1
encrypted := r.aead.Seal(nil, nonce, secret, credentialAAD(reference, version))
if _, err := r.pool.Exec(ctx, `
INSERT INTO device_credential(device_id,tenant_id,credential_ref,key_version,encrypted_secret,nonce)
VALUES($1::uuid,$2::uuid,$3,$4,$5,$6)
ON CONFLICT (device_id) DO NOTHING`, deviceID, tenantID, reference, version, encrypted, nonce); err != nil {
return fmt.Errorf("import legacy device credential %s: %w", reference, err)
}
}
return nil
}
func (r *DatabaseSecretResolver) Provision(ctx context.Context, devicePublicID string, secret []byte, rotate bool) (int64, error) {
if len(secret) < 32 {
return 0, ErrCredentialInvalid
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("begin device credential provisioning: %w", err)
}
defer tx.Rollback(ctx)
version, err := r.ProvisionTx(ctx, tx, devicePublicID, secret, rotate)
if err != nil {
return 0, err
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("commit device credential provisioning: %w", err)
}
return version, nil
}
// ProvisionTx stores a credential inside a caller-owned transaction so device
// registration and first credential issuance can commit atomically.
func (r *DatabaseSecretResolver) ProvisionTx(ctx context.Context, tx pgx.Tx, devicePublicID string, secret []byte, rotate bool) (int64, error) {
if len(secret) < 32 {
return 0, ErrCredentialInvalid
}
var deviceID, tenantID, reference string
if err := tx.QueryRow(ctx, `
SELECT id::text,tenant_id::text,credential_ref FROM device
WHERE public_id=$1::uuid AND status <> 'revoked' FOR UPDATE`, strings.TrimSpace(devicePublicID),
).Scan(&deviceID, &tenantID, &reference); errors.Is(err, pgx.ErrNoRows) {
return 0, ErrDeviceNotFound
} else if err != nil {
return 0, fmt.Errorf("resolve device for credential provisioning: %w", err)
}
var currentVersion int64
err := tx.QueryRow(ctx, `SELECT key_version FROM device_credential WHERE device_id=$1::uuid FOR UPDATE`, deviceID).Scan(&currentVersion)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return 0, fmt.Errorf("read current device credential: %w", err)
}
if err == nil && !rotate {
return 0, ErrCredentialAlreadyExists
}
version := currentVersion + 1
nonce := make([]byte, r.aead.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return 0, fmt.Errorf("generate device credential nonce: %w", err)
}
encrypted := r.aead.Seal(nil, nonce, secret, credentialAAD(reference, version))
if currentVersion == 0 {
_, err = tx.Exec(ctx, `
INSERT INTO device_credential(device_id,tenant_id,credential_ref,key_version,encrypted_secret,nonce)
VALUES($1::uuid,$2::uuid,$3,$4,$5,$6)`, deviceID, tenantID, reference, version, encrypted, nonce)
} else {
_, err = tx.Exec(ctx, `
UPDATE device_credential SET key_version=$2,encrypted_secret=$3,nonce=$4,status='active',
rotated_at=now(),revoked_at=NULL
WHERE device_id=$1::uuid`, deviceID, version, encrypted, nonce)
}
if err != nil {
return 0, fmt.Errorf("store device credential: %w", err)
}
return version, nil
}
func (r *DatabaseSecretResolver) Revoke(ctx context.Context, devicePublicID string) error {
result, err := r.pool.Exec(ctx, `
UPDATE device_credential dc SET status='revoked',revoked_at=now()
FROM device d WHERE d.id=dc.device_id AND d.public_id=$1::uuid AND dc.status='active'`, strings.TrimSpace(devicePublicID))
if err != nil {
return fmt.Errorf("revoke device credential: %w", err)
}
if result.RowsAffected() != 1 {
return ErrDeviceNotFound
}
return nil
}
func credentialAAD(reference string, version int64) []byte {
return []byte(fmt.Sprintf("device-credential-v1\n%s\n%d", strings.TrimSpace(reference), version))
}
@@ -0,0 +1,89 @@
package deviceauth
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestDatabaseSecretResolverImportsEncryptedCredential(t *testing.T) {
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
deviceID := uuid.NewString()
publicID := uuid.NewString()
reference := "database-test://" + publicID
serial := sha256.Sum256([]byte(publicID))
_, err = pool.Exec(ctx, `
INSERT INTO device(id,public_id,tenant_id,serial_digest,model,status,credential_ref)
VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001'::uuid,$3,'database-credential-test','registered',$4)`,
deviceID, publicID, hex.EncodeToString(serial[:]), reference)
if err != nil {
t.Fatal(err)
}
defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM device_credential WHERE device_id=$1::uuid`, deviceID)
_, _ = pool.Exec(ctx, `DELETE FROM device WHERE id=$1::uuid`, deviceID)
}()
const masterKey = "101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f"
secret := []byte("database-device-secret-at-least-32-bytes")
resolver, err := NewDatabaseSecretResolver(pool, masterKey)
if err != nil {
t.Fatal(err)
}
if err := resolver.ImportMissingLegacyCredentials(ctx, map[string][]byte{reference: secret}); err != nil {
t.Fatal(err)
}
resolved, err := resolver.Resolve(ctx, reference)
if err != nil || !bytes.Equal(resolved, secret) {
t.Fatalf("resolved credential does not match: len=%d err=%v", len(resolved), err)
}
var encrypted []byte
if err := pool.QueryRow(ctx, `SELECT encrypted_secret FROM device_credential WHERE device_id=$1::uuid`, deviceID).Scan(&encrypted); err != nil {
t.Fatal(err)
}
if bytes.Contains(encrypted, secret) || bytes.Equal(encrypted, secret) {
t.Fatal("database stored plaintext device credential")
}
wrongResolver, err := NewDatabaseSecretResolver(pool, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
if err != nil {
t.Fatal(err)
}
if _, err := wrongResolver.Resolve(ctx, reference); !errors.Is(err, ErrCredentialInvalid) {
t.Fatalf("wrong master key error=%v", err)
}
if _, err := resolver.Provision(ctx, publicID, []byte("replacement-device-secret-at-least-32-bytes"), false); !errors.Is(err, ErrCredentialAlreadyExists) {
t.Fatalf("duplicate provision error=%v", err)
}
replacement := []byte("replacement-device-secret-at-least-32-bytes")
version, err := resolver.Provision(ctx, publicID, replacement, true)
if err != nil || version != 2 {
t.Fatalf("rotate version=%d err=%v", version, err)
}
resolved, err = resolver.Resolve(ctx, reference)
if err != nil || !bytes.Equal(resolved, replacement) {
t.Fatalf("rotated credential does not match: len=%d err=%v", len(resolved), err)
}
if err := resolver.Revoke(ctx, publicID); err != nil {
t.Fatal(err)
}
if _, err := resolver.Resolve(ctx, reference); !errors.Is(err, ErrCredentialInvalid) {
t.Fatalf("revoked credential error=%v", err)
}
}
+154
View File
@@ -3,6 +3,8 @@ package diagnostics
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strings"
@@ -69,6 +71,30 @@ type AcceptanceAlertState struct {
BlockingItemsTruncated bool `json:"blocking_items_truncated"`
}
type AcceptancePlatformHealthState struct {
State string `json:"state"`
Status string `json:"status,omitempty"`
ObservedAt *time.Time `json:"observed_at,omitempty"`
ReceivedAt *time.Time `json:"received_at,omitempty"`
CheckCount int `json:"check_count"`
WarningCount int `json:"warning_count"`
CriticalCount int `json:"critical_count"`
GateSatisfied bool `json:"gate_satisfied"`
}
type AcceptanceProductionInfrastructureState struct {
Applicable bool `json:"applicable"`
DeploymentProfile string `json:"deployment_profile"`
ApplicationEnvironment string `json:"application_environment"`
IdentityServiceMode string `json:"identity_service_mode"`
ApplicationRuntimeReady bool `json:"application_runtime_ready"`
IdentityServiceReady bool `json:"identity_service_ready"`
OffsiteBackupReady bool `json:"offsite_backup_ready"`
ExternalAlertReceiverReady bool `json:"external_alert_receiver_ready"`
MockServicesDisabled bool `json:"mock_services_disabled"`
GateSatisfied bool `json:"gate_satisfied"`
}
type AcceptanceBlockingAlert struct {
ID string `json:"id"`
RuleCode string `json:"rule_code"`
@@ -103,6 +129,8 @@ type AcceptanceSnapshot struct {
ExternalDependencies []AcceptanceDependencyState `json:"external_dependencies"`
Outbox AcceptanceOutboxState `json:"outbox"`
Alerts AcceptanceAlertState `json:"alerts"`
PlatformHealth AcceptancePlatformHealthState `json:"platform_health"`
ProductionInfrastructure AcceptanceProductionInfrastructureState `json:"production_infrastructure"`
Gates []AcceptanceGate `json:"gates"`
OverallGateSatisfied bool `json:"overall_gate_satisfied"`
Disclaimer string `json:"disclaimer"`
@@ -275,6 +303,12 @@ func buildAcceptanceSnapshot(ctx context.Context, tx pgx.Tx, tenantPublicID, ten
); err != nil {
return AcceptanceSnapshot{}, err
}
platformHealth, productionInfrastructure, err := loadAcceptancePlatformHealth(ctx, tx, now, input.Environment)
if err != nil {
return AcceptanceSnapshot{}, err
}
snapshot.PlatformHealth = platformHealth
snapshot.ProductionInfrastructure = productionInfrastructure
runtimeSatisfied := input.Environment == "LAB" || (snapshot.RuntimeBuild.Version == input.CandidateVersion && snapshot.RuntimeBuild.Commit != "unknown" && snapshot.RuntimeBuild.BuildTime != "unknown")
dependenciesSatisfied := true
@@ -290,6 +324,8 @@ func buildAcceptanceSnapshot(ctx context.Context, tx pgx.Tx, tenantPublicID, ten
{Code: "integration_issues_clear", Satisfied: snapshot.IntegrationIssues.ReleaseGateSatisfied, Summary: "不得存在未关闭的高或严重级联调问题"},
{Code: "outbox_clear", Satisfied: outboxSatisfied, Summary: "租户及平台级待处理、处理中和死信任务必须清零"},
{Code: "critical_alerts_clear", Satisfied: alertsSatisfied, Summary: "不得存在未解决的高或严重级告警"},
{Code: "platform_health_current", Satisfied: snapshot.PlatformHealth.GateSatisfied, Summary: acceptancePlatformHealthSummary(snapshot.PlatformHealth)},
{Code: "production_infrastructure_ready", Satisfied: snapshot.ProductionInfrastructure.GateSatisfied, Summary: acceptanceProductionInfrastructureSummary(snapshot.ProductionInfrastructure)},
}
snapshot.OverallGateSatisfied = true
for _, gate := range snapshot.Gates {
@@ -298,6 +334,124 @@ func buildAcceptanceSnapshot(ctx context.Context, tx pgx.Tx, tenantPublicID, ten
return snapshot, nil
}
func loadAcceptancePlatformHealth(ctx context.Context, tx pgx.Tx, now time.Time, environment string) (AcceptancePlatformHealthState, AcceptanceProductionInfrastructureState, error) {
state := AcceptancePlatformHealthState{State: "MISSING"}
var observedAt, receivedAt time.Time
var checksJSON []byte
err := tx.QueryRow(ctx, `
SELECT status,observed_at,received_at,checks
FROM platform_health_snapshot ORDER BY observed_at DESC,id DESC LIMIT 1`).Scan(
&state.Status, &observedAt, &receivedAt, &checksJSON,
)
if errors.Is(err, pgx.ErrNoRows) {
return state, evaluateAcceptanceProductionInfrastructure(environment, nil), nil
}
if err != nil {
return AcceptancePlatformHealthState{}, AcceptanceProductionInfrastructureState{}, err
}
var checks []PlatformHealthCheck
if err = json.Unmarshal(checksJSON, &checks); err != nil {
return AcceptancePlatformHealthState{}, AcceptanceProductionInfrastructureState{}, err
}
return evaluateAcceptancePlatformHealth(state.Status, observedAt, receivedAt, checks, now), evaluateAcceptanceProductionInfrastructure(environment, checks), nil
}
func evaluateAcceptancePlatformHealth(status string, observedAt, receivedAt time.Time, checks []PlatformHealthCheck, now time.Time) AcceptancePlatformHealthState {
state := AcceptancePlatformHealthState{
State: "CURRENT", Status: status, ObservedAt: &observedAt, ReceivedAt: &receivedAt, CheckCount: len(checks),
}
for _, check := range checks {
switch check.Status {
case "warning":
state.WarningCount++
case "critical":
state.CriticalCount++
}
}
if observedAt.Before(now.Add(-platformHealthMaxAge)) || observedAt.After(now.Add(platformHealthFuture)) {
state.State = "STALE"
}
state.GateSatisfied = state.State == "CURRENT" && state.Status == "healthy" && state.WarningCount == 0 && state.CriticalCount == 0
return state
}
func acceptancePlatformHealthSummary(state AcceptancePlatformHealthState) string {
switch state.State {
case "MISSING":
return "尚未收到云端主机健康快照"
case "STALE":
return "云端主机健康快照必须在15分钟内更新"
}
if state.Status != "healthy" || state.WarningCount > 0 || state.CriticalCount > 0 {
return fmt.Sprintf("云端主机存在%d项预警和%d项严重异常", state.WarningCount, state.CriticalCount)
}
return fmt.Sprintf("云端主机健康快照当前有效,共%d项检查", state.CheckCount)
}
func evaluateAcceptanceProductionInfrastructure(environment string, checks []PlatformHealthCheck) AcceptanceProductionInfrastructureState {
state := AcceptanceProductionInfrastructureState{
Applicable: environment == "PRODUCTION",
DeploymentProfile: "MISSING",
ApplicationEnvironment: "MISSING",
IdentityServiceMode: "MISSING",
}
if !state.Applicable {
state.GateSatisfied = true
return state
}
profileReady := false
for _, check := range checks {
switch check.Name {
case "deployment_profile":
state.DeploymentProfile = check.Value
profileReady = check.Status == "healthy" && check.Value == "production"
case "application_environment":
state.ApplicationEnvironment = check.Value
state.ApplicationRuntimeReady = check.Status == "healthy" && check.Value == "prod"
case "identity_service_mode":
state.IdentityServiceMode = check.Value
state.IdentityServiceReady = check.Status == "healthy" && check.Value == "hardened"
case "offsite_backup":
state.OffsiteBackupReady = check.Status == "healthy" && strings.HasPrefix(check.Value, "age_seconds=")
case "external_alert_receiver":
state.ExternalAlertReceiverReady = check.Status == "healthy" && check.Value == "configured"
case "mock_services":
state.MockServicesDisabled = check.Status == "healthy" && check.Value == "disabled"
}
}
state.GateSatisfied = profileReady && state.ApplicationRuntimeReady && state.IdentityServiceReady && state.OffsiteBackupReady && state.ExternalAlertReceiverReady && state.MockServicesDisabled
return state
}
func acceptanceProductionInfrastructureSummary(state AcceptanceProductionInfrastructureState) string {
if !state.Applicable {
return "非生产候选不适用生产基础设施强制门禁"
}
missing := make([]string, 0, 6)
if state.DeploymentProfile != "production" {
missing = append(missing, "生产部署模式")
}
if !state.ApplicationRuntimeReady {
missing = append(missing, "生产应用运行环境")
}
if !state.IdentityServiceReady {
missing = append(missing, "非开发模式身份服务")
}
if !state.OffsiteBackupReady {
missing = append(missing, "已验证异地备份")
}
if !state.ExternalAlertReceiverReady {
missing = append(missing, "真实外部告警接收器")
}
if !state.MockServicesDisabled {
missing = append(missing, "关闭模拟服务")
}
if len(missing) > 0 {
return "生产基础设施尚缺:" + strings.Join(missing, "、")
}
return "生产部署与应用环境、身份服务、异地备份、外部告警和模拟服务退出均已确认"
}
func acceptanceRuntimeSummary(environment string) string {
if environment == "LAB" {
return "实验室环境允许开发构建,但候选版本与镜像摘要必须明确"
@@ -29,7 +29,7 @@ func TestAcceptanceSnapshotIsAuditedIdempotentAndTenantScoped(t *testing.T) {
if err != nil {
t.Fatal(err)
}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
tenantPublicID := "10000000-0000-0000-0000-000000000010"
key := "acceptance-snapshot-" + uuid.NewString()
requestID := "acceptance-snapshot-test-" + uuid.NewString()
@@ -59,7 +59,7 @@ func TestAcceptanceSnapshotIsAuditedIdempotentAndTenantScoped(t *testing.T) {
if err != nil || !created {
t.Fatalf("first=%+v created=%t err=%v", first, created, err)
}
if first.ID == "" || first.Database.MigrationVersion < 36 || first.Database.Dirty || first.RedactionPolicy != "ops-acceptance-minimal-v1" || len(first.ExternalDependencies) != 1 || len(first.Gates) != 6 {
if first.ID == "" || first.Database.MigrationVersion < 39 || first.Database.Dirty || first.RedactionPolicy != "ops-acceptance-minimal-v1" || len(first.ExternalDependencies) != 1 || len(first.Gates) != 8 {
t.Fatalf("unexpected snapshot=%+v", first)
}
if first.ExternalDependencies[0].Dependency != "oidc_jwks" || first.ExternalDependencies[0].ServiceName != "api" {
@@ -74,6 +74,12 @@ func TestAcceptanceSnapshotIsAuditedIdempotentAndTenantScoped(t *testing.T) {
if !foundBlockingAlert || first.Alerts.Blocking == 0 || first.Gates[5].Code != "critical_alerts_clear" || first.Gates[5].Satisfied {
t.Fatalf("blocking alert evidence=%+v gates=%+v", first.Alerts, first.Gates)
}
if first.Gates[6].Code != "platform_health_current" || first.PlatformHealth.State == "" {
t.Fatalf("platform health=%+v gates=%+v", first.PlatformHealth, first.Gates)
}
if first.Gates[7].Code != "production_infrastructure_ready" || !first.Gates[7].Satisfied || first.ProductionInfrastructure.Applicable {
t.Fatalf("production infrastructure=%+v gates=%+v", first.ProductionInfrastructure, first.Gates)
}
replayed, createdAgain, err := service.ExportAcceptanceSnapshot(ctx, admin, tenantPublicID, key, requestID, input)
if err != nil || createdAgain || replayed.ID != first.ID || !replayed.GeneratedAt.Equal(first.GeneratedAt) {
t.Fatalf("replayed=%+v created=%t err=%v", replayed, createdAgain, err)
@@ -3,6 +3,7 @@ package diagnostics
import (
"strings"
"testing"
"time"
)
func TestValidAcceptanceSnapshotInputLimitsAuditReason(t *testing.T) {
@@ -24,6 +25,82 @@ func TestValidAcceptanceSnapshotInputLimitsAuditReason(t *testing.T) {
}
}
func TestEvaluateAcceptancePlatformHealthBlocksStaleAndUnhealthyReports(t *testing.T) {
now := time.Date(2026, 8, 26, 14, 0, 0, 0, time.UTC)
tests := []struct {
name string
status string
observedAt time.Time
checks []PlatformHealthCheck
state string
satisfied bool
}{
{
name: "current healthy", status: "healthy", observedAt: now.Add(-5 * time.Minute),
checks: []PlatformHealthCheck{{Name: "public_https", Status: "healthy", Value: "http_200"}}, state: "CURRENT", satisfied: true,
},
{
name: "stale healthy", status: "healthy", observedAt: now.Add(-16 * time.Minute),
checks: []PlatformHealthCheck{{Name: "public_https", Status: "healthy", Value: "http_200"}}, state: "STALE", satisfied: false,
},
{
name: "current warning", status: "warning", observedAt: now.Add(-5 * time.Minute),
checks: []PlatformHealthCheck{{Name: "backup", Status: "warning", Value: "late"}}, state: "CURRENT", satisfied: false,
},
{
name: "current critical", status: "critical", observedAt: now.Add(-5 * time.Minute),
checks: []PlatformHealthCheck{{Name: "disk", Status: "critical", Value: "full"}}, state: "CURRENT", satisfied: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
state := evaluateAcceptancePlatformHealth(test.status, test.observedAt, now, test.checks, now)
if state.State != test.state || state.GateSatisfied != test.satisfied {
t.Fatalf("state=%+v", state)
}
})
}
}
func TestAcceptancePlatformHealthSummaryCoversMissingAndCounts(t *testing.T) {
if summary := acceptancePlatformHealthSummary(AcceptancePlatformHealthState{State: "MISSING"}); !strings.Contains(summary, "尚未收到") {
t.Fatalf("missing summary=%q", summary)
}
if summary := acceptancePlatformHealthSummary(AcceptancePlatformHealthState{State: "CURRENT", Status: "warning", WarningCount: 2, CriticalCount: 1}); !strings.Contains(summary, "2项预警") || !strings.Contains(summary, "1项严重异常") {
t.Fatalf("unhealthy summary=%q", summary)
}
}
func TestEvaluateAcceptanceProductionInfrastructure(t *testing.T) {
readyChecks := []PlatformHealthCheck{
{Name: "deployment_profile", Status: "healthy", Value: "production"},
{Name: "application_environment", Status: "healthy", Value: "prod"},
{Name: "identity_service_mode", Status: "healthy", Value: "hardened"},
{Name: "offsite_backup", Status: "healthy", Value: "age_seconds=120"},
{Name: "external_alert_receiver", Status: "healthy", Value: "configured"},
{Name: "mock_services", Status: "healthy", Value: "disabled"},
}
ready := evaluateAcceptanceProductionInfrastructure("PRODUCTION", readyChecks)
if !ready.Applicable || !ready.GateSatisfied || !ready.ApplicationRuntimeReady || !ready.IdentityServiceReady || !ready.OffsiteBackupReady || !ready.ExternalAlertReceiverReady || !ready.MockServicesDisabled {
t.Fatalf("ready=%+v", ready)
}
demo := evaluateAcceptanceProductionInfrastructure("PRODUCTION", []PlatformHealthCheck{
{Name: "deployment_profile", Status: "healthy", Value: "demo"},
{Name: "application_environment", Status: "healthy", Value: "local"},
{Name: "identity_service_mode", Status: "healthy", Value: "development"},
{Name: "offsite_backup", Status: "healthy", Value: "not_required"},
{Name: "external_alert_receiver", Status: "healthy", Value: "not_configured"},
{Name: "mock_services", Status: "healthy", Value: "enabled_for_demo"},
})
if demo.GateSatisfied || demo.DeploymentProfile != "demo" || !strings.Contains(acceptanceProductionInfrastructureSummary(demo), "异地备份") {
t.Fatalf("demo=%+v summary=%q", demo, acceptanceProductionInfrastructureSummary(demo))
}
lab := evaluateAcceptanceProductionInfrastructure("LAB", nil)
if lab.Applicable || !lab.GateSatisfied {
t.Fatalf("lab=%+v", lab)
}
}
func TestAcceptanceSnapshotAllowsAllProductionExternalDependencies(t *testing.T) {
input := AcceptanceSnapshotInput{
CandidateVersion: "0.64.0-rc.1", BuildDigest: "sha256:" + strings.Repeat("b", 64),
@@ -31,7 +31,7 @@ func TestIntegrationIssueLifecycleIsTenantScopedIdempotentAndVersioned(t *testin
if err != nil {
t.Fatal(err)
}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
tenantPublicID := "10000000-0000-0000-0000-000000000010"
devicePublicID := "10000000-0000-0000-0000-000000000511"
key := "integration-issue-" + uuid.NewString()
+135
View File
@@ -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")
}
}
+1 -1
View File
@@ -156,7 +156,7 @@ func (s *Service) resolveActor(ctx context.Context, principal identity.Principal
}
var result actor
var role string
err := s.pool.QueryRow(ctx, `SELECT t.id::text,ua.id::text,ua.public_id::text,ua.platform_role FROM tenant t JOIN user_account ua ON ua.tenant_id=t.id AND ua.status='active' WHERE t.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&result.tenantID, &result.userID, &result.userPublicID, &role)
err := s.pool.QueryRow(ctx, `SELECT t.id::text,ua.id::text,ua.public_id::text,ua.platform_role FROM tenant t JOIN user_account ua ON ua.tenant_id=t.id AND ua.status='active' AND ua.account_kind='operator' WHERE t.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&result.tenantID, &result.userID, &result.userPublicID, &role)
if errors.Is(err, pgx.ErrNoRows) {
return actor{}, ErrAccessDenied
}
@@ -31,7 +31,7 @@ func TestDiagnosticSearchAndConfigRefreshAreTenantScopedAndIdempotent(t *testing
if err != nil {
t.Fatal(err)
}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
tenantPublicID := "10000000-0000-0000-0000-000000000010"
devicePublicID := "10000000-0000-0000-0000-000000000511"
key := "diagnostic-refresh-integration"
@@ -79,8 +79,8 @@ func TestDiagnosticConversationActionsCoverStableAndProviderStates(t *testing.T)
if err != nil {
t.Fatal(err)
}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
approver := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000102"}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
approver := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000105"}
tenantPublicID := "10000000-0000-0000-0000-000000000010"
tenantID := "10000000-0000-0000-0000-000000000001"
householdID := "10000000-0000-0000-0000-000000000201"
@@ -191,7 +191,7 @@ func TestDiagnosticJobRetryBundleAndDeviceRevocation(t *testing.T) {
if err != nil {
t.Fatal(err)
}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
tenantPublicID, tenantID := "10000000-0000-0000-0000-000000000010", "10000000-0000-0000-0000-000000000001"
deviceID, devicePublicID := uuid.NewString(), uuid.NewString()
jobInternalID, aggregateID := uuid.NewString(), uuid.NewString()
+52 -1
View File
@@ -11,6 +11,7 @@ import (
"net/url"
"strings"
"time"
"unicode"
"data-terminal-cloud/internal/contentpolicy"
"data-terminal-cloud/internal/outbox"
@@ -26,6 +27,7 @@ type GenerationResult struct {
StoryTitle string `json:"story_title"`
StoryContent string `json:"story_content"`
FactContent string `json:"fact_content"`
Keywords []string `json:"keywords"`
Confidence float64 `json:"confidence"`
ModelRef string `json:"-"`
PromptVersion string `json:"-"`
@@ -49,6 +51,7 @@ func (DeterministicGenerator) Generate(_ context.Context, input GenerationInput)
StoryTitle: "关于“" + truncateRunes(content, 24) + "”的回忆",
StoryContent: content,
FactContent: content,
Keywords: deterministicKeywords(content),
Confidence: 0.9,
ModelRef: "local-deterministic-extractor",
PromptVersion: "story-v1",
@@ -117,7 +120,7 @@ func (g *ArkGenerator) Generate(ctx context.Context, input GenerationInput) (Gen
requestBody := map[string]any{
"model": g.model,
"messages": []map[string]string{
{"role": "system", "content": "你是长者口述内容整理助手。只依据输入字幕生成可人工核对的候选,不补充未出现的事实,不把推断写成事实。必须返回JSON对象,字段为summary、open_topic、story_title、story_content、fact_content、confidence。summary概括本次内容,open_topic给出一个温和的后续话题,story和fact保留原意,confidence取0到1。"},
{"role": "system", "content": "你是长者口述内容整理助手。只依据输入字幕生成可人工核对的候选,不补充未出现的事实,不把推断写成事实。必须返回JSON对象,字段为summary、keywords、open_topic、story_title、story_content、fact_content、confidence。summary概括本次内容,keywords是1至8个简短关键词字符串组成的数组,open_topic给出一个温和的后续话题,story和fact保留原意,confidence取0到1。"},
{"role": "user", "content": content},
},
"temperature": 0.2,
@@ -174,6 +177,7 @@ func (g *ArkGenerator) Generate(ctx context.Context, input GenerationInput) (Gen
result.StoryTitle = strings.TrimSpace(result.StoryTitle)
result.StoryContent = strings.TrimSpace(result.StoryContent)
result.FactContent = strings.TrimSpace(result.FactContent)
result.Keywords = normalizedKeywords(result.Keywords)
if !validGeneratedContent(result) {
return GenerationResult{}, outbox.Retryable(errors.New("Ark structured content failed validation"))
}
@@ -189,5 +193,52 @@ func validGeneratedContent(result GenerationResult) bool {
result.StoryTitle != "" && len([]rune(result.StoryTitle)) <= 300 &&
result.StoryContent != "" && len([]rune(result.StoryContent)) <= 20000 &&
result.FactContent != "" && len([]rune(result.FactContent)) <= 5000 &&
len(result.Keywords) >= 1 && len(result.Keywords) <= 8 &&
result.Confidence >= 0 && result.Confidence <= 1
}
func normalizedKeywords(values []string) []string {
result := make([]string, 0, min(len(values), 8))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || len([]rune(value)) > 40 {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
if len(result) == 8 {
break
}
}
return result
}
func deterministicKeywords(content string) []string {
parts := strings.FieldsFunc(content, func(character rune) bool {
return unicode.IsSpace(character) || strings.ContainsRune(",。!?;、,.!?;::\"“”‘’()()", character)
})
candidates := make([]string, 0, len(parts))
for _, part := range parts {
runes := []rune(strings.TrimSpace(part))
if len(runes) < 2 {
continue
}
if len(runes) > 12 {
runes = runes[:12]
}
candidates = append(candidates, string(runes))
}
keywords := normalizedKeywords(candidates)
if len(keywords) > 0 {
return keywords
}
runes := []rune(content)
if len(runes) > 12 {
runes = runes[:12]
}
return []string{string(runes)}
}

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