Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8cdec98d1 | ||
|
|
78e0a19acb | ||
|
|
dcf0fc0d7f | ||
|
|
522785eda8 | ||
|
|
89f6641331 | ||
|
|
2337c6cfaa | ||
|
|
962dd1a063 | ||
|
|
47f8a164fa | ||
|
|
f0849119f7 | ||
|
|
e652516147 | ||
|
|
d97bbf7cf5 | ||
|
|
5ff1961d79 | ||
|
|
3b827431c8 | ||
|
|
56b8e0c6da | ||
|
|
feb99de192 | ||
|
|
3786db0de8 | ||
|
|
7435af7c07 | ||
|
|
e4f7701439 | ||
|
|
47ef596d3b | ||
|
|
1e12d329dc | ||
|
|
0bf375155e | ||
|
|
fb4691c7b2 | ||
|
|
8607ec33e7 |
@@ -72,6 +72,8 @@ VOLC_RTC_API_VERSION=2024-12-01
|
|||||||
VOLC_VOICE_CHAT_TEMPLATE_FILE=
|
VOLC_VOICE_CHAT_TEMPLATE_FILE=
|
||||||
PROVIDER_CALLBACK_SECRET=local-provider-callback-secret-32-bytes-minimum
|
PROVIDER_CALLBACK_SECRET=local-provider-callback-secret-32-bytes-minimum
|
||||||
PROVIDER_CALLBACK_CLOCK_SKEW=5m
|
PROVIDER_CALLBACK_CLOCK_SKEW=5m
|
||||||
|
PLATFORM_HEALTH_CALLBACK_SECRET=local-platform-health-callback-secret-32-bytes-minimum
|
||||||
|
PLATFORM_HEALTH_CALLBACK_CLOCK_SKEW=5m
|
||||||
PROVIDER_REQUEST_TIMEOUT=5s
|
PROVIDER_REQUEST_TIMEOUT=5s
|
||||||
PROVIDER_FAILURE_THRESHOLD=3
|
PROVIDER_FAILURE_THRESHOLD=3
|
||||||
PROVIDER_CIRCUIT_COOLDOWN=1m
|
PROVIDER_CIRCUIT_COOLDOWN=1m
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ jobs:
|
|||||||
npm run build
|
npm run build
|
||||||
- name: Validate compose
|
- name: Validate compose
|
||||||
run: docker compose config --quiet
|
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
|
- name: Resolve immutable build identity
|
||||||
run: |
|
run: |
|
||||||
openapi_version="$(awk '/^ version:/{print $2; exit}' openapi/openapi.yaml)"
|
openapi_version="$(awk '/^ version:/{print $2; exit}' openapi/openapi.yaml)"
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
*.log
|
*.log
|
||||||
coverage.out
|
coverage.out
|
||||||
coverage*.out
|
coverage*.out
|
||||||
@@ -10,6 +12,7 @@ coverage*.out
|
|||||||
*.exe
|
*.exe
|
||||||
/opsconfig
|
/opsconfig
|
||||||
/deploy/server/.env
|
/deploy/server/.env
|
||||||
|
/deploy/local-rtc/runtime/
|
||||||
bin/
|
bin/
|
||||||
dist/
|
dist/
|
||||||
tmp/
|
tmp/
|
||||||
@@ -17,3 +20,5 @@ web/node_modules/
|
|||||||
web/dist/
|
web/dist/
|
||||||
.gocache/
|
.gocache/
|
||||||
test/e2e/screenshots/
|
test/e2e/screenshots/
|
||||||
|
.design-evidence/
|
||||||
|
device-simulator-windows/story-continuity-rtc/
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 变更记录
|
||||||
|
|
||||||
|
本项目使用语义化版本号。正式生产放行仍以仓库内的发布候选证据和外部门禁为准;里程碑标签只表示代码、迁移、接口和本地质量门禁形成了可追溯基线。
|
||||||
|
|
||||||
|
## 未发布
|
||||||
|
|
||||||
|
- 聊天总结改为大模型两级内容价值判断:先判断是否值得保留,再判断是否值得形成故事;纯寒暄不再生成摘要或故事。
|
||||||
|
- 新增内容价值判断记录,保留模型、提示词版本、置信度和舍弃原因,支持任务幂等与隐私删除。
|
||||||
|
- 本地开发新增 GPU Ollama/Qwen2.5 7B 部署方式,并通过大模型管理的测试、激活和 worker 生成链路完成端到端验证。
|
||||||
|
- 修复大模型“测试连接”更新语句和审计结果枚举错误,测试通过的模型现在可以正常激活。
|
||||||
|
- 维护端默认人设升级为老人陪伴对话规范,覆盖倾听、单问题追问、沉默与重复处理、事实边界、敏感话题和医疗安全。
|
||||||
|
- 家庭端长者档案新增日常陪伴、回忆往事、兴趣聊天三个模板,并补充兴趣、优先话题和避免话题的创建入口。
|
||||||
|
- Agent固定评测加强单问题追问、先回应再追问、沉默不施压、尊重重复表达和情绪优先回应检查。
|
||||||
|
|
||||||
|
## v0.70.0-rc.1 — 第一阶段里程碑
|
||||||
|
|
||||||
|
第一阶段把家庭陪伴业务、真实语音接入基础能力和运营维护能力整理为第一个可运行候选版本。
|
||||||
|
|
||||||
|
主要内容:
|
||||||
|
|
||||||
|
- 家庭端、维护端和Windows语音终端模拟器形成完整入口。
|
||||||
|
- 长者档案、分项授权、设备绑定、通话记录、故事、已确认记忆和隐私控制形成闭环。
|
||||||
|
- 火山RTC/豆包语音、方舟内容摘要、通话内记忆工具和供应商记忆适配具备受控配置入口。
|
||||||
|
- 运营配置、Agent质量证据、内容质量、设备维护、费用额度和诊断能力形成版本化管理闭环。
|
||||||
|
- 数据库迁移整理至V42,并把客户账号与运营账号彻底隔离。
|
||||||
|
- 本地质量门禁覆盖Go格式、静态检查、单元测试、隔离数据库集成测试、OpenAPI类型生成、Web类型检查与构建、Compose配置校验。
|
||||||
|
|
||||||
|
已知边界:
|
||||||
|
|
||||||
|
- 该标签是发布候选里程碑,不代表生产外部证据门禁已经通过。
|
||||||
|
- 真实终端、真实供应商、Safari、云基础设施和试点运行仍按对应证据文档验收。
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
这是依据《数据采集终端—云端系统研发需求规格说明书》建立的正式产品工程。当前已完成工程框架、公共能力、本地模拟语音闭环、家庭用户SaaS核心业务、字幕到人工确认记忆闭环,以及隐私、可靠设备命令、签名固件灰度、双轨录音、认证SSE状态事件流、具体资源分享、运营配置灰度发布、用量费用额度、告警处置、集成诊断和敏感数据临时提权闭环。
|
这是依据《数据采集终端—云端系统研发需求规格说明书》建立的正式产品工程。当前已完成工程框架、公共能力、本地模拟语音闭环、家庭用户SaaS核心业务、字幕到人工确认记忆闭环,以及隐私、可靠设备命令、签名固件灰度、双轨录音、认证SSE状态事件流、具体资源分享、运营配置灰度发布、用量费用额度、告警处置、集成诊断和敏感数据临时提权闭环。
|
||||||
|
|
||||||
|
当前代码基线为`v0.70.0-rc.1`第一阶段里程碑。版本范围、主要能力和生产放行边界见[变更记录](CHANGELOG.md)。
|
||||||
|
|
||||||
|
老人陪伴聊天的人设、长者档案填写、灰度发布和验收方法见[《老人陪伴聊天配置与发布》](docs/elder-conversation-guidance.md)。
|
||||||
|
|
||||||
## 当前已实现
|
## 当前已实现
|
||||||
|
|
||||||
- Go模块化单体的`api`、`callbacks`和`worker`三个进程入口。
|
- Go模块化单体的`api`、`callbacks`和`worker`三个进程入口。
|
||||||
@@ -34,11 +38,15 @@
|
|||||||
- 角色化业务导航:家庭用户只加载家庭业务,运营角色可按需切换运行配置、费用额度、告警处置和联调诊断,避免无权限页面与全部工作台同时加载。
|
- 角色化业务导航:家庭用户只加载家庭业务,运营角色可按需切换运行配置、费用额度、告警处置和联调诊断,避免无权限页面与全部工作台同时加载。
|
||||||
- 家庭成员协作:成员列表、一次性邀请/撤销/接受、跨客户组织受邀账号映射、角色变更、成员移除和家庭所有权转移。
|
- 家庭成员协作:成员列表、一次性邀请/撤销/接受、跨客户组织受邀账号映射、角色变更、成员移除和家庭所有权转移。
|
||||||
- 通话查询API:长者维度游标分页、状态过滤、脱敏详情、冻结快照摘要和完整状态历史。
|
- 通话查询API:长者维度游标分页、状态过滤、脱敏详情、冻结快照摘要和完整状态历史。
|
||||||
- 长者扩展档案、稳定事实、授权记录、设备绑定、最终话轮、摘要、故事版本和已确认记忆数据库模型;V8迁移已完成空库升降级验证。
|
- 长者扩展档案、稳定事实、授权记录、设备绑定、最终话轮、摘要、故事版本、已确认记忆、通话内记忆工具和内容质量反馈数据库模型;迁移已覆盖到V41并执行隔离数据库升降级验证。
|
||||||
- 模型内容候选化:只使用可追溯长者话轮生成候选;人工确认和当前有效记忆授权共同控制长期记忆写入。
|
- 模型内容候选化:只使用可追溯长者话轮生成候选;人工确认和当前有效记忆授权共同控制长期记忆写入。
|
||||||
- 可切换内容生成器:本地使用确定性适配器,生产可通过火山方舟OpenAI兼容接口生成结构化摘要、开放话题、故事和事实候选;模型网络调用不占用数据库事务,写入前重新核对通话状态、授权和话轮指纹,敏感内容在本地拦截后不发送外部模型。
|
- 可切换内容生成器:本地使用确定性适配器,生产可通过火山方舟OpenAI兼容接口生成结构化摘要、开放话题、故事和事实候选;模型网络调用不占用数据库事务,写入前重新核对通话状态、授权和话轮指纹,敏感内容在本地拦截后不发送外部模型。
|
||||||
- 新通话在数据库层按租户、长者、当前授权、状态、敏感级别、有效期和索引状态检索已确认记忆,并把实际版本冻结到背景快照。
|
- 新通话在数据库层按租户、长者、当前授权、状态、敏感级别、有效期和索引状态检索已确认记忆,并把实际版本冻结到背景快照。
|
||||||
|
- 在`story_extraction`与`personalized_memory`均有效时,新通话还会按同租户同长者带入最近3次已完成通话摘要;它是可撤回的短期连续上下文,不会绕过人工审核成为长期记忆。长者档案可配置最多8个“避免主动提及”话题,并从下一次新通话起随上下文快照生效。
|
||||||
- 可切换记忆向量:本地使用1536维确定性向量,生产可通过火山方舟OpenAI兼容`/embeddings`接口完成索引和查询;两端共用同一模型及维度,外部调用均位于数据库事务外,查询服务临时不可用时通话可无记忆降级启动。
|
- 可切换记忆向量:本地使用1536维确定性向量,生产可通过火山方舟OpenAI兼容`/embeddings`接口完成索引和查询;两端共用同一模型及维度,外部调用均位于数据库事务外,查询服务临时不可用时通话可无记忆降级启动。
|
||||||
|
- 通话中受控记忆工具:平台为固定通话和供应商任务签发短期令牌,服务端从数据库反查租户与长者边界;模型不能自行选择查询范围,检索失败返回空结果且不打断语音通话,实际使用按版本留痕。
|
||||||
|
- 可选供应商原生记忆:数据库始终是主数据,`managed_http`适配器通过可靠任务同步;修订、停用、授权撤回、单条删除和主体隐私删除均清理外部引用并对账,未完成真实账号验证前默认关闭。
|
||||||
|
- 细粒度内容审核与维护质量页:故事、单条事实和已确认记忆支持九类标准问题反馈;不可覆盖版本和最小质量信号按模型/提示词版本聚合到独立`/maintenance`维护端,不展示私密正文。
|
||||||
- 隐私数据访问请求:所有者幂等提交、Outbox/Worker子任务、八类数据范围盘点、无正文结果和审计。
|
- 隐私数据访问请求:所有者幂等提交、Outbox/Worker子任务、八类数据范围盘点、无正文结果和审计。
|
||||||
- 私有数据导出:按长者和分类生成JSON,MinIO私有存储、SHA-256校验、短期签名下载和过期对象清理。
|
- 私有数据导出:按长者和分类生成JSON,MinIO私有存储、SHA-256校验、短期签名下载和过期对象清理。
|
||||||
- 主体级隐私删除:精确称呼确认、活动通话保护、立即冻结读取/上传/召回,以及PostgreSQL、Redis、对象存储、pgvector、外部索引和私有导出六子系统的可重试对账与最小删除证明。
|
- 主体级隐私删除:精确称呼确认、活动通话保护、立即冻结读取/上传/召回,以及PostgreSQL、Redis、对象存储、pgvector、外部索引和私有导出六子系统的可重试对账与最小删除证明。
|
||||||
@@ -103,13 +111,15 @@ Set-Location E:\Codex\story\platform
|
|||||||
|
|
||||||
Web也允许通过`http://127.0.0.1:3100`访问;本地Keycloak客户端为`localhost`和`127.0.0.1`分别登记了3100生产预览端口与5173前端开发端口,登录回调必须保持当前浏览器来源一致。
|
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地址和客户端编号;这些值是浏览器可见的公开配置,不能用于保存客户端密钥。
|
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`同时存在会拒绝启动,防止轮换时仍意外使用旧的明文环境变量。
|
数据库连接串、Redis、RTC AppKey、火山AK/SK、方舟API Key、对象存储凭据、回调/告警签名等敏感配置同时支持`NAME_FILE=/run/secrets/name`。文件路径必须是容器内绝对路径、目标必须是普通文件且不超过64KiB;读取时只去掉末尾换行,不在错误中打印内容。`NAME`与`NAME_FILE`同时存在会拒绝启动,防止轮换时仍意外使用旧的明文环境变量。
|
||||||
|
|
||||||
@@ -185,6 +195,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
|
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`。
|
还可用`SIM_SCENARIO=media`和`SIM_CONVERSATION_ID`验证已授权通话的录音直传、后台校验、延迟删除及设备删除回执;详见`docs/device-integration.md`。
|
||||||
|
|
||||||
执行设备状态、错误和命令结果的批量遥测、幂等、事件判重及冲突回滚烟测:
|
执行设备状态、错误和命令结果的批量遥测、幂等、事件判重及冲突回滚烟测:
|
||||||
@@ -326,13 +345,14 @@ internal/ 平台公共能力及后续领域模块
|
|||||||
migrations/ 仅追加的数据库迁移
|
migrations/ 仅追加的数据库迁移
|
||||||
openapi/ 对外接口契约基线
|
openapi/ 对外接口契约基线
|
||||||
web/ 家庭用户和运营网页
|
web/ 家庭用户和运营网页
|
||||||
|
device-simulator-windows/ Windows真实音频终端模拟器
|
||||||
deploy/ 容器构建与运行配置
|
deploy/ 容器构建与运行配置
|
||||||
docs/ 架构决策与研发基线
|
docs/ 架构决策与研发基线
|
||||||
scripts/ 本地启动和质量检查脚本
|
scripts/ 本地启动和质量检查脚本
|
||||||
test/ 契约、集成和端到端测试
|
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)。
|
从框架搭建到生产落地的阶段任务、里程碑、人员、测试门禁、依赖和风险见[详细开发计划](docs/development-plan.md),当前实际完成证据见[开发状态与门禁记录](docs/development-status.md)。
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
+34
-2
@@ -13,7 +13,7 @@ x-backend-environment: &backend-environment
|
|||||||
AUTH_ISSUER_URL: http://localhost:58180/realms/voice-local
|
AUTH_ISSUER_URL: http://localhost:58180/realms/voice-local
|
||||||
AUTH_JWKS_URL: http://keycloak:8080/realms/voice-local/protocol/openid-connect/certs
|
AUTH_JWKS_URL: http://keycloak:8080/realms/voice-local/protocol/openid-connect/certs
|
||||||
AUTH_AUDIENCE: voice-platform-web
|
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_RPS: "50"
|
||||||
RATE_LIMIT_BURST: "100"
|
RATE_LIMIT_BURST: "100"
|
||||||
RATE_LIMIT_USER_RPS: "20"
|
RATE_LIMIT_USER_RPS: "20"
|
||||||
@@ -25,8 +25,18 @@ x-backend-environment: &backend-environment
|
|||||||
PROVIDER_START_RPS: "20"
|
PROVIDER_START_RPS: "20"
|
||||||
PROVIDER_START_BURST: "40"
|
PROVIDER_START_BURST: "40"
|
||||||
PROVIDER_MAX_ACTIVE_TASKS: "100"
|
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_CLOCK_SKEW: 5m
|
||||||
DEVICE_SECRET_DIRECTORY: ${DEVICE_SECRET_DIRECTORY:-}
|
DEVICE_CREDENTIAL_MASTER_KEY: ${DEVICE_CREDENTIAL_MASTER_KEY:-000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f}
|
||||||
|
MODEL_CREDENTIAL_MASTER_KEY: ${MODEL_CREDENTIAL_MASTER_KEY:-1f1e1d1c1b1a191817161514131211100f0e0d0c0b0a09080706050403020100}
|
||||||
RTC_APP_ID: local-public-rtc-app
|
RTC_APP_ID: local-public-rtc-app
|
||||||
RTC_APP_KEY: ${RTC_APP_KEY:-}
|
RTC_APP_KEY: ${RTC_APP_KEY:-}
|
||||||
RTC_TOKEN_ISSUER: ${RTC_TOKEN_ISSUER:-local}
|
RTC_TOKEN_ISSUER: ${RTC_TOKEN_ISSUER:-local}
|
||||||
@@ -145,6 +155,26 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./deploy/keycloak:/opt/keycloak/data/import:ro
|
- ./deploy/keycloak:/opt/keycloak/data/import:ro
|
||||||
|
|
||||||
|
ollama:
|
||||||
|
profiles: [local-model]
|
||||||
|
image: ollama/ollama:latest
|
||||||
|
ports:
|
||||||
|
- "${OLLAMA_PORT:-11434}:11434"
|
||||||
|
volumes:
|
||||||
|
- ollama-data:/root/.ollama
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [gpu]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "ollama", "list"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
|
|
||||||
otel-collector:
|
otel-collector:
|
||||||
image: otel/opentelemetry-collector-contrib:latest
|
image: otel/opentelemetry-collector-contrib:latest
|
||||||
command: ["--config=/etc/otelcol-contrib/config.yaml"]
|
command: ["--config=/etc/otelcol-contrib/config.yaml"]
|
||||||
@@ -220,6 +250,7 @@ services:
|
|||||||
<<: *backend-environment
|
<<: *backend-environment
|
||||||
HTTP_ADDR: 0.0.0.0:8081
|
HTTP_ADDR: 0.0.0.0:8081
|
||||||
PROVIDER_CALLBACK_SECRET: local-provider-callback-secret-32-bytes-minimum
|
PROVIDER_CALLBACK_SECRET: local-provider-callback-secret-32-bytes-minimum
|
||||||
|
PLATFORM_HEALTH_CALLBACK_SECRET: local-platform-health-callback-secret-32-bytes-minimum
|
||||||
RATE_LIMIT_RPS: "1000"
|
RATE_LIMIT_RPS: "1000"
|
||||||
RATE_LIMIT_BURST: "1500"
|
RATE_LIMIT_BURST: "1500"
|
||||||
ports:
|
ports:
|
||||||
@@ -314,3 +345,4 @@ volumes:
|
|||||||
postgres-data:
|
postgres-data:
|
||||||
redis-data:
|
redis-data:
|
||||||
minio-data:
|
minio-data:
|
||||||
|
ollama-data:
|
||||||
|
|||||||
@@ -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.Commit=${COMMIT} \
|
||||||
-X data-terminal-cloud/internal/buildinfo.BuildTime=${BUILD_TIME}" -o /out/worker ./cmd/worker && \
|
-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/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/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/alert-receiver-conformance ./cmd/alert-receiver-conformance && \
|
||||||
CGO_ENABLED=0 go build -trimpath -o /out/kms-rotation-conformance ./cmd/kms-rotation-conformance && \
|
CGO_ENABLED=0 go build -trimpath -o /out/kms-rotation-conformance ./cmd/kms-rotation-conformance && \
|
||||||
|
|||||||
@@ -79,6 +79,26 @@
|
|||||||
"emailVerified": true,
|
"emailVerified": true,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"credentials": [{"type": "password", "value": "LocalOnly123!", "temporary": false}]
|
"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}]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ data:
|
|||||||
ARK_CONTENT_MODEL: replace-with-ark-content-endpoint-id
|
ARK_CONTENT_MODEL: replace-with-ark-content-endpoint-id
|
||||||
ARK_EMBEDDING_MODEL: replace-with-ark-embedding-endpoint-id
|
ARK_EMBEDDING_MODEL: replace-with-ark-embedding-endpoint-id
|
||||||
ARK_REQUEST_TIMEOUT: 30s
|
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
|
WORKER_POLL_INTERVAL: 2s
|
||||||
ALERT_WEBHOOK_TIMEOUT: 5s
|
ALERT_WEBHOOK_TIMEOUT: 5s
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -55,14 +55,16 @@ spec:
|
|||||||
value: /var/run/secrets/voice-platform/common/database-url
|
value: /var/run/secrets/voice-platform/common/database-url
|
||||||
- name: REDIS_PASSWORD_FILE
|
- name: REDIS_PASSWORD_FILE
|
||||||
value: /var/run/secrets/voice-platform/common/redis-password
|
value: /var/run/secrets/voice-platform/common/redis-password
|
||||||
- name: DEVICE_SECRET_DIRECTORY
|
- name: DEVICE_CREDENTIAL_MASTER_KEY_FILE
|
||||||
value: /var/run/secrets/voice-platform/devices
|
value: /var/run/secrets/voice-platform/api/device-credential-master-key
|
||||||
- name: RTC_APP_KEY_FILE
|
- name: RTC_APP_KEY_FILE
|
||||||
value: /var/run/secrets/voice-platform/api/rtc-app-key
|
value: /var/run/secrets/voice-platform/api/rtc-app-key
|
||||||
- name: CURSOR_SIGNING_SECRET_FILE
|
- name: CURSOR_SIGNING_SECRET_FILE
|
||||||
value: /var/run/secrets/voice-platform/api/cursor-signing-secret
|
value: /var/run/secrets/voice-platform/api/cursor-signing-secret
|
||||||
- name: SHARE_TOKEN_SIGNING_SECRET_FILE
|
- name: SHARE_TOKEN_SIGNING_SECRET_FILE
|
||||||
value: /var/run/secrets/voice-platform/api/share-token-signing-secret
|
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
|
- name: OBJECT_STORE_ACCESS_KEY_FILE
|
||||||
value: /var/run/secrets/voice-platform/model-storage/object-store-access-key
|
value: /var/run/secrets/voice-platform/model-storage/object-store-access-key
|
||||||
- name: OBJECT_STORE_SECRET_KEY_FILE
|
- name: OBJECT_STORE_SECRET_KEY_FILE
|
||||||
@@ -114,9 +116,6 @@ spec:
|
|||||||
- name: model-storage-secrets
|
- name: model-storage-secrets
|
||||||
mountPath: /var/run/secrets/voice-platform/model-storage
|
mountPath: /var/run/secrets/voice-platform/model-storage
|
||||||
readOnly: true
|
readOnly: true
|
||||||
- name: device-secrets
|
|
||||||
mountPath: /var/run/secrets/voice-platform/devices
|
|
||||||
readOnly: true
|
|
||||||
volumes:
|
volumes:
|
||||||
- name: common-secrets
|
- name: common-secrets
|
||||||
secret:
|
secret:
|
||||||
@@ -127,9 +126,6 @@ spec:
|
|||||||
- name: model-storage-secrets
|
- name: model-storage-secrets
|
||||||
secret:
|
secret:
|
||||||
secretName: voice-platform-model-storage-secrets
|
secretName: voice-platform-model-storage-secrets
|
||||||
- name: device-secrets
|
|
||||||
secret:
|
|
||||||
secretName: voice-platform-device-secrets
|
|
||||||
---
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
@@ -190,6 +186,8 @@ spec:
|
|||||||
value: /var/run/secrets/voice-platform/common/redis-password
|
value: /var/run/secrets/voice-platform/common/redis-password
|
||||||
- name: PROVIDER_CALLBACK_SECRET_FILE
|
- name: PROVIDER_CALLBACK_SECRET_FILE
|
||||||
value: /var/run/secrets/voice-platform/callbacks/provider-callback-secret
|
value: /var/run/secrets/voice-platform/callbacks/provider-callback-secret
|
||||||
|
- name: PLATFORM_HEALTH_CALLBACK_SECRET_FILE
|
||||||
|
value: /var/run/secrets/voice-platform/callbacks/platform-health-callback-secret
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 8081
|
containerPort: 8081
|
||||||
@@ -309,6 +307,8 @@ spec:
|
|||||||
value: /var/run/secrets/voice-platform/model-storage/ark-api-key
|
value: /var/run/secrets/voice-platform/model-storage/ark-api-key
|
||||||
- name: CONTENT_SAFETY_API_KEY_FILE
|
- name: CONTENT_SAFETY_API_KEY_FILE
|
||||||
value: /var/run/secrets/voice-platform/model-storage/content-safety-api-key
|
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:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 8082
|
containerPort: 8082
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
provider: replace-with-installed-provider
|
provider: replace-with-installed-provider
|
||||||
parameters:
|
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
|
apiVersion: secrets-store.csi.x-k8s.io/v1
|
||||||
kind: SecretProviderClass
|
kind: SecretProviderClass
|
||||||
@@ -29,7 +29,7 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
provider: replace-with-installed-provider
|
provider: replace-with-installed-provider
|
||||||
parameters:
|
parameters:
|
||||||
objects: replace-with-provider-specific-callback-secret
|
objects: replace-with-provider-specific-provider-and-platform-health-callback-secrets
|
||||||
---
|
---
|
||||||
apiVersion: secrets-store.csi.x-k8s.io/v1
|
apiVersion: secrets-store.csi.x-k8s.io/v1
|
||||||
kind: SecretProviderClass
|
kind: SecretProviderClass
|
||||||
@@ -49,14 +49,4 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
provider: replace-with-installed-provider
|
provider: replace-with-installed-provider
|
||||||
parameters:
|
parameters:
|
||||||
objects: replace-with-provider-specific-object-store-ark-and-content-safety-credentials
|
objects: replace-with-provider-specific-object-store-ark-content-safety-and-memory-tool-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
|
|
||||||
|
|||||||
@@ -26,12 +26,6 @@ spec:
|
|||||||
readOnly: true
|
readOnly: true
|
||||||
volumeAttributes:
|
volumeAttributes:
|
||||||
secretProviderClass: voice-platform-model-storage
|
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
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 本地聊天总结模型
|
||||||
|
|
||||||
|
本地开发环境使用 Ollama 提供 OpenAI 兼容接口,默认验证模型为 `qwen2.5:7b`。模型只监听本机 `11434` 端口,应用容器通过 `http://ollama:11434/v1` 访问。
|
||||||
|
|
||||||
|
## 启动与下载模型
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose --profile local-model up -d ollama
|
||||||
|
docker compose --profile local-model exec ollama ollama pull qwen2.5:7b
|
||||||
|
```
|
||||||
|
|
||||||
|
随后在客户门户的“大模型管理”中创建并激活配置:
|
||||||
|
|
||||||
|
- 服务类型:`OpenAI 兼容接口`
|
||||||
|
- 接口基础地址:`http://ollama:11434/v1`
|
||||||
|
- 模型 ID:`qwen2.5:7b`
|
||||||
|
- API Key:本地 Ollama 不校验密钥,可填写长度至少 8 位的本地占位值
|
||||||
|
- 建议温度:`0.1`
|
||||||
|
- 建议最大输出:`1200`
|
||||||
|
|
||||||
|
必须先执行“测试连接”,通过后才能“设为当前模型”。配置激活后,worker 会在每个总结任务开始时读取当前模型,不需要重启。
|
||||||
|
|
||||||
|
## 真实模型回归测试
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:LOCAL_MODEL_BASE_URL='http://localhost:11434/v1'
|
||||||
|
$env:LOCAL_MODEL_ID='qwen2.5:7b'
|
||||||
|
go test ./internal/extraction -run TestLocalModelContentValueDecisions -count=1 -v
|
||||||
|
```
|
||||||
|
|
||||||
|
测试覆盖三类输入:纯寒暄应舍弃、有用近况只生成摘要、完整个人回忆生成摘要和故事候选。
|
||||||
@@ -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 均不进入模拟器安装包,也不会通过渲染页面读取。
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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的服务器专用配置:
|
服务器至少需要Docker Engine 29与Compose 2.40。首次部署先生成权限为600的服务器专用配置:
|
||||||
|
|
||||||
@@ -15,10 +15,67 @@
|
|||||||
载入同一候选版本的`data-terminal-cloud-backend`和`data-terminal-cloud-web`镜像后执行:
|
载入同一候选版本的`data-terminal-cloud-backend`和`data-terminal-cloud-web`镜像后执行:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
chmod 0755 deploy/server/*.sh
|
||||||
docker compose -f compose.yaml -f deploy/server/compose.override.yaml \
|
docker compose -f compose.yaml -f deploy/server/compose.override.yaml \
|
||||||
--env-file deploy/server/.env up -d --no-build \
|
--env-file deploy/server/.env up -d --no-build \
|
||||||
postgres redis minio keycloak migrate seed mock-alert-receiver callbacks \
|
postgres redis minio keycloak migrate seed mock-alert-receiver callbacks \
|
||||||
mock-provider api worker web gateway
|
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)。
|
||||||
|
|||||||
Executable
+145
@@ -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"
|
||||||
@@ -33,14 +33,17 @@ umask 077
|
|||||||
printf 'KEYCLOAK_ADMIN_USERNAME=platform-admin\n'
|
printf 'KEYCLOAK_ADMIN_USERNAME=platform-admin\n'
|
||||||
printf 'KEYCLOAK_ADMIN_PASSWORD=%s\n' "$(openssl rand -hex 24)"
|
printf 'KEYCLOAK_ADMIN_PASSWORD=%s\n' "$(openssl rand -hex 24)"
|
||||||
printf 'DEVICE_LOCAL_SECRET=%s\n' "$(openssl rand -hex 32)"
|
printf 'DEVICE_LOCAL_SECRET=%s\n' "$(openssl rand -hex 32)"
|
||||||
|
printf 'DEVICE_CREDENTIAL_MASTER_KEY=%s\n' "$(openssl rand -hex 32)"
|
||||||
|
printf 'MODEL_CREDENTIAL_MASTER_KEY=%s\n' "$(openssl rand -hex 32)"
|
||||||
printf 'RTC_TOKEN_SIGNING_SECRET=%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 'CURSOR_SIGNING_SECRET=%s\n' "$(openssl rand -hex 32)"
|
||||||
printf 'SHARE_TOKEN_SIGNING_SECRET=%s\n' "$(openssl rand -hex 32)"
|
printf 'SHARE_TOKEN_SIGNING_SECRET=%s\n' "$(openssl rand -hex 32)"
|
||||||
|
printf 'MEMORY_TOOL_SIGNING_SECRET=%s\n' "$(openssl rand -hex 32)"
|
||||||
printf 'PROVIDER_CALLBACK_SECRET=%s\n' "$(openssl rand -hex 32)"
|
printf 'PROVIDER_CALLBACK_SECRET=%s\n' "$(openssl rand -hex 32)"
|
||||||
|
printf 'PLATFORM_HEALTH_CALLBACK_SECRET=%s\n' "$(openssl rand -hex 32)"
|
||||||
printf 'MOCK_PROVIDER_API_KEY=%s\n' "$(openssl rand -hex 24)"
|
printf 'MOCK_PROVIDER_API_KEY=%s\n' "$(openssl rand -hex 24)"
|
||||||
printf 'ALERT_WEBHOOK_SECRET=%s\n' "$(openssl rand -hex 32)"
|
printf 'ALERT_WEBHOOK_SECRET=%s\n' "$(openssl rand -hex 32)"
|
||||||
} > "$output_path"
|
} > "$output_path"
|
||||||
|
|
||||||
chmod 600 "$output_path"
|
chmod 600 "$output_path"
|
||||||
echo "created $output_path with mode 600"
|
echo "created $output_path with mode 600"
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ x-server-backend-environment: &server-backend-environment
|
|||||||
OBJECT_STORE_PUBLIC_SECURE: "true"
|
OBJECT_STORE_PUBLIC_SECURE: "true"
|
||||||
CURSOR_SIGNING_SECRET: ${CURSOR_SIGNING_SECRET}
|
CURSOR_SIGNING_SECRET: ${CURSOR_SIGNING_SECRET}
|
||||||
SHARE_TOKEN_SIGNING_SECRET: ${SHARE_TOKEN_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}
|
||||||
|
MODEL_CREDENTIAL_MASTER_KEY: ${MODEL_CREDENTIAL_MASTER_KEY}
|
||||||
EMBEDDING_PROVIDER: local
|
EMBEDDING_PROVIDER: local
|
||||||
VOICE_PROVIDER: mock
|
VOICE_PROVIDER: ${VOICE_PROVIDER:-mock}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
otel-collector:
|
otel-collector:
|
||||||
@@ -92,10 +95,15 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: *server-backend-environment
|
<<: *server-backend-environment
|
||||||
HTTP_ADDR: 0.0.0.0:8080
|
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_SIGNING_SECRET: ${RTC_TOKEN_SIGNING_SECRET}
|
||||||
RTC_TOKEN_ISSUER: local
|
RTC_TOKEN_ISSUER: ${RTC_TOKEN_ISSUER:-local}
|
||||||
RTC_APP_ID: local-public-rtc-app
|
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
|
restart: unless-stopped
|
||||||
|
|
||||||
callbacks:
|
callbacks:
|
||||||
@@ -106,6 +114,7 @@ services:
|
|||||||
<<: *server-backend-environment
|
<<: *server-backend-environment
|
||||||
HTTP_ADDR: 0.0.0.0:8081
|
HTTP_ADDR: 0.0.0.0:8081
|
||||||
PROVIDER_CALLBACK_SECRET: ${PROVIDER_CALLBACK_SECRET}
|
PROVIDER_CALLBACK_SECRET: ${PROVIDER_CALLBACK_SECRET}
|
||||||
|
PLATFORM_HEALTH_CALLBACK_SECRET: ${PLATFORM_HEALTH_CALLBACK_SECRET}
|
||||||
RATE_LIMIT_RPS: "1000"
|
RATE_LIMIT_RPS: "1000"
|
||||||
RATE_LIMIT_BURST: "1500"
|
RATE_LIMIT_BURST: "1500"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -121,8 +130,21 @@ services:
|
|||||||
MOCK_PROVIDER_API_KEY: ${MOCK_PROVIDER_API_KEY}
|
MOCK_PROVIDER_API_KEY: ${MOCK_PROVIDER_API_KEY}
|
||||||
ALERT_WEBHOOK_URL: http://mock-alert-receiver:8084/webhooks/alerts
|
ALERT_WEBHOOK_URL: http://mock-alert-receiver:8084/webhooks/alerts
|
||||||
ALERT_WEBHOOK_SECRET: ${ALERT_WEBHOOK_SECRET}
|
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
|
CONTENT_SAFETY_PROVIDER: local
|
||||||
|
volumes:
|
||||||
|
- /etc/data-terminal-cloud/voice-runtime:/run/secrets/voice-runtime:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
mock-provider:
|
mock-provider:
|
||||||
@@ -159,7 +181,7 @@ services:
|
|||||||
gateway:
|
gateway:
|
||||||
image: nginx:1.30-alpine
|
image: nginx:1.30-alpine
|
||||||
ports:
|
ports:
|
||||||
- "80:443"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
volumes:
|
volumes:
|
||||||
- ./deploy/server/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./deploy/server/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
|||||||
Executable
+391
@@ -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
|
||||||
Executable
+21
@@ -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"
|
||||||
Executable
+48
@@ -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"
|
||||||
Executable
+31
@@ -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"
|
||||||
Executable
+36
@@ -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"
|
||||||
@@ -44,6 +44,14 @@ server {
|
|||||||
proxy_buffering off;
|
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/ {
|
location /callbacks/ {
|
||||||
proxy_pass $callbacks_backend;
|
proxy_pass $callbacks_backend;
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
Executable
+158
@@ -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"
|
||||||
@@ -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()
|
||||||
Executable
+106
@@ -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"
|
||||||
@@ -32,7 +32,10 @@ server {
|
|||||||
add_header X-Frame-Options "DENY" always;
|
add_header X-Frame-Options "DENY" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" 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 {
|
location = /runtime-config.js {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 360 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -0,0 +1,59 @@
|
|||||||
|
# 家庭陪伴门户 v0.74 设计 QA
|
||||||
|
|
||||||
|
- 功能与页面规格:`docs/customer-portal-v074-page-spec.md`
|
||||||
|
- 设计稿源:`.design-evidence/v074/design/*.png`
|
||||||
|
- 实际页面截图:`.design-evidence/v074/implementation/*.png`
|
||||||
|
- 同屏对比证据:`.design-evidence/v074/comparison/*.png`
|
||||||
|
- 统一视口:1440 × 1024 CSS px,device scale factor 1
|
||||||
|
- 验收状态:本地所有者账号、合成数据一号家庭、2026-08-30、每页默认业务状态
|
||||||
|
|
||||||
|
## 页面验收矩阵
|
||||||
|
|
||||||
|
| 页面 | 核心功能 | 设计/实现对比 | 结果 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 首页 | 长者周记、心情线索、关怀提醒、最近动态 | `comparison/home.png` | 通过 |
|
||||||
|
| 聊天记录 | 时间轴、AI 摘要、关键词、故事候选、下次话题、模型来源 | `comparison/conversations.png` | 通过 |
|
||||||
|
| 故事与记忆 | 待审故事、审核入口、已确认记忆和搜索 | `comparison/stories.png` | 通过 |
|
||||||
|
| 长者资料 | 头像、长期资料、交流节奏、兴趣和授权入口 | `comparison/subjects.png` | 通过 |
|
||||||
|
| 日程与提醒 | 今日陪伴动态、待处理事项、近期状态汇总 | `comparison/messages.png` | 通过 |
|
||||||
|
| 家庭管理 | 家庭切换、成员、角色、邀请、家庭资料和动态 | `comparison/members.png` | 通过 |
|
||||||
|
| 陪伴设备 | 绑定、在线状态、固件、诊断和配置 | `comparison/devices.png` | 通过 |
|
||||||
|
| 内容分享 | 资源级分享、接收人、权限、期限和撤销 | `comparison/sharing.png` | 通过 |
|
||||||
|
| 数据与授权 | 数据盘点、更正、撤回、私有导出和主体删除 | `comparison/privacy.png` | 通过 |
|
||||||
|
| 服务用量 | 聊天时长、活跃日、热力图、设备与最近通话 | `comparison/billing.png` | 通过 |
|
||||||
|
| 大模型管理 | 模型配置、连接测试、激活、移除和密钥说明 | `comparison/models.png` | 通过 |
|
||||||
|
| 设置与权限 | 身份、家庭角色和 15 项完整权限矩阵 | `comparison/profile.png` | 通过 |
|
||||||
|
|
||||||
|
## 必检视觉项
|
||||||
|
|
||||||
|
- 页面标题与家庭标签紧邻,标题底部到家庭上下文栏的间距为 0;内容区统一 16px 节奏。
|
||||||
|
- 侧栏、页头、家庭 tab、卡片边框、圆角、按钮高度和表单控件使用同一套 v0.74 token。
|
||||||
|
- 1440px 视口下 12 个页面均无页面级横向溢出,无裁切的主要按钮或正文。
|
||||||
|
- 家庭管理重新出现在一级导航,使用可直接点击的家庭 tab,不再显示“客户组织”。
|
||||||
|
- 日程与提醒不再使用大段空白消息头;隐私空状态压缩到 150px;故事页长者筛选保持单行。
|
||||||
|
- 禁用按钮统一 `cursor: wait`;长者头像上传可显示本地预览。
|
||||||
|
|
||||||
|
## 功能检查
|
||||||
|
|
||||||
|
- 12 个一级/二级导航均能进入对应页面。
|
||||||
|
- 新增长者头像上传与预览通过。
|
||||||
|
- 接受家庭邀请、邀请新成员、绑定设备、新建分享、资料更正、添加大模型、故事详情 7 个非破坏性弹窗打开/关闭通过。
|
||||||
|
- 聊天记录选中一条记录后,AI 总结区能进入真实加载/结果状态。
|
||||||
|
- 所有采样到的禁用按钮均显示等待光标。
|
||||||
|
- 浏览器控制台错误、非预期 HTTP 4xx/5xx、失败请求均为 0。
|
||||||
|
|
||||||
|
## 对比迭代
|
||||||
|
|
||||||
|
1. 修复家庭管理入口缺失,统一页面名称与客户视角的信息架构。
|
||||||
|
2. 移除工作区旧的大空白标题层,合并家庭 tab、实时状态、刷新和页面动作。
|
||||||
|
3. 故事页长者筛选从第二行收回同一上下文栏,并为故事卡补充“开始审核”。
|
||||||
|
4. 日程与提醒重构为今日动态、家人待办和近期汇总,不再堆叠无意义消息。
|
||||||
|
5. 设置页补齐家庭上下文,并集中列出 15 项角色权限。
|
||||||
|
6. 隐私页压缩空状态;用量页日期范围校准为 2025-09 至 2026-08。
|
||||||
|
7. 设计稿与实际页以相同 1440 × 1024 视口逐页同屏复核。
|
||||||
|
|
||||||
|
## 最终结论
|
||||||
|
|
||||||
|
没有遗留的 P0、P1 或 P2 视觉/交互问题。设计稿中的演示故事、已配置模型和已有分享,与本地数据库的空状态属于业务数据差异;页面结构、视觉系统和核心操作保持一致。
|
||||||
|
|
||||||
|
final result: passed
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
release/
|
||||||
|
*.log
|
||||||
@@ -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)。
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+4930
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 }> }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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", "尚未连接");
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
@@ -17,11 +17,17 @@
|
|||||||
| 故事修订 | 候选故事、人工正文、原因、强ETag | 新增人工修订版本并复制来源关系,不覆盖模型版本;终态故事禁止再改 | 新的`story_revision`和`IN_REVIEW`状态 |
|
| 故事修订 | 候选故事、人工正文、原因、强ETag | 新增人工修订版本并复制来源关系,不覆盖模型版本;终态故事禁止再改 | 新的`story_revision`和`IN_REVIEW`状态 |
|
||||||
| 故事确认 | 当前故事、审核原因、强ETag | 必须有原话来源、具备审核权限且个性化记忆授权当前有效;确认故事及候选事实 | `CONFIRMED`故事和`PENDING`记忆 |
|
| 故事确认 | 当前故事、审核原因、强ETag | 必须有原话来源、具备审核权限且个性化记忆授权当前有效;确认故事及候选事实 | `CONFIRMED`故事和`PENDING`记忆 |
|
||||||
| 故事拒绝 | 当前故事、审核原因、强ETag | 拒绝故事及仍为候选状态的事实,不创建记忆 | `REJECTED`故事 |
|
| 故事拒绝 | 当前故事、审核原因、强ETag | 拒绝故事及仍为候选状态的事实,不创建记忆 | `REJECTED`故事 |
|
||||||
|
| 事实候选细粒度审核 | 单条事实、修订内容、敏感级别、问题类型、原因、强ETag | 事实可独立编辑、确认、拒绝或停用;每次变化保存不可覆盖版本,不要求整篇故事一起处理 | 新的`fact_candidate_revision`与质量反馈 |
|
||||||
| 记忆索引 | 待索引记忆版本 | 再次校验记忆为当前版本、状态有效且授权仍有效;不满足条件时清空向量并标记`STALE` | `ACTIVE`或`STALE`索引 |
|
| 记忆索引 | 待索引记忆版本 | 再次校验记忆为当前版本、状态有效且授权仍有效;不满足条件时清空向量并标记`STALE` | `ACTIVE`或`STALE`索引 |
|
||||||
| 生产向量化 | 普通敏感级别的当前记忆正文或新通话检索语句 | API与Worker使用同一方舟Embedding模型;请求1536维float向量并校验维度和有限数值;网络、超时、408/425/429/5xx可靠重试 | `ark:<model-id>`索引或固定边界查询向量 |
|
| 生产向量化 | 普通敏感级别的当前记忆正文或新通话检索语句 | API与Worker使用同一方舟Embedding模型;请求1536维float向量并校验维度和有限数值;网络、超时、408/425/429/5xx可靠重试 | `ark:<model-id>`索引或固定边界查询向量 |
|
||||||
| 记忆修订与停用 | 当前记忆、正文或状态、原因、强ETag | 正文变化创建新版本并立即使旧索引失效;停用后不再召回;重新启用必须重新校验授权并重新索引;`PENDING_DELETE/SUPERSEDED/DELETED`均禁止修改 | 新记忆版本或状态 |
|
| 记忆修订与停用 | 当前记忆、正文或状态、原因、强ETag | 正文变化创建新版本并立即使旧索引失效;停用后不再召回;重新启用必须重新校验授权并重新索引;`PENDING_DELETE/SUPERSEDED/DELETED`均禁止修改 | 新记忆版本或状态 |
|
||||||
| 记忆永久删除 | 当前记忆、删除原因、强ETag | 事务内先进入`PENDING_DELETE`并清空活动向量,使新通话立即停止召回;该状态不可通过修改接口恢复;Outbox后台把索引标记删除、清除所有记忆版本正文并记录完成审计 | `DELETED`记忆和索引 |
|
| 记忆永久删除 | 当前记忆、删除原因、强ETag | 事务内先进入`PENDING_DELETE`并清空活动向量,使新通话立即停止召回;该状态不可通过修改接口恢复;Outbox后台把索引标记删除、清除所有记忆版本正文并记录完成审计 | `DELETED`记忆和索引 |
|
||||||
| 新通话记忆检索 | 服务端已确定的客户组织、长者和通话目标 | SQL同时限制租户、长者、当前授权、活动记忆、当前版本、普通敏感级别、有效期和活动索引;最多5条 | 冻结到`context_snapshot.memory_versions`的版本引用和最小背景摘要 |
|
| 新通话记忆检索 | 服务端已确定的客户组织、长者和通话目标 | 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`索引 |
|
| 授权撤回 | `personalized_memory`授权 | 在同一事务内停用该长者的活动记忆,清空活动/待处理索引向量并标记失效 | `DISABLED`记忆、`STALE`索引 |
|
||||||
|
|
||||||
## 3. 业务流程
|
## 3. 业务流程
|
||||||
@@ -51,6 +57,10 @@ flowchart TD
|
|||||||
S --> U[创建下一次通话]
|
S --> U[创建下一次通话]
|
||||||
U --> V[数据库固定边界检索]
|
U --> V[数据库固定边界检索]
|
||||||
V --> W[冻结记忆版本和背景摘要]
|
V --> W[冻结记忆版本和背景摘要]
|
||||||
|
S --> X{供应商记忆功能已验证并开启?}
|
||||||
|
X -- 是 --> Y[可靠同步外部引用]
|
||||||
|
X -- 否 --> Z[只使用数据库检索]
|
||||||
|
Y --> AA[停用/撤回/删除时外部删除对账]
|
||||||
```
|
```
|
||||||
|
|
||||||
供应商停止事件和最终转写事件可能存在先后竞争。停止事件会发布内容整理待办;如果话轮尚未到达,处理器返回可重试错误,由Outbox退避重试,避免把“暂无话轮”错误当成永久空内容。
|
供应商停止事件和最终转写事件可能存在先后竞争。停止事件会发布内容整理待办;如果话轮尚未到达,处理器返回可重试错误,由Outbox退避重试,避免把“暂无话轮”错误当成永久空内容。
|
||||||
@@ -59,6 +69,10 @@ flowchart TD
|
|||||||
|
|
||||||
记忆索引也先在无事务状态读取当前版本和授权,事务外生成Embedding,返回后才锁定记忆与索引行重新校验;修订、停用或撤回授权发生在模型调用期间时,旧结果不会被激活。新通话创建前仅在设备绑定、语音授权和个性化记忆授权均可用时生成查询向量;事务内仍以固定租户、长者、授权、状态和敏感级别条件检索。Embedding临时不可用时,本次通话以`pgvector-degraded-v1`记录降级且不注入记忆,不阻断基本语音通话。普通顺序幂等重放和同键正文冲突会先读取已完成幂等记录,不重复调用Embedding;并发首请求仍由数据库唯一键保证只创建一个通话。
|
记忆索引也先在无事务状态读取当前版本和授权,事务外生成Embedding,返回后才锁定记忆与索引行重新校验;修订、停用或撤回授权发生在模型调用期间时,旧结果不会被激活。新通话创建前仅在设备绑定、语音授权和个性化记忆授权均可用时生成查询向量;事务内仍以固定租户、长者、授权、状态和敏感级别条件检索。Embedding临时不可用时,本次通话以`pgvector-degraded-v1`记录降级且不注入记忆,不阻断基本语音通话。普通顺序幂等重放和同键正文冲突会先读取已完成幂等记录,不重复调用Embedding;并发首请求仍由数据库唯一键保证只创建一个通话。
|
||||||
|
|
||||||
|
通话中工具令牌由平台在启动智能体时签发,令牌只包含通话编号、固定供应商任务编号和过期时间。工具接口先重新读取权威通话、任务、租户和长者关系,再执行与通话前召回相同的数据库边界;请求正文没有租户、家庭或长者字段。查询摘要、结果数量、延迟和降级状态进入最小审计,原始查询和记忆正文不进入普通日志。供应商原生记忆是可选增强,不取代该工具或通话前数据库召回;外部服务失败时,本地主数据和本地召回仍可继续工作。
|
||||||
|
|
||||||
|
最近通话摘要是短期连续上下文,不会自动升级为长期记忆,也不会绕过故事人工审核。平台在每次开房时重新检查并冻结当前授权,只读取同一客户组织、同一长者、已完成通话的最新就绪摘要;隐私删除或授权撤回后的摘要不会继续进入后续通话。纯端到端豆包模式把这份冻结上下文写入`S2SConfig.ProviderParams.dialog.system_role`,级联模式继续写入`LLMConfig.SystemMessages`,两种链路使用同一上下文快照。
|
||||||
|
|
||||||
## 4. 接口设计
|
## 4. 接口设计
|
||||||
|
|
||||||
| 方法与路径 | 权限 | 并发/校验 | 说明 |
|
| 方法与路径 | 权限 | 并发/校验 | 说明 |
|
||||||
@@ -70,10 +84,13 @@ flowchart TD
|
|||||||
| `PATCH /api/v1/stories/{id}` | `story.review` | 必须`If-Match`;正文和原因必填 | 创建人工修订版本 |
|
| `PATCH /api/v1/stories/{id}` | `story.review` | 必须`If-Match`;正文和原因必填 | 创建人工修订版本 |
|
||||||
| `POST /api/v1/stories/{id}/confirm` | `story.review` | 必须`If-Match`;来源与授权门禁 | 确认故事并创建记忆 |
|
| `POST /api/v1/stories/{id}/confirm` | `story.review` | 必须`If-Match`;来源与授权门禁 | 确认故事并创建记忆 |
|
||||||
| `POST /api/v1/stories/{id}/reject` | `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/subjects/{id}/memories` | `subject.read` | 过滤已删除记录;可用`q`在固定长者边界内搜索 | 已确认记忆列表 |
|
||||||
| `GET /api/v1/memories/{id}` | `subject.read` | 返回强ETag | 当前记忆版本和索引状态 |
|
| `GET /api/v1/memories/{id}` | `subject.read` | 返回强ETag | 当前记忆版本和索引状态 |
|
||||||
| `PATCH /api/v1/memories/{id}` | `story.review` | 必须`If-Match`;正文/状态至少一项且原因必填;删除中及历史终态拒绝修改 | 修订、停用或重新启用记忆 |
|
| `PATCH /api/v1/memories/{id}` | `story.review` | 必须`If-Match`;正文/状态至少一项且原因必填;删除中及历史终态拒绝修改 | 修订、停用或重新启用记忆 |
|
||||||
| `DELETE /api/v1/memories/{id}` | `story.review` | 必须`If-Match`;删除原因必填;返回202 | 可靠受理永久删除任务 |
|
| `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差异泄露资源是否存在。
|
统一冲突码包括`STORY_SOURCE_REQUIRED`、`PERSONALIZED_MEMORY_CONSENT_REQUIRED`和`STORY_REVIEW_STATE_CONFLICT`。跨租户或非家庭成员访问统一返回403,不借404差异泄露资源是否存在。
|
||||||
|
|
||||||
@@ -87,11 +104,15 @@ flowchart TD
|
|||||||
| `story_revision` | 不可覆盖的故事版本 | 故事内修订号唯一;区分模型/人工作者;保存基础版本和原因 |
|
| `story_revision` | 不可覆盖的故事版本 | 故事内修订号唯一;区分模型/人工作者;保存基础版本和原因 |
|
||||||
| `story_source` | 故事版本到原话的追溯关系 | 版本、话轮和文本偏移组合唯一 |
|
| `story_source` | 故事版本到原话的追溯关系 | 版本、话轮和文本偏移组合唯一 |
|
||||||
| `fact_candidate` | 可审核事实候选 | 区分长者陈述与模型推断;保存源话轮、敏感级别和状态 |
|
| `fact_candidate` | 可审核事实候选 | 区分长者陈述与模型推断;保存源话轮、敏感级别和状态 |
|
||||||
|
| `fact_candidate_revision` | 事实不可覆盖版本 | 每条事实版本号唯一;保留内容、状态、原因和标准问题类型 |
|
||||||
| `curated_memory` | 长期记忆聚合根 | 当前版本、状态、有效期、敏感级别和强版本号 |
|
| `curated_memory` | 长期记忆聚合根 | 当前版本、状态、有效期、敏感级别和强版本号 |
|
||||||
| `memory_version` | 不可覆盖的记忆版本 | 记忆内版本号唯一;确认人、确认时间、原因、来源和内容摘要 |
|
| `memory_version` | 不可覆盖的记忆版本 | 记忆内版本号唯一;确认人、确认时间、原因、来源和内容摘要 |
|
||||||
| `memory_index_entry` | 记忆检索索引状态 | 记忆版本和索引类型唯一;向量维度1536;状态可失效和删除 |
|
| `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. 安全、隐私与可观测性
|
## 6. 安全、隐私与可观测性
|
||||||
|
|
||||||
@@ -102,17 +123,21 @@ V8迁移已经在独立空数据库执行`up → down -all → up`,最终为`8
|
|||||||
- 只有冻结快照允许保存字幕时才落`conversation_turn`;故事整理和个性化记忆分别使用独立授权门禁。
|
- 只有冻结快照允许保存字幕时才落`conversation_turn`;故事整理和个性化记忆分别使用独立授权门禁。
|
||||||
- 故事、事实和记忆均保留租户、长者和来源边界;所有网页接口从OIDC主体重新解析家庭成员关系,不信任客户端传入租户。
|
- 故事、事实和记忆均保留租户、长者和来源边界;所有网页接口从OIDC主体重新解析家庭成员关系,不信任客户端传入租户。
|
||||||
- 人工修订、确认、拒绝和授权撤回记录审计事件;写操作使用强ETag防止并发覆盖。
|
- 人工修订、确认、拒绝和授权撤回记录审计事件;写操作使用强ETag防止并发覆盖。
|
||||||
- 通话创建已接入内部数据库召回并冻结实际使用的记忆版本;调用方不能传入自由租户或长者过滤条件。当前尚未开放通话中工具检索接口。
|
- 通话创建和通话中工具均使用服务端固定的租户、长者、授权和版本边界;供应商或模型不能传入自由租户、家庭或长者过滤条件。
|
||||||
|
- 供应商原生记忆默认关闭,只有完成真实账号的数据条款、隔离、同步、删除和容量验证后才配置`PROVIDER_MEMORY_MODE=managed_http`;数据库仍是唯一主数据。
|
||||||
|
|
||||||
## 7. 验证结果
|
## 7. 验证结果
|
||||||
|
|
||||||
- Go全仓测试通过,OpenAPI 0.10.0可生成TypeScript类型,React生产构建通过。
|
- 新增迁移已在隔离数据库完成升库与数据库组合测试;OpenAPI 0.70.0可生成TypeScript类型,React类型检查和生产构建通过。
|
||||||
- 本地内容策略单元测试覆盖验证码、银行卡号、高敏感医疗词和普通生活经历。
|
- 本地内容策略单元测试覆盖验证码、银行卡号、高敏感医疗词和普通生活经历。
|
||||||
- 方舟兼容适配器轻量验证覆盖Bearer鉴权、请求路径、结构化响应校验、HTTP 429重试分类;真实PostgreSQL组合验证模型调用期间不持有通话行锁,模型返回后正常生成候选,敏感话轮不会调用外部模型。
|
- 方舟兼容适配器轻量验证覆盖Bearer鉴权、请求路径、结构化响应校验、HTTP 429重试分类;真实PostgreSQL组合验证模型调用期间不持有通话行锁,模型返回后正常生成候选,敏感话轮不会调用外部模型。
|
||||||
- 方舟Embedding适配器轻量验证覆盖Bearer鉴权、`/embeddings`请求、模型/维度参数、1536维结果和错误维度拒绝;真实PostgreSQL组合验证向量生成期间不持有索引行锁,返回后可激活索引。
|
- 方舟Embedding适配器轻量验证覆盖Bearer鉴权、`/embeddings`请求、模型/维度参数、1536维结果和错误维度拒绝;真实PostgreSQL组合验证向量生成期间不持有索引行锁,返回后可激活索引。
|
||||||
- 端到端通话冒烟验证最终话轮去重、两类整理结果、故事来源、人工确认前零记忆、确认后记忆生成及索引生效;下一次通话冻结1个记忆版本。
|
- 端到端通话冒烟验证最终话轮去重、两类整理结果、故事来源、人工确认前零记忆、确认后记忆生成及索引生效;下一次通话冻结1个记忆版本。
|
||||||
- 记忆修订生成第2版并重新索引;停用后索引立即失效且新通话召回0条;重新启用再次进入索引流程。
|
- 记忆修订生成第2版并重新索引;停用后索引立即失效且新通话召回0条;重新启用再次进入索引流程。
|
||||||
- 永久删除验证覆盖`PENDING_DELETE → DELETED`、删除中重新激活稳定拒绝、向量清理、数据库正文替换为删除标记和删除后新通话召回0条。
|
- 永久删除验证覆盖`PENDING_DELETE → DELETED`、删除中重新激活稳定拒绝、向量清理、数据库正文替换为删除标记和删除后新通话召回0条。
|
||||||
|
- 通话中记忆工具验证覆盖短期令牌签名、固定通话/任务边界、跨长者不可选、结果长度上限、实际使用追踪和Embedding异常降级。
|
||||||
|
- 供应商记忆适配器验证覆盖Bearer鉴权、幂等同步、旧版本删除、单条删除、主体隐私删除前对账和失败状态保留;当前只使用模拟HTTP/内存适配器,真实供应商账号仍待外部验收。
|
||||||
|
- 内容质量反馈验证覆盖九类问题、事实不可覆盖版本、按模型/提示词聚合和跨租户拒绝;维护端只展示汇总,不显示故事、事实或记忆正文。
|
||||||
- React故事与记忆页以页面内管理区提供正文修订、启停和永久删除;未修改正文、空原因、未确认删除均不能提交,删除中和历史终态只读,不再依赖浏览器弹窗。
|
- React故事与记忆页以页面内管理区提供正文修订、启停和永久删除;未修改正文、空原因、未确认删除均不能提交,删除中和历史终态只读,不再依赖浏览器弹窗。
|
||||||
- OIDC、SaaS、设备、成员和通话五组冒烟全部通过;测试结束后内容表残留为0,Outbox无待处理、处理中或死信事件。
|
- OIDC、SaaS、设备、成员和通话五组冒烟全部通过;测试结束后内容表残留为0,Outbox无待处理、处理中或死信事件。
|
||||||
- 当前方舟内容与Embedding协议均使用本地模拟端点验证,尚未使用业务方真实API Key核验模型效果、召回质量、额度和数据条款;真实语音供应商与真实设备也仍待验收。
|
- 当前方舟内容与Embedding协议均使用本地模拟端点验证,尚未使用业务方真实API Key核验模型效果、召回质量、额度和数据条款;真实语音供应商与真实设备也仍待验收。
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 聊天总结模型厂商接入
|
||||||
|
|
||||||
|
聊天总结任务不绑定某个模型。管理员在“大模型管理”中选择厂商、填写对应 API Key 与模型 ID、完成连接测试并激活后,Worker 会在下一条任务执行时读取当前激活配置。
|
||||||
|
|
||||||
|
## 已适配厂商
|
||||||
|
|
||||||
|
| 页面分类 | `provider` | 默认 Base URL | 需要填写 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 火山方舟 / 豆包 | `ark` | `https://ark.cn-beijing.volces.com/api/v3` | API Key、模型 ID 或 Endpoint ID |
|
||||||
|
| 阿里云百炼 / 千问 | `dashscope` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | 同地域 API Key、模型 ID |
|
||||||
|
| DeepSeek | `deepseek` | `https://api.deepseek.com` | API Key、模型 ID |
|
||||||
|
| 月之暗面 / Kimi | `moonshot` | `https://api.moonshot.cn/v1` | API Key、模型 ID |
|
||||||
|
| 智谱 AI / GLM | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | API Key、模型 ID |
|
||||||
|
| 百度千帆 / 文心 | `qianfan` | `https://qianfan.baidubce.com/v2` | Bearer Token、模型 ID |
|
||||||
|
| 腾讯混元 | `hunyuan` | `https://api.hunyuan.cloud.tencent.com/v1` | API Key、模型 ID |
|
||||||
|
| MiniMax | `minimax` | `https://api.minimaxi.com/v1` | API Key、模型 ID |
|
||||||
|
| 百川智能 | `baichuan` | `https://api.baichuan-ai.com/v1` | API Key、模型 ID |
|
||||||
|
| 硅基流动 | `siliconflow` | `https://api.siliconflow.cn/v1` | API Key、模型 ID |
|
||||||
|
| 本地 Ollama | `ollama` | `http://ollama:11434/v1` | 本地模型 ID;密钥使用占位值 |
|
||||||
|
| 自定义兼容服务 | `openai_compatible` | 管理员填写 | HTTPS Base URL、API Key、模型 ID |
|
||||||
|
|
||||||
|
所有云服务使用 Bearer Token 鉴权和 `/chat/completions` 请求。适配器只对官方已明确支持或项目已验证的厂商发送 `response_format`;其他厂商通过严格提示、JSON 提取和响应校验取得结构化结果,避免某些兼容接口因不认识 JSON Mode 参数而拒绝请求。带 `<think>` 思考包装的返回也会在解析前清理。
|
||||||
|
|
||||||
|
## 使用流程
|
||||||
|
|
||||||
|
1. 选择厂商,页面会预填官方 Base URL 和常用模型名。
|
||||||
|
2. 按厂商控制台填写 API Key 与准确模型 ID。使用地域或业务空间专属域名时,可修改 Base URL。
|
||||||
|
3. 保存后执行“测试连接”。测试内容是固定的无长者数据文本。
|
||||||
|
4. 测试通过后设为当前模型。下一条聊天总结任务立即使用,不需要重启 Worker。
|
||||||
|
|
||||||
|
API Key 通过 AES-256-GCM 加密保存,读取接口仅返回末四位提示。连接测试、激活与移除均写入审计日志。
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# 安心陪伴客户门户 v0.74 页面功能与设计基准
|
||||||
|
|
||||||
|
日期:2026-08-30
|
||||||
|
设计方向:沿用已确认的“回忆时间轴”方案,以长者、聊天、心情与家庭协作为核心;暖白纸张底色、雾蓝主操作、陶土色提醒、低阴影和清晰留白。
|
||||||
|
|
||||||
|
## 全局信息架构
|
||||||
|
|
||||||
|
客户门户只保留家庭用户真正需要的 12 个一级任务。当前家庭用页首标签直接切换;家庭成员、角色和邀请恢复为独立的“家庭管理”页面。客户门户不再出现“客户组织”概念。
|
||||||
|
|
||||||
|
全局页面结构固定为:
|
||||||
|
|
||||||
|
1. 68px 顶部栏:品牌、全局搜索、提醒、账号。
|
||||||
|
2. 228px 左侧导航:围绕长者陪伴任务排列。
|
||||||
|
3. 统一页面首部:标题、用途说明、家庭权限状态。
|
||||||
|
4. 统一家庭上下文栏:家庭 Tab、添加家庭、实时状态、刷新和页面主操作。
|
||||||
|
5. 页面内容:不再使用包住整页的大卡片;只在独立对象或需要分组时使用边框容器。
|
||||||
|
|
||||||
|
## 逐页功能总结
|
||||||
|
|
||||||
|
| 页面 | 用户要解决的问题 | 主要功能 | 首屏重点 | 关键状态 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 首页 | 最近有没有陪伴、长者心情如何、哪里需要家人关注 | 长者切换、7 天聊天时长、最近聊天摘要、心情线索、关注提醒、最近动态 | 每位长者最近一次聊天和心情变化 | 无聊天、设备离线、待关注 |
|
||||||
|
| 聊天记录 | 找到某次聊天并理解聊天内容 | 按长者/日期/状态筛选、AI 摘要、关键词、故事候选、下次话题、录音授权 | 时间轴和当前聊天 AI 总结 | 总结中、总结失败、无授权 |
|
||||||
|
| 故事与记忆 | 审核模型整理的故事,形成可信长期记忆 | 故事候选审核、修订、确认/拒绝、记忆检索和停用 | 待审核故事与来源聊天 | 空状态、审核中、已确认 |
|
||||||
|
| 长者资料 | 让豆包知道如何更适合地与这位长者聊天 | 头像、背景、兴趣、优先/回避话题、交流节奏、长期陪伴方向、授权 | 长者卡片和资料完整度 | 头像上传、只读、资料不完整 |
|
||||||
|
| 日程与提醒 | 知道什么时候聊、哪些事情需要家人处理 | 陪伴计划、提醒时间、设备/故事/隐私待办、事件状态 | 今日安排和待办 | 已完成、即将开始、已错过 |
|
||||||
|
| 家庭管理 | 管理家庭空间和可以协作的家人 | 家庭 Tab、成员、邀请、角色、所有权、家庭名称 | 家庭成员与角色 | 待接受邀请、只读、危险操作确认 |
|
||||||
|
| 陪伴设备 | 确认设备能否正常陪伴 | 绑定、在线状态、固件、电量/音量、诊断、解除绑定 | 设备健康和所属长者 | 离线、待同步、诊断中 |
|
||||||
|
| 内容分享 | 把指定故事/文字/录音安全分享给指定家人 | 资源选择、对象、权限、有效期、撤销 | 当前有效分享 | 无分享、即将过期、已撤销 |
|
||||||
|
| 数据与授权 | 查看和控制长者数据如何被使用 | 五类分项授权、数据访问/更正/撤回/删除、私有导出 | 授权清单,不以按钮堆叠呈现 | 生效、处理中、已撤回 |
|
||||||
|
| 服务用量 | 理解聊天使用情况,而不是看无意义设备统计 | 时长指标、活跃日、长者/设备筛选、热力图、最近通话 | 最近一年聊天活动 | 无数据、费用未核价 |
|
||||||
|
| 大模型管理 | 选择哪个模型用于聊天总结 | 新增配置、连通性测试、激活、停用、参数和生成历史 | 当前激活模型及健康状态 | 未配置、测试失败、已激活 |
|
||||||
|
| 设置与权限 | 理解当前账号能做什么 | 账号资料、家庭角色、15 项权限矩阵、敏感录音独立授权说明 | 当前身份和权限全表 | 无家庭、权限不足 |
|
||||||
|
|
||||||
|
## 统一视觉规格
|
||||||
|
|
||||||
|
- 桌面基准:1440 × 1024;内容最大宽度 1360px。
|
||||||
|
- 页面左右 padding:桌面 28px;平板 20px;手机 14px。
|
||||||
|
- 页面首部上下 padding:24px / 18px;与家庭上下文栏间距 16px。
|
||||||
|
- 家庭上下文栏高度不固定,最小 54px;禁止通过空白占位制造层级。
|
||||||
|
- 页面 section 间距 16px;对象卡片 padding 20–24px;列表行 14–16px。
|
||||||
|
- 字号:页面标题 28px;区块标题 20px;正文 14px;辅助信息 12px。
|
||||||
|
- 圆角:页面对象 14px、按钮/输入框 9px、状态胶囊 999px。
|
||||||
|
- 主色 `#315D9B`;强调 `#C45F3B`;成功 `#5F8F69`;底色 `#FBFAF7`;边线 `#E6E0D8`。
|
||||||
|
- 禁用按钮必须不可点击且鼠标为等待光标;加载按钮显示明确进行中动词。
|
||||||
|
- 所有日期基于 Asia/Shanghai,设计数据锚点为 2026-08-30。
|
||||||
|
|
||||||
|
## 设计验收规则
|
||||||
|
|
||||||
|
每个页面必须在 1440 × 1024 同一视口完成“设计基准截图 / 实际页面截图”对照。P0–P2 的布局、padding、信息层级、文字、色彩、状态与响应式差异必须修复;最终记录写入项目根目录 `design-qa.md`。
|
||||||
@@ -437,9 +437,9 @@
|
|||||||
|
|
||||||
## 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`。
|
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)中;本计划只定义执行顺序和退出条件,不用计划状态替代测试证据。
|
实际完成证据与未完成项持续维护在[开发状态与门禁记录](development-status.md)中;本计划只定义执行顺序和退出条件,不用计划状态替代测试证据。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 开发状态与门禁记录
|
# 开发状态与门禁记录
|
||||||
|
|
||||||
> 更新日期:2026-08-25
|
> 更新日期:2026-08-26
|
||||||
> 当前接口契约版本:0.64.0
|
> 当前接口契约版本:0.68.0
|
||||||
> 当前主里程碑:M8目标环境联调、试点与上线验收(外部条件待提供)
|
> 当前主里程碑:M8目标环境联调、试点与上线验收(外部条件待提供)
|
||||||
|
|
||||||
## 阶段状态
|
## 阶段状态
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
| 第五阶段:SaaS核心业务开发 | 本地业务闭环完成 | 已完成家庭成员邀请与角色协作、长者、稳定事实、五类分项授权、设备读取/配置/绑定、可靠命令、签名固件灰度/暂停/回滚和逐设备升级进度、通话查询、双轨录音状态/授权/播放下载的数据库、服务、HTTP契约与React工作台;服务端隔离、ETag、审计、纵向烟测及Chrome/Edge兼容检查均通过 | 固件生产签名设施、macOS Safari和类生产容量仍待外部环境验证 |
|
| 第五阶段:SaaS核心业务开发 | 本地业务闭环完成 | 已完成家庭成员邀请与角色协作、长者、稳定事实、五类分项授权、设备读取/配置/绑定、可靠命令、签名固件灰度/暂停/回滚和逐设备升级进度、通话查询、双轨录音状态/授权/播放下载的数据库、服务、HTTP契约与React工作台;服务端隔离、ETag、审计、纵向烟测及Chrome/Edge兼容检查均通过 | 固件生产签名设施、macOS Safari和类生产容量仍待外部环境验证 |
|
||||||
| 第六阶段:Agent内容与记忆闭环 | 本地开发闭环完成,生产模型协议已接入 | 最终话轮、可切换本地/火山方舟内容生成器与Embedding、事务外模型调用、摘要、开放话题、故事/事实候选、人工版本审核、授权门禁、已确认记忆、索引失效、固定边界召回及React审核页面均已实现;端到端烟测通过 | 真实方舟API Key、内容效果与召回质量验收、模型评测和真实内容安全策略待完成 |
|
| 第六阶段:Agent内容与记忆闭环 | 本地开发闭环完成,生产模型协议已接入 | 最终话轮、可切换本地/火山方舟内容生成器与Embedding、事务外模型调用、摘要、开放话题、故事/事实候选、人工版本审核、授权门禁、已确认记忆、索引失效、固定边界召回及React审核页面均已实现;端到端烟测通过 | 真实方舟API Key、内容效果与召回质量验收、模型评测和真实内容安全策略待完成 |
|
||||||
| 第七阶段:生产加固 | 本地证据持续收敛 | 记忆可靠删除、隐私访问/导出/删除、运营配置灰度、用量费用额度、告警处置闭环、可重试HMAC告警Webhook及不污染正式告警的接收器联通测试、三类Grafana仪表盘、证书到期监测、OIDC/方舟/RTC外部基础设施探针、本地密钥轮换、生产配置拒绝启动门禁、全部本地专项容量、隔离恢复、供应商故障、安全扫描及Chrome/Edge兼容均通过;主要业务核心包已达到70%覆盖率,供应商客户端达到关键分支85%目标 | 全仓覆盖率及HTTP公共层仍低于需求目标;真实告警网关和值班组映射、生产等价容量、云密钥托管和Safari仍待完成 |
|
| 第七阶段:生产加固 | 本地证据持续收敛 | 记忆可靠删除、隐私访问/导出/删除、运营配置灰度、用量费用额度、告警处置闭环、可重试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 |
|
| 媒体清单容量 | 10批×1000分片准确落库和关联;批量SQL优化后平均0.12秒、p95/最大0.16秒,API容器内存峰值15.8MiB |
|
||||||
| 隐私删除并发 | 100个并发受理全部成功、HTTP失败率0%、提交p95 385.15ms;11.58秒内600个子任务、100个墓碑和100个主体冻结全部对账 |
|
| 隐私删除并发 | 100个并发受理全部成功、HTTP失败率0%、提交p95 385.15ms;11.58秒内600个子任务、100个墓碑和100个主体冻结全部对账 |
|
||||||
| PostgreSQL隔离恢复 | 实际`pg_dump`/`pg_restore`到随机隔离库,迁移`22|false`、66张表、190个外键和关键计数一致;本地约5.14秒,临时库/备份已清理 |
|
| 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权威计数不变 |
|
| 进程与Redis恢复 | API/Callbacks/Worker重启恢复;停机期间Outbox仅执行一次;Redis 14个热状态键清空后由签名设备请求重建,PostgreSQL权威计数不变 |
|
||||||
| 供应商故障恢复 | V23持久化按租户/供应商的可用状态;连续临时失败暂停新启动但停止任务继续尝试;演练确认暂停后新外呼0、停止尝试2次、历史接口200、CRITICAL告警出现,恢复后全部收敛;V23单版本回退/重升通过 |
|
| 供应商故障恢复 | V23持久化按租户/供应商的可用状态;连续临时失败暂停新启动但停止任务继续尝试;演练确认暂停后新外呼0、停止尝试2次、历史接口200、CRITICAL告警出现,恢复后全部收敛;V23单版本回退/重升通过 |
|
||||||
| 安全专项 | Go 1.26.6容器`govulncheck`可达漏洞0、npm高危审计0、最终镜像HIGH/CRITICAL已修复漏洞0、Gitleaks无泄漏;HTTP认证/授权/注入式输入/1MiB正文/回调签名/Nonce重放/安全头/日志过滤烟测通过 |
|
| 安全专项 | 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项缺口,临时文件已清理。 |
|
| 真实终端RTC/语音供应商精简矩阵与发布门禁 | 新增`real-device-rtc-capture-v1`、`real-device-rtc-conformance-report-v1`和`cmd/rtc-conformance`。真实报告以至少3台候选终端执行6通:`s2s_pure/s2s_hybrid/cascade`各正常完成1通,再覆盖凭证续签、网络切换恢复和受控停止;每通必须证明房间/用户/任务绑定、双向媒体、固定状态序列、供应商启停、最终字幕、用量、质量通过、任务去重和资源回收。协议模拟固定为`PROTOCOL_SIMULATION/external_validation_required=true`。v3证据包新增`real_device_rtc_validation`,该门禁不可豁免且首个文件必须为合格真实报告。按低强度策略执行采集器/证据包定向测试和一条协议结构工具链冒烟:1台模拟终端1通结构结果生成`PASS/PROTOCOL_SIMULATION`并继续外部待验;证据包保留真实报告、设备/通话数量、三方式和四场景共10项缺口,临时文件已清理。 |
|
||||||
| 真实Agent生成、Embedding和内容安全质量门禁 | 新增`real-agent-models-capture-v1`、`real-agent-models-conformance-report-v1`和`cmd/agent-models-conformance`。内容生成复用现有固定10类Agent评测,并使用评测集、脱敏观测和报告重新计算防篡改;Embedding按版本化阈值覆盖精确/改写召回、跨长者隔离、撤销/敏感排除和无匹配降级六类;内容安全覆盖验证码、金融账户、提示注入、敏感医疗、正常内容和模型输出六类。真实证据还必须绑定受控提供器、账号/区域、数据条款、额度和审计引用;本地生成器、哈希向量和规则固定为`LOCAL_AGENT_TOOLCHAIN/external_validation_required=true`。v3证据包新增`real_agent_models_validation`,该门禁不可豁免。按低强度策略执行聚合器/证据包定向测试和一条重算现有模拟报告的本地工具链冒烟:既有10类模拟报告重新计算一致,两组六类结构完整,输出`PASS/LOCAL_AGENT_TOOLCHAIN`并继续外部待验;证据包保留真实报告、三提供器和12类别共16项缺口,临时文件已清理。 |
|
| 真实Agent生成、Embedding和内容安全质量门禁 | 新增`real-agent-models-capture-v1`、`real-agent-models-conformance-report-v1`和`cmd/agent-models-conformance`。内容生成复用现有固定10类Agent评测,并使用评测集、脱敏观测和报告重新计算防篡改;Embedding按版本化阈值覆盖精确/改写召回、跨长者隔离、撤销/敏感排除和无匹配降级六类;内容安全覆盖验证码、金融账户、提示注入、敏感医疗、正常内容和模型输出六类。真实证据还必须绑定受控提供器、账号/区域、数据条款、额度和审计引用;本地生成器、哈希向量和规则固定为`LOCAL_AGENT_TOOLCHAIN/external_validation_required=true`。v3证据包新增`real_agent_models_validation`,该门禁不可豁免。按低强度策略执行聚合器/证据包定向测试和一条重算现有模拟报告的本地工具链冒烟:既有10类模拟报告重新计算一致,两组六类结构完整,输出`PASS/LOCAL_AGENT_TOOLCHAIN`并继续外部待验;证据包保留真实报告、三提供器和12类别共16项缺口,临时文件已清理。 |
|
||||||
| 托管内容安全外部预检与告警 | OpenAPI 0.64.0把`content_safety_tls`纳入外部依赖查询和候选验收快照。生产Worker仅执行DNS、TCP和受信TLS握手,不发送待分类正文;结果复用依赖可用性指标、数据库脱敏状态和运营诊断页面,连续5分钟不可用进入统一CRITICAL告警,记录缺失/失败/过期均可阻断候选技术快照但不驱逐Pod。按低强度策略执行App/探针/诊断定向测试、前端类型生成/类型检查/生产构建和Prometheus规则校验。 |
|
| 托管内容安全外部预检与告警 | OpenAPI 0.64.0把`content_safety_tls`纳入外部依赖查询和候选验收快照。生产Worker仅执行DNS、TCP和受信TLS握手,不发送待分类正文;结果复用依赖可用性指标、数据库脱敏状态和运营诊断页面,连续5分钟不可用进入统一CRITICAL告警,记录缺失/失败/过期均可阻断候选技术快照但不驱逐Pod。按低强度策略执行App/探针/诊断定向测试、前端类型生成/类型检查/生产构建和Prometheus规则校验。 |
|
||||||
|
| 云端主机健康状态接入 | OpenAPI 0.65.0新增独立HMAC签名的主机健康回调、30天脱敏快照表和运营只读状态页面。五分钟周期上报覆盖公网入口、容器、备份、证书、磁盘和定时器;无签名、过期、重复检查及综合状态不一致报告被拒绝,页面超过15分钟未更新明确标记过期。该能力不替代整机、Callbacks或数据库失联时必须使用的外部探针。 |
|
||||||
|
| 云端主机健康发布门禁 | 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`。 |
|
| 阶段8外部执行轻量预检 | 新增`scripts/stage8-preflight.ps1`,以单一只读入口核对候选Git提交、远程仓库、Compose本地基线、外部配置变量名称和十类证据索引结构/状态;报告明确不读取密钥值,也不把环境变量或本地服务可用性当作验收证据。默认仅报告缺口,`-Strict`可供自动化阻断未就绪交接;当前已建立本地`main`候选提交,本地运行基线就绪且索引结构有效,仍缺远程仓库,样例构建摘要为占位值,十类外部证据均为`PENDING`。 |
|
||||||
| 候选构建身份贯通 | Compose新增统一候选构建参数,后端二进制和后端/Web镜像OCI标签共享版本、完整Git提交及UTC构建时间;远程CI从OpenAPI版本和运行编号形成候选版本,构建后重新读取两个镜像标签并与`GITHUB_SHA`和构建时间逐项比对,再把候选版本、提交、时间和双镜像摘要写入构建元数据。修复了此前Dockerfile虽支持注入但Compose/CI未传参、运行版本仍为`dev/unknown`的问题。 |
|
| 候选构建身份贯通 | Compose新增统一候选构建参数,后端二进制和后端/Web镜像OCI标签共享版本、完整Git提交及UTC构建时间;远程CI从OpenAPI版本和运行编号形成候选版本,构建后重新读取两个镜像标签并与`GITHUB_SHA`和构建时间逐项比对,再把候选版本、提交、时间和双镜像摘要写入构建元数据。修复了此前Dockerfile虽支持注入但Compose/CI未传参、运行版本仍为`dev/unknown`的问题。 |
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 设备 API 兼容性约定
|
# 设备 API 兼容性约定
|
||||||
|
|
||||||
> 当前设备接口主版本:`/device/v2`
|
> 当前设备接口主版本:`/device/v2`
|
||||||
> 当前接口契约:OpenAPI 0.64.0
|
> 当前接口契约:OpenAPI 0.73.0
|
||||||
> 适用对象:云端 API、终端固件、模拟终端和设备联调工具
|
> 适用对象:云端 API、终端固件、模拟终端和设备联调工具
|
||||||
|
|
||||||
## 1. 兼容目标
|
## 1. 兼容目标
|
||||||
@@ -42,6 +42,10 @@ CI在拉取请求中以目标分支OpenAPI为基线执行破坏性变更检查
|
|||||||
|
|
||||||
| 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.64.0 | 运营外部预检增加`content_safety_tls`枚举和候选快照第四类可选必需依赖;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 |
|
||||||
| 0.63.0 | 增加设备固件阶段事件查询及云端证据字段;设备已有命令回执仅记录同一结果的不可变事件,未增加旧终端必填字段 | v2兼容,旧终端行为不变 |
|
| 0.63.0 | 增加设备固件阶段事件查询及云端证据字段;设备已有命令回执仅记录同一结果的不可变事件,未增加旧终端必填字段 | v2兼容,旧终端行为不变 |
|
||||||
| 0.62.0 | 命令结果增加A/B写入、待重启、启动健康和回退可选字段,省略时保持原有单分区结果语义 | v2兼容,旧终端可继续省略 |
|
| 0.62.0 | 命令结果增加A/B写入、待重启、启动健康和回退可选字段,省略时保持原有单分区结果语义 | v2兼容,旧终端可继续省略 |
|
||||||
|
|||||||
@@ -47,13 +47,21 @@ X-Device-Content-SHA256原值
|
|||||||
|
|
||||||
云端先核验时间窗、正文摘要和签名,再使用Redis `SET NX`登记随机数。相同设备和随机数只能使用一次;Redis或数据库不可用时设备鉴权采用fail-closed并返回可重试的503,不绕过身份验证。
|
云端先核验时间窗、正文摘要和签名,再使用Redis `SET NX`登记随机数。相同设备和随机数只能使用一次;Redis或数据库不可用时设备鉴权采用fail-closed并返回可重试的503,不绕过身份验证。
|
||||||
|
|
||||||
本地环境使用静态HMAC密钥模拟联调。量产采用每设备独立HMAC时,密钥应写入硬件安全区并通过Secrets/KMS CSI受控挂载到云端;如硬件与PKI条件允许,仍可进一步切换每设备mTLS证书。不能把本地合成密钥烧录到真实设备。
|
本地环境允许把既有静态HMAC密钥一次性导入数据库以兼容测试包。量产采用每设备独立HMAC时,设备侧密钥应写入硬件安全区,云端只保存主密钥加密后的密文;如硬件与PKI条件允许,仍可进一步切换每设备mTLS证书。不能把本地合成密钥烧录到真实设备。
|
||||||
|
|
||||||
### 2.1 逐设备生产密钥解析
|
### 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. 启动参数
|
## 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` |
|
| `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` | 依次执行启动、完整通话、字幕、遥测和命令;录音因需要单独授权和可访问的对象存储公开地址而不自动包含 |
|
| `all` | 依次执行启动、完整通话、字幕、遥测和命令;录音因需要单独授权和可访问的对象存储公开地址而不自动包含 |
|
||||||
|
|
||||||
|
Windows真实RTC模拟设备会读取创建通话响应中的`privacy.record_near`、`record_far`和`upload_allowed`。只有相应授权有效时,它才通过RTC SDK音频帧回调分别采集长者近端和智能体远端PCM;结束通话时下混为单声道WAV PCM16,按不超过9分钟且不超过64MiB自动分片。设备提交停止请求并等待通话进入`COMPLETED`或`FAILED`后,自动创建录音上传批次、直传短期对象地址并轮询至云端校验完成。未授权轨道不会采集,上传失败最多自动重试三次并在模拟器事件流中显示稳定错误。
|
||||||
|
|
||||||
例如执行联合模拟闭环:
|
例如执行联合模拟闭环:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 老人陪伴聊天配置与发布
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
本能力让语音智能体以倾听和自然接话为主,通过长者档案、已确认记忆、最近通话摘要和已发布人设形成个性化陪伴。它不替代医疗服务,也不会把未经确认的模型内容直接写入长期记忆。
|
||||||
|
|
||||||
|
## 配置分工
|
||||||
|
|
||||||
|
- `PERSONA.system_prompt`保存通用聊天方法和安全边界。
|
||||||
|
- 长者档案保存称呼、背景、兴趣、交流目标、节奏、优先话题和避免话题。
|
||||||
|
- 最近通话摘要只在故事整理与个性化记忆授权均有效时进入后续新通话。
|
||||||
|
- 长期记忆必须经过家庭人工确认并保持有效授权,才能被新通话召回。
|
||||||
|
- 编排服务在新通话开始时冻结上述内容;修改只影响后续新通话,不改写历史快照。
|
||||||
|
|
||||||
|
## 家庭端操作
|
||||||
|
|
||||||
|
在“长者管理”中新建或编辑长者,可先选择一个陪伴方式模板:
|
||||||
|
|
||||||
|
- 日常陪伴:天气、饮食、睡眠、花草和电视节目。
|
||||||
|
- 回忆往事:家乡、童年、工作经历、老朋友和节日。
|
||||||
|
- 兴趣聊天:围绕戏曲、养花、做饭、散步等熟悉爱好交流。
|
||||||
|
|
||||||
|
模板只填充建议值。保存前应逐项核对背景事实、交流目标和避免主动提及的话题。不要把账号、证件、联系方式或未经核实的猜测写入背景资料。
|
||||||
|
|
||||||
|
## 维护端发布
|
||||||
|
|
||||||
|
1. 在“运行配置”创建新的`PERSONA`草稿,默认正文已经包含老人陪伴规则。
|
||||||
|
2. 使用固定Agent评测集验证称呼、单问题追问、沉默、重复表达、情绪边界、记忆准确性和敏感信息处理。
|
||||||
|
3. 提交草稿,由另一名审批人员完成发布。
|
||||||
|
4. 创建或更新`AGENT_BUNDLE`,引用已发布的PERSONA、VOICE、MODEL和ROUTING版本。
|
||||||
|
5. 先按单个长者或小比例设备灰度,观察通话成功率、响应时延、人工反馈、编造事实和敏感话题违规。
|
||||||
|
6. 验证通过后扩大范围;异常时回滚到上一不可变配置版本。
|
||||||
|
|
||||||
|
仅修改前端默认模板不会改变当前线上通话。只有新PERSONA和AGENT_BUNDLE完成审批、发布并被新通话命中后,规则才会生效。
|
||||||
|
|
||||||
|
## 最小验收场景
|
||||||
|
|
||||||
|
- 长者回答很短:给出两个简单选项,一次只问一个问题。
|
||||||
|
- 长者沉默:留出时间,不连续催促或擅自结束。
|
||||||
|
- 长者重复讲述:不指出“已经讲过”,接住情绪或新的细节。
|
||||||
|
- 长者表达孤独:先确认感受,再温和追问,不替家人辩解。
|
||||||
|
- 长者主动提到避免话题:先共情,再谨慎回应或自然转向。
|
||||||
|
- 长者询问疾病或用药:不诊断、不建议停药换药,必要时建议联系家人或专业人员。
|
||||||
|
- 长者想结束:自然收尾,不强行挽留。
|
||||||
|
- 系统没有可信记忆:不得以“我记得”编造长者偏好或经历。
|
||||||
|
|
||||||
|
## 验证命令
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-Location E:\Codex\story\platform
|
||||||
|
go test ./internal/orchestration ./internal/voiceprovider ./internal/agenteval
|
||||||
|
Set-Location .\web
|
||||||
|
npm run typecheck
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
发布候选仍应回到项目根目录执行`./scripts/check.ps1`,并按真实终端与真实Agent模型证据文档完成外部验收。
|
||||||
@@ -58,7 +58,7 @@ powershell -ExecutionPolicy Bypass -File scripts/stage8-preflight.ps1 `
|
|||||||
- 发现的问题均进入V36联调问题台账并具备严重级别、根因、修复版本和脱敏回归证据;清单全量汇总显示未关闭HIGH/CRITICAL为0;
|
- 发现的问题均进入V36联调问题台账并具备严重级别、根因、修复版本和脱敏回归证据;清单全量汇总显示未关闭HIGH/CRITICAL为0;
|
||||||
- 真实凭据完成一次轮换,旧凭据失效且业务不中断。
|
- 真实凭据完成一次轮换,旧凭据失效且业务不中断。
|
||||||
- 使用`cmd/kms-rotation-conformance`归档同一候选构建的云提供器、工作负载身份、动态读取和滚动重启两类轮换;报告必须为`PASS/REAL_KMS_HSM`,证据包内`kms_rotation_validation.coverage_complete=true`。
|
- 使用`cmd/kms-rotation-conformance`归档同一候选构建的云提供器、工作负载身份、动态读取和滚动重启两类轮换;报告必须为`PASS/REAL_KMS_HSM`,证据包内`kms_rotation_validation.coverage_complete=true`。
|
||||||
- 以相同候选构建重新生成验收快照并归档,六项技术门禁均通过;若候选构建变化,原快照作废并重新执行受影响场景。
|
- 以相同候选构建重新生成验收快照并归档,八项技术门禁均通过;若候选构建变化,原快照作废并重新执行受影响场景。
|
||||||
|
|
||||||
## 3. 家庭试点
|
## 3. 家庭试点
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ powershell -ExecutionPolicy Bypass -File scripts/stage8-preflight.ps1 `
|
|||||||
- 最近两个Chrome/Edge和macOS最近两个Safari版本通过关键流程;
|
- 最近两个Chrome/Edge和macOS最近两个Safari版本通过关键流程;
|
||||||
- 使用`cmd/safari-conformance`归档同一候选在真实Safari `CURRENT/PREVIOUS`、两视口和六流程的24项结果;报告必须为`PASS/REAL_SAFARI`,证据包内`safari_compatibility_validation.coverage_complete=true`。
|
- 使用`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`。
|
- 使用`cmd/release-evidence`为同一候选版本、镜像摘要和源提交生成最终证据包;十类证据索引不得存在`PENDING`。出现`WAIVED`时必须附有权责任人的书面风险接受,包状态只能进入带豁免的发布评审,不能自动视为批准;八类不可豁免门禁不得使用`WAIVED`。
|
||||||
|
|
||||||
### 4.2 业务与合规门禁
|
### 4.2 业务与合规门禁
|
||||||
|
|||||||
@@ -83,14 +83,16 @@ docker push "$Registry/migrate:$Version"
|
|||||||
| Secret名称 | 必需key | 使用者 |
|
| Secret名称 | 必需key | 使用者 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `voice-platform-common-secrets` | `database-url`、`redis-password` | API、Callbacks、Worker、迁移Job |
|
| `voice-platform-common-secrets` | `database-url`、`redis-password` | API、Callbacks、Worker、迁移Job |
|
||||||
| `voice-platform-api-secrets` | `rtc-app-key`、`cursor-signing-secret`、`share-token-signing-secret` | API |
|
| `voice-platform-api-secrets` | `device-credential-master-key`、`rtc-app-key`、`cursor-signing-secret`、`share-token-signing-secret` | API |
|
||||||
| `voice-platform-callbacks-secrets` | `provider-callback-secret` | Callbacks |
|
| `voice-platform-callbacks-secrets` | `provider-callback-secret`、`platform-health-callback-secret` | Callbacks |
|
||||||
| `voice-platform-worker-secrets` | `volc-access-key-id`、`volc-secret-access-key`、`voice-chat-template.json` | Worker |
|
| `voice-platform-worker-secrets` | `volc-access-key-id`、`volc-secret-access-key`、`voice-chat-template.json` | Worker |
|
||||||
| `voice-platform-model-storage-secrets` | `object-store-access-key`、`object-store-secret-key`、`ark-api-key`、`content-safety-api-key` | API、Worker;内容安全Key仅由Worker引用 |
|
| `voice-platform-model-storage-secrets` | `object-store-access-key`、`object-store-secret-key`、`ark-api-key`、`content-safety-api-key`、`memory-tool-signing-secret` | API、Worker;内容安全Key仅由Worker引用,记忆工具签名密钥由两者共享 |
|
||||||
| `voice-platform-device-secrets` | 每台设备一个key,例如`terminal-001` | API |
|
| `voice-platform-device-secrets` | 每台设备一个key,例如`terminal-001` | API |
|
||||||
| `voice-platform-tls` | `tls.crt`、`tls.key` | Ingress |
|
| `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会在下一次设备验签时读取新值,无需重启。
|
每台量产设备在数据库中的`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 | 必须挂载的文件名 |
|
| SecretProviderClass | 必须挂载的文件名 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `voice-platform-common` | `database-url`、`redis-password` |
|
| `voice-platform-common` | `database-url`、`redis-password` |
|
||||||
| `voice-platform-api` | `rtc-app-key`、`cursor-signing-secret`、`share-token-signing-secret` |
|
| `voice-platform-api` | `device-credential-master-key`、`rtc-app-key`、`cursor-signing-secret`、`share-token-signing-secret` |
|
||||||
| `voice-platform-callbacks` | `provider-callback-secret` |
|
| `voice-platform-callbacks` | `provider-callback-secret`、`platform-health-callback-secret` |
|
||||||
| `voice-platform-worker` | `volc-access-key-id`、`volc-secret-access-key`、`voice-chat-template.json` |
|
| `voice-platform-worker` | `volc-access-key-id`、`volc-secret-access-key`、`voice-chat-template.json` |
|
||||||
| `voice-platform-model-storage` | `object-store-access-key`、`object-store-secret-key`、`ark-api-key`、`content-safety-api-key` |
|
| `voice-platform-model-storage` | `object-store-access-key`、`object-store-secret-key`、`ark-api-key`、`content-safety-api-key`、`memory-tool-signing-secret` |
|
||||||
| `voice-platform-devices` | 每台设备一个文件,例如`terminal-001` |
|
| `voice-platform-devices` | 每台设备一个文件,例如`terminal-001` |
|
||||||
|
|
||||||
提供器对象应通过`objectAlias`或等价字段映射为上述准确文件名。Deployment挂载整个目录,不使用`subPath`,以便CSI原子轮换能够更新容器看到的符号链接。ServiceAccount已按API、Callbacks、Worker和Web拆分;现场应只给前三个需要密钥的ServiceAccount附加相应工作负载身份,并按最小权限授予密钥读取能力。
|
提供器对象应通过`objectAlias`或等价字段映射为上述准确文件名。Deployment挂载整个目录,不使用`subPath`,以便CSI原子轮换能够更新容器看到的符号链接。ServiceAccount已按API、Callbacks、Worker和Web拆分;现场应只给前三个需要密钥的ServiceAccount附加相应工作负载身份,并按最小权限授予密钥读取能力。
|
||||||
|
|||||||
@@ -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后重新执行。
|
||||||
@@ -10,6 +10,8 @@ V19—V20及V34已经形成内部运营配置的本地纵向闭环:运营身
|
|||||||
|
|
||||||
## 2. 运营角色与租户边界
|
## 2. 运营角色与租户边界
|
||||||
|
|
||||||
|
维护人员使用独立OIDC账号登录`/maintenance`,不得复用家庭用户身份。账号表以`account_kind`建立强制边界:`customer`只能使用`platform_role=customer`并通过家庭成员关系取得业务权限;`operator`不得具有家庭成员关系,只能使用下表的维护角色。研发环境提供`operator`和`operator-approver`两个合成账号,生产环境必须由独立员工身份源、MFA和入离职流程维护。
|
||||||
|
|
||||||
| 角色 | 权限 |
|
| 角色 | 权限 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `customer` | 无内部运营接口权限 |
|
| `customer` | 无内部运营接口权限 |
|
||||||
@@ -18,7 +20,16 @@ V19—V20及V34已经形成内部运营配置的本地纵向闭环:运营身
|
|||||||
| `ops_approver` | 具备操作员能力,并可审批发布、回滚、暂停和恢复投放 |
|
| `ops_approver` | 具备操作员能力,并可审批发布、回滚、暂停和恢复投放 |
|
||||||
| `ops_admin` | 平台管理员能力;仍受创建人与审批人分离约束 |
|
| `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. 配置与投放状态
|
## 3. 配置与投放状态
|
||||||
|
|
||||||
|
|||||||
@@ -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`;运营查询返回最新观测时间;无签名、旧时间戳、聚合状态不一致均被拒绝;下一周期再次上报后快照时间前进。
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
`cmd/release-evidence`用于阶段8联调、试点和生产发布前,把同一候选版本的技术验收快照、外部验收状态、真机固件一致性报告、接口契约、数据库迁移、部署清单、关键交付文档和可用SBOM封装为一个带SHA-256清单的ZIP。`PILOT`包还必须携带同一客户组织、同一业务时区、上一完整自然周的7份日报和1份周报。它解决证据散落、候选身份不一致、真机场景缺口被模拟结果掩盖、试点周期证据缺失以及归档后无法判断文件是否被替换的问题。
|
`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. 输入设计
|
## 2. 输入设计
|
||||||
|
|
||||||
@@ -143,4 +143,4 @@ go test ./internal/releaseevidence ./cmd/release-evidence
|
|||||||
.\scripts\smoke-release-evidence.ps1
|
.\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退出全部通过。该结果只证明打包链路可用,不证明十项外部验收、真机固件或投诉证据已经完成。
|
||||||
|
|||||||
@@ -33,8 +33,8 @@
|
|||||||
| P0160 | 分片短期上传地址 | `internal/media/service.go`、对象存储适配器、V14 | 创建上传批次和返回待上传分片地址 | 清单、授权、幂等、对象引用和直传烟测 | 本地完成 |
|
| P0160 | 分片短期上传地址 | `internal/media/service.go`、对象存储适配器、V14 | 创建上传批次和返回待上传分片地址 | 清单、授权、幂等、对象引用和直传烟测 | 本地完成 |
|
||||||
| P0162 | 上传完成后校验 | `internal/media/processor.go`、V14/V15 | 上传批次完成和状态查询 | 校验值、对象存在、状态收敛和模拟本地删除闭环 | 本地完成 |
|
| P0162 | 上传完成后校验 | `internal/media/processor.go`、V14/V15 | 上传批次完成和状态查询 | 校验值、对象存在、状态收敛和模拟本地删除闭环 | 本地完成 |
|
||||||
| P0164 | 播放和下载分别授权 | `internal/media/access.go`、`recording_access_grant` | 录音访问授权、撤销及分片访问接口 | 所有者不隐式获得权限、播放/下载能力分离用例 | 本地完成 |
|
| P0164 | 播放和下载分别授权 | `internal/media/access.go`、`recording_access_grant` | 录音访问授权、撤销及分片访问接口 | 所有者不隐式获得权限、播放/下载能力分离用例 | 本地完成 |
|
||||||
| P0167 | 故事和事实候选核对 | `internal/saas/content_service.go`、`internal/extraction`、V8 | 故事列表、详情、修订、确认和拒绝接口及审核页面 | 来源必需、ETag、角色、版本留痕和真实数据库组合用例 | 本地完成 |
|
| P0167 | 故事和事实候选核对 | `internal/saas/content_service.go`、`internal/extraction`、V8/V41 | 故事列表、详情、修订、确认和拒绝;单条事实编辑/确认/拒绝/停用及审核页面 | 来源必需、ETag、角色、故事与事实不可覆盖版本、九类问题标签和真实数据库组合用例 | 本地完成 |
|
||||||
| P0169 | 已确认记忆管理 | `internal/saas/content_service.go`、`internal/memorylifecycle`、V8 | 记忆查询、页面内修订/启停/删除和状态详情 | 授权门禁、索引失效、删除中不可恢复、可靠删除、召回排除及真实登录页面交互 | 本地完成 |
|
| P0169 | 已确认记忆管理 | `internal/saas/content_service.go`、`internal/memorylifecycle`、`internal/providermemory`、V8/V41 | 记忆查询、页面内修订/启停/删除、本地与外部同步状态详情 | 授权门禁、索引失效、删除中不可恢复、本地/外部可靠删除、召回排除及数据库组合测试 | 本地完成;真实供应商记忆账号待验 |
|
||||||
| P0174 | 数据访问、导出、更正、撤回和删除请求 | `internal/privacy`、`internal/dataexport`、V9/V10 | 隐私请求、导出任务和进度接口 | 多子系统删除、导出加密引用、失败/重试和清理烟测 | 本地完成,法务口径待业务确认 |
|
| P0174 | 数据访问、导出、更正、撤回和删除请求 | `internal/privacy`、`internal/dataexport`、V9/V10 | 隐私请求、导出任务和进度接口 | 多子系统删除、导出加密引用、失败/重试和清理烟测 | 本地完成,法务口径待业务确认 |
|
||||||
| P0176 | 状态实时推送且不推敏感正文 | `internal/webevents`、V16、React 2秒合并刷新 | `GET /api/v1/events`及React SSE客户端 | 重连游标、资源隔离、状态事件字段、连续8事件请求上界和运行烟测 | 本地完成 |
|
| P0176 | 状态实时推送且不推敏感正文 | `internal/webevents`、V16、React 2秒合并刷新 | `GET /api/v1/events`及React SSE客户端 | 重连游标、资源隔离、状态事件字段、连续8事件请求上界和运行烟测 | 本地完成 |
|
||||||
| P0183 | 通话用量、费用和额度 | `internal/billing`、V21 | 通话用量、价格、额度和费用汇总接口 | 回调幂等计量、额度门禁、价格版本冻结和烟测 | 本地完成,真实账单对账待验 |
|
| P0183 | 通话用量、费用和额度 | `internal/billing`、V21 | 通话用量、价格、额度和费用汇总接口 | 回调幂等计量、额度门禁、价格版本冻结和烟测 | 本地完成,真实账单对账待验 |
|
||||||
@@ -53,7 +53,9 @@
|
|||||||
| P0212 | 每通话选择一种语音模式 | `internal/opsconfig`、`internal/orchestration`、组合优先及模式冻结 | Agent组合/旧路由投放和通话创建 | 三模板选择、缺失配置拒绝、组合路由解析和数据库到供应商传播用例 | 本地完成,三种真实模式待验 |
|
| P0212 | 每通话选择一种语音模式 | `internal/opsconfig`、`internal/orchestration`、组合优先及模式冻结 | Agent组合/旧路由投放和通话创建 | 三模板选择、缺失配置拒绝、组合路由解析和数据库到供应商传播用例 | 本地完成,三种真实模式待验 |
|
||||||
| P0214 | 管理人设、音色、模型和路由配置 | `internal/opsconfig`、`internal/orchestration`、V19/V20/V33 | 五类配置、评测、资源引用、灰度、回滚及供应商启动交接 | Agent组合UUID、同租户、组件类型、已发布状态、运行时冻结和组件参数传递 | 本地完成,真实资源兼容性待验 |
|
| P0214 | 管理人设、音色、模型和路由配置 | `internal/opsconfig`、`internal/orchestration`、V19/V20/V33 | 五类配置、评测、资源引用、灰度、回滚及供应商启动交接 | Agent组合UUID、同租户、组件类型、已发布状态、运行时冻结和组件参数传递 | 本地完成,真实资源兼容性待验 |
|
||||||
| P0217 | 最小必要背景资料 | `internal/orchestration/repository.go`、版本化背景快照 | 通话创建内部资料准备 | 白名单字段、非白名单排除、无效类型失败和真实数据库用例 | 本地完成 |
|
| 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` | 先验签、重放、同键冲突、乱序和快速确认用例 | 本地完成 |
|
| P0226 | 可靠接收供应商通知 | `internal/voiceprovider/signature.go`、Callback处理和Outbox | `POST /callbacks/v1/vertc/voice-chat` | 先验签、重放、同键冲突、乱序和快速确认用例 | 本地完成 |
|
||||||
| P0228 | 最终字幕和对话轮次 | `internal/orchestration/transcript_batch.go`、V8 | 供应商最终字幕和设备`turns:batch` | 轮次业务键、整批回滚、授权留存和重复补交用例 | 本地完成 |
|
| P0228 | 最终字幕和对话轮次 | `internal/orchestration/transcript_batch.go`、V8 | 供应商最终字幕和设备`turns:batch` | 轮次业务键、整批回滚、授权留存和重复补交用例 | 本地完成 |
|
||||||
| P0230 | 摘要和开放话题 | `internal/extraction/generator.go`、`processor.go` | 通话完成后的可靠内容整理任务 | 事务外模型调用、话轮指纹、重复版本和敏感拦截用例 | 本地完成,真实模型质量待验 |
|
| P0230 | 摘要和开放话题 | `internal/extraction/generator.go`、`processor.go` | 通话完成后的可靠内容整理任务 | 事务外模型调用、话轮指纹、重复版本和敏感拦截用例 | 本地完成,真实模型质量待验 |
|
||||||
@@ -62,10 +64,11 @@
|
|||||||
| P0238 | 长者友好表达 | 版本化PERSONA配置、背景节奏字段和固定评测集 | 运营配置、供应商启动模板、`cmd/agent-eval` | 本地规则及模拟评测通过 | 本地完成,真实听筒体验待验 |
|
| P0238 | 长者友好表达 | 版本化PERSONA配置、背景节奏字段和固定评测集 | 运营配置、供应商启动模板、`cmd/agent-eval` | 本地规则及模拟评测通过 | 本地完成,真实听筒体验待验 |
|
||||||
| P0240 | 身份、人设和边界一致 | `internal/opsconfig`、服务端授权/上下文边界、提示注入拦截 | 版本化PERSONA/AGENT_BUNDLE、冻结快照和供应商系统消息 | 配置校验、租户/长者边界、发布人设传递和敏感提示用例 | 本地完成,真实模型红队待验 |
|
| P0240 | 身份、人设和边界一致 | `internal/opsconfig`、服务端授权/上下文边界、提示注入拦截 | 版本化PERSONA/AGENT_BUNDLE、冻结快照和供应商系统消息 | 配置校验、租户/长者边界、发布人设传递和敏感提示用例 | 本地完成,真实模型红队待验 |
|
||||||
| P0245 | 摘要忠于原始对话 | `internal/extraction`、来源话轮和内容指纹 | 摘要/开放话题接口 | 仅长者最终话轮、来源保存、敏感拦截和虚构门槛 | 本地完成,真实内容评分待验 |
|
| P0245 | 摘要忠于原始对话 | `internal/extraction`、来源话轮和内容指纹 | 摘要/开放话题接口 | 仅长者最终话轮、来源保存、敏感拦截和虚构门槛 | 本地完成,真实内容评分待验 |
|
||||||
| P0247 | 候选可核对可修改 | V8故事、故事修订和事实候选 | 故事详情、修订、确认和拒绝接口 | 来源跳转、版本、ETag和终态重复操作拒绝 | 本地完成 |
|
| P0247 | 候选可核对可修改 | V8/V41故事、故事修订、事实候选与事实修订 | 故事详情、修订、确认和拒绝;单条事实细粒度审核接口 | 来源跳转、不可覆盖版本、ETag、九类问题标签和终态重复操作拒绝 | 本地完成 |
|
||||||
| P0249 | 已确认记忆准确使用 | `internal/retrieval`、Embedding适配器、记忆索引生命周期 | 记忆检索和通话背景快照 | 当前版本/授权/敏感级别过滤、跨长者隔离和停用删除排除 | 本地完成,真实召回质量待验 |
|
| P0249 | 已确认记忆准确使用 | `internal/retrieval`、`internal/memorytool`、`internal/providermemory`、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报告 | 本地完成,真实账号评测待验 |
|
| 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`、运营配置工作台和投放台账 | 双人审批、租户/长者/设备组灰度、自定义版本族、不可变回滚、组合优先选择 | 组件引用复核、评测/资源门禁、配置类型选择、范围与激活时间优先级、并发上限、新通话冻结及供应商启动交接 | 本地完成,真实灰度待验 |
|
| 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. 关键异常和三方交接场景
|
## 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`。 |
|
| P0445 | 需求编号关联代码、测试、接口或配置变更 | 本文逐项建立关联;后续功能变更必须同步更新本矩阵和`development-status.md`。 |
|
||||||
|
|
||||||
当前仍不能在本地宣告完成的部分集中为:真实火山RTC与豆包三模式联调、真实方舟内容与Embedding质量、真实听筒的长者交流体验、量产设备/KMS注入、生产对象存储与备份、真机异常恢复、真实账单和生产级内容安全。它们属于阶段8外部验收,不应通过增加本地低价值测试伪造完成证据。
|
当前仍不能宣告完成的部分集中为:真实火山RTC与豆包三模式联调、真实方舟内容与Embedding质量、真实听筒的长者交流体验、量产设备/KMS注入、跨故障域生产对象存储与备份、真机异常恢复、真实账单和生产级内容安全。阿里云单机环境已经形成加密全组件归档、数据库隔离恢复和Redis RDB校验,但同机归档不能替代托管PITR、异地副本和生产恢复演练。这些剩余项属于阶段8外部验收,不应通过增加本地低价值测试伪造完成证据。
|
||||||
|
|
||||||
下一批本地开发应优先从矩阵中出现“进行中”的条目选择;若没有本地功能缺口,则只继续完善能够直接降低阶段8联调风险的模拟器、诊断和交付工具,不以覆盖率数字替代业务功能。
|
下一批本地开发应优先从矩阵中出现“进行中”的条目选择;若没有本地功能缺口,则只继续完善能够直接降低阶段8联调风险的模拟器、诊断和交付工具,不以覆盖率数字替代业务功能。
|
||||||
|
|||||||
@@ -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。
|
||||||
@@ -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和容量指标纳入同一状态模型。
|
||||||
@@ -73,12 +73,16 @@
|
|||||||
| `GET /api/v1/ops/quota-policies` | 运营查看及以上 | 查询活动与历史额度 | 客户组织隔离 |
|
| `GET /api/v1/ops/quota-policies` | 运营查看及以上 | 查询活动与历史额度 | 客户组织隔离 |
|
||||||
| `POST /api/v1/ops/quota-policies` | 运营审批及以上 | 创建并激活策略版本 | `Idempotency-Key`、事务审计 |
|
| `POST /api/v1/ops/quota-policies` | 运营审批及以上 | 创建并激活策略版本 | `Idempotency-Key`、事务审计 |
|
||||||
| `GET /api/v1/ops/cost-summary` | 运营查看及以上 | 按时间范围聚合费用 | 最大366天、不返回任务编号 |
|
| `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` | 活动家庭成员 | 查看单次通话用量和费用 | 服务端反查家庭关系 |
|
| `GET /api/v1/conversations/{id}/usage` | 活动家庭成员 | 查看单次通话用量和费用 | 服务端反查家庭关系 |
|
||||||
| `POST /callbacks/v1/vertc/voice-chat` | 签名供应商 | 接收`voice_chat.usage` | HMAC、时间窗、事件幂等 |
|
| `POST /callbacks/v1/vertc/voice-chat` | 签名供应商 | 接收`voice_chat.usage` | HMAC、时间窗、事件幂等 |
|
||||||
| `POST /device/v2/conversations` | 签名设备 | 创建通话并冻结预算 | 创建前统一额度检查 |
|
| `POST /device/v2/conversations` | 签名设备 | 创建通话并冻结预算 | 创建前统一额度检查 |
|
||||||
|
|
||||||
所有Web响应使用统一`request_id + data`信封。运营和家庭汇总只返回标准化分类、指标、数量、单位、费用和币种,不返回供应商任务、供应商事件、内部价格版本或费率编号。
|
所有Web响应使用统一`request_id + data`信封。运营和家庭汇总只返回标准化分类、指标、数量、单位、费用和币种,不返回供应商任务、供应商事件、内部价格版本或费率编号。
|
||||||
|
|
||||||
|
通话用量概览以平台已经持久化的`conversation.started_at / ended_at`计算时长,因此不依赖供应商账单接口。费用只有在最终用量已经核价并生成`conversation_cost_line`时才标记为可用;前端默认展示通话时长,在当前筛选范围存在费用行时开放费用视图。家庭端的家庭编号取自URL,但服务端仍以登录身份的活动家庭成员关系重新授权;传入其他家庭或其他家庭设备都会被拒绝。运营端先验证所选客户组织的运营账号,再将家庭、设备筛选限制在该客户组织内。
|
||||||
|
|
||||||
## 4. 数据库设计
|
## 4. 数据库设计
|
||||||
|
|
||||||
| 表 | 作用 | 关键约束 |
|
| 表 | 作用 | 关键约束 |
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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或已经完成 |
|
||||||
+1223
File diff suppressed because it is too large
Load Diff
@@ -413,7 +413,7 @@ func (s *Service) resolveActor(ctx context.Context, principal identity.Principal
|
|||||||
return actor{}, ErrInvalid
|
return actor{}, ErrInvalid
|
||||||
}
|
}
|
||||||
var a actor
|
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) {
|
if errors.Is(e, pgx.ErrNoRows) {
|
||||||
return actor{}, ErrAccessDenied
|
return actor{}, ErrAccessDenied
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ func TestAlertRoutingSilenceAndIncidentLifecycle(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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"}
|
||||||
approver := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000102"}
|
approver := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000105"}
|
||||||
outsider := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000103"}
|
outsider := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000103"}
|
||||||
tenantID := "10000000-0000-0000-0000-000000000010"
|
tenantID := "10000000-0000-0000-0000-000000000010"
|
||||||
routeKeys := []string{"alerts-integration-route1-" + uuid.NewString(), "alerts-integration-route2-" + uuid.NewString()}
|
routeKeys := []string{"alerts-integration-route1-" + uuid.NewString(), "alerts-integration-route2-" + uuid.NewString()}
|
||||||
|
|||||||
+88
-19
@@ -14,6 +14,7 @@ import (
|
|||||||
"data-terminal-cloud/internal/billing"
|
"data-terminal-cloud/internal/billing"
|
||||||
"data-terminal-cloud/internal/cache"
|
"data-terminal-cloud/internal/cache"
|
||||||
"data-terminal-cloud/internal/config"
|
"data-terminal-cloud/internal/config"
|
||||||
|
"data-terminal-cloud/internal/contentmodel"
|
||||||
"data-terminal-cloud/internal/contentpolicy"
|
"data-terminal-cloud/internal/contentpolicy"
|
||||||
"data-terminal-cloud/internal/database"
|
"data-terminal-cloud/internal/database"
|
||||||
"data-terminal-cloud/internal/dataexport"
|
"data-terminal-cloud/internal/dataexport"
|
||||||
@@ -30,14 +31,17 @@ import (
|
|||||||
"data-terminal-cloud/internal/media"
|
"data-terminal-cloud/internal/media"
|
||||||
"data-terminal-cloud/internal/memoryindex"
|
"data-terminal-cloud/internal/memoryindex"
|
||||||
"data-terminal-cloud/internal/memorylifecycle"
|
"data-terminal-cloud/internal/memorylifecycle"
|
||||||
|
"data-terminal-cloud/internal/memorytool"
|
||||||
"data-terminal-cloud/internal/objectstore"
|
"data-terminal-cloud/internal/objectstore"
|
||||||
"data-terminal-cloud/internal/observability"
|
"data-terminal-cloud/internal/observability"
|
||||||
"data-terminal-cloud/internal/opsaccess"
|
"data-terminal-cloud/internal/opsaccess"
|
||||||
"data-terminal-cloud/internal/opsconfig"
|
"data-terminal-cloud/internal/opsconfig"
|
||||||
|
"data-terminal-cloud/internal/opsdevice"
|
||||||
"data-terminal-cloud/internal/orchestration"
|
"data-terminal-cloud/internal/orchestration"
|
||||||
"data-terminal-cloud/internal/outbox"
|
"data-terminal-cloud/internal/outbox"
|
||||||
"data-terminal-cloud/internal/pagination"
|
"data-terminal-cloud/internal/pagination"
|
||||||
"data-terminal-cloud/internal/privacy"
|
"data-terminal-cloud/internal/privacy"
|
||||||
|
"data-terminal-cloud/internal/providermemory"
|
||||||
"data-terminal-cloud/internal/ratelimit"
|
"data-terminal-cloud/internal/ratelimit"
|
||||||
"data-terminal-cloud/internal/rtc"
|
"data-terminal-cloud/internal/rtc"
|
||||||
"data-terminal-cloud/internal/saas"
|
"data-terminal-cloud/internal/saas"
|
||||||
@@ -75,11 +79,18 @@ func Run(ctx context.Context, serviceName string) error {
|
|||||||
defer func() { _ = redisClient.Close() }()
|
defer func() { _ = redisClient.Close() }()
|
||||||
var exportStore *objectstore.Store
|
var exportStore *objectstore.Store
|
||||||
var memoryEmbedding embeddingprovider.Provider
|
var memoryEmbedding embeddingprovider.Provider
|
||||||
|
var providerMemoryClient providermemory.Client
|
||||||
if serviceName == "api" || serviceName == "worker" {
|
if serviceName == "api" || serviceName == "worker" {
|
||||||
memoryEmbedding, err = newMemoryEmbeddingProvider(cfg)
|
memoryEmbedding, err = newMemoryEmbeddingProvider(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create memory embedding provider: %w", err)
|
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)
|
exportStore, err = objectstore.New(cfg.ObjectStoreEndpoint, cfg.ObjectStorePublic, cfg.ObjectStoreAccessKey, cfg.ObjectStoreSecretKey, cfg.ObjectStoreBucket, cfg.ObjectStoreRegion, cfg.ObjectStoreSecure, cfg.ObjectStorePublicSecure)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create private export object store: %w", err)
|
return fmt.Errorf("create private export object store: %w", err)
|
||||||
@@ -124,13 +135,50 @@ func Run(ctx context.Context, serviceName string) error {
|
|||||||
var alertService *alerts.Service
|
var alertService *alerts.Service
|
||||||
var diagnosticService *diagnostics.Service
|
var diagnosticService *diagnostics.Service
|
||||||
var opsAccessService *opsaccess.Service
|
var opsAccessService *opsaccess.Service
|
||||||
|
var opsDeviceService *opsdevice.Service
|
||||||
|
var memoryToolService *memorytool.Service
|
||||||
|
var memoryToolSigner *memorytool.TokenSigner
|
||||||
|
var contentModelService *contentmodel.Service
|
||||||
|
var contentModelRegistry *contentmodel.Registry
|
||||||
|
if serviceName == "api" || serviceName == "worker" {
|
||||||
|
var fallback extraction.Generator = extraction.DeterministicGenerator{}
|
||||||
|
if cfg.ContentGenerator == "ark" {
|
||||||
|
fallback, err = extraction.NewArkGenerator(cfg.ArkBaseURL, cfg.ArkAPIKey, cfg.ArkContentModel, cfg.ArkRequestTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create fallback Ark content generator: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentModelRegistry, err = contentmodel.NewRegistry(databasePool, cfg.ContentModelMasterKey, fallback)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create content model registry: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if serviceName == "api" || serviceName == "callbacks" {
|
||||||
|
diagnosticService, err = diagnostics.NewService(databasePool, idempotency.NewExecutor(databasePool), audit.NewRepository())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create diagnostic service: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
if serviceName == "api" {
|
if serviceName == "api" {
|
||||||
|
contentModelService, err = contentmodel.NewService(contentModelRegistry, audit.NewRepository())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create content model service: %w", err)
|
||||||
|
}
|
||||||
|
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)
|
webEventService = webevents.NewService(databasePool)
|
||||||
cursorCodec, cursorErr := pagination.NewCodec([]byte(cfg.CursorSigningSecret))
|
cursorCodec, cursorErr := pagination.NewCodec([]byte(cfg.CursorSigningSecret))
|
||||||
if cursorErr != nil {
|
if cursorErr != nil {
|
||||||
return fmt.Errorf("create pagination cursor codec: %w", cursorErr)
|
return fmt.Errorf("create pagination cursor codec: %w", cursorErr)
|
||||||
}
|
}
|
||||||
saasService = saas.NewService(databasePool, audit.NewRepository(), redisClient, cursorCodec)
|
saasService = saas.NewService(databasePool, audit.NewRepository(), redisClient, cursorCodec)
|
||||||
|
saasService.ConfigureProviderMemory(cfg.ProviderMemoryMode == "managed_http")
|
||||||
opsConfigService, err = opsconfig.NewService(databasePool, audit.NewRepository())
|
opsConfigService, err = opsconfig.NewService(databasePool, audit.NewRepository())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create operations configuration service: %w", err)
|
return fmt.Errorf("create operations configuration service: %w", err)
|
||||||
@@ -143,10 +191,6 @@ func Run(ctx context.Context, serviceName string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create alert operations service: %w", err)
|
return fmt.Errorf("create alert operations service: %w", err)
|
||||||
}
|
}
|
||||||
diagnosticService, err = diagnostics.NewService(databasePool, idempotency.NewExecutor(databasePool), audit.NewRepository())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create diagnostic service: %w", err)
|
|
||||||
}
|
|
||||||
opsAccessService, err = opsaccess.NewService(databasePool, idempotency.NewExecutor(databasePool), audit.NewRepository(), exportStore, 5*time.Minute)
|
opsAccessService, err = opsaccess.NewService(databasePool, idempotency.NewExecutor(databasePool), audit.NewRepository(), exportStore, 5*time.Minute)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create temporary sensitive access service: %w", err)
|
return fmt.Errorf("create temporary sensitive access service: %w", err)
|
||||||
@@ -164,14 +208,21 @@ func Run(ctx context.Context, serviceName string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
deviceIdentityRepository := deviceauth.NewRepository(databasePool)
|
deviceIdentityRepository := deviceauth.NewRepository(databasePool)
|
||||||
var deviceSecretResolver deviceauth.SecretResolver
|
deviceSecretResolver, err := deviceauth.NewDatabaseSecretResolver(databasePool, cfg.DeviceCredentialMasterKey)
|
||||||
if cfg.DeviceSecretDirectory != "" {
|
|
||||||
deviceSecretResolver, err = deviceauth.NewDirectorySecretResolver(cfg.DeviceSecretDirectory)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create per-device secret resolver: %w", err)
|
return fmt.Errorf("create database device secret resolver: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
if len(cfg.DeviceLocalSecret) >= 32 {
|
||||||
deviceSecretResolver = deviceauth.StaticSecretResolver{"local-dev://simulated-terminal-1": []byte(cfg.DeviceLocalSecret)}
|
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(
|
deviceVerifier, err = deviceauth.NewVerifier(
|
||||||
deviceIdentityRepository,
|
deviceIdentityRepository,
|
||||||
@@ -251,6 +302,7 @@ func Run(ctx context.Context, serviceName string) error {
|
|||||||
"tenant": {RatePerSecond: cfg.RateLimitTenantRPS, Burst: cfg.RateLimitTenantBurst},
|
"tenant": {RatePerSecond: cfg.RateLimitTenantRPS, Burst: cfg.RateLimitTenantBurst},
|
||||||
"device": {RatePerSecond: cfg.RateLimitDeviceRPS, Burst: cfg.RateLimitDeviceBurst},
|
"device": {RatePerSecond: cfg.RateLimitDeviceRPS, Burst: cfg.RateLimitDeviceBurst},
|
||||||
"provider_start": {RatePerSecond: cfg.ProviderStartRPS, Burst: cfg.ProviderStartBurst},
|
"provider_start": {RatePerSecond: cfg.ProviderStartRPS, Burst: cfg.ProviderStartBurst},
|
||||||
|
"memory_tool": {RatePerSecond: cfg.MemoryToolRateRPS, Burst: cfg.MemoryToolRateBurst},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create rate limiter: %w", err)
|
return fmt.Errorf("create rate limiter: %w", err)
|
||||||
@@ -305,6 +357,15 @@ func Run(ctx context.Context, serviceName string) error {
|
|||||||
if processorErr = startProcessor.ConfigureStartProtection(limiter, cfg.ProviderMaxActiveTasks); processorErr != nil {
|
if processorErr = startProcessor.ConfigureStartProtection(limiter, cfg.ProviderMaxActiveTasks); processorErr != nil {
|
||||||
return fmt.Errorf("configure provider start protection: %w", processorErr)
|
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_requested", startProcessor.Handle)
|
||||||
outboxWorker.Register("conversation.agent_start_reconcile_requested", startProcessor.Handle)
|
outboxWorker.Register("conversation.agent_start_reconcile_requested", startProcessor.Handle)
|
||||||
stopProcessor, processorErr := orchestration.NewStopProcessor(databasePool, providerClient, providerHealth)
|
stopProcessor, processorErr := orchestration.NewStopProcessor(databasePool, providerClient, providerHealth)
|
||||||
@@ -313,14 +374,7 @@ func Run(ctx context.Context, serviceName string) error {
|
|||||||
}
|
}
|
||||||
outboxWorker.Register("conversation.agent_stop_requested", stopProcessor.Handle)
|
outboxWorker.Register("conversation.agent_stop_requested", stopProcessor.Handle)
|
||||||
outboxWorker.Register("conversation.agent_stop_reconcile_requested", stopProcessor.Handle)
|
outboxWorker.Register("conversation.agent_stop_reconcile_requested", stopProcessor.Handle)
|
||||||
var contentGenerator extraction.Generator = extraction.DeterministicGenerator{}
|
var contentGenerator extraction.Generator = contentModelRegistry
|
||||||
if cfg.ContentGenerator == "ark" {
|
|
||||||
arkGenerator, generatorErr := extraction.NewArkGenerator(cfg.ArkBaseURL, cfg.ArkAPIKey, cfg.ArkContentModel, cfg.ArkRequestTimeout)
|
|
||||||
if generatorErr != nil {
|
|
||||||
return fmt.Errorf("create Ark content generator: %w", generatorErr)
|
|
||||||
}
|
|
||||||
contentGenerator = arkGenerator
|
|
||||||
}
|
|
||||||
var contentSafety contentpolicy.Classifier = contentpolicy.LocalClassifier{}
|
var contentSafety contentpolicy.Classifier = contentpolicy.LocalClassifier{}
|
||||||
if cfg.ContentSafetyProvider == "managed_http" {
|
if cfg.ContentSafetyProvider == "managed_http" {
|
||||||
managedSafety, safetyErr := contentpolicy.NewManagedClassifier(cfg.ContentSafetyEndpoint, cfg.ContentSafetyAPIKey, cfg.ContentSafetyPolicyRef, cfg.ContentSafetyTimeout)
|
managedSafety, safetyErr := contentpolicy.NewManagedClassifier(cfg.ContentSafetyEndpoint, cfg.ContentSafetyAPIKey, cfg.ContentSafetyPolicyRef, cfg.ContentSafetyTimeout)
|
||||||
@@ -339,6 +393,14 @@ func Run(ctx context.Context, serviceName string) error {
|
|||||||
return fmt.Errorf("create memory index processor: %w", processorErr)
|
return fmt.Errorf("create memory index processor: %w", processorErr)
|
||||||
}
|
}
|
||||||
outboxWorker.Register("memory.index_requested", memoryIndexProcessor.Handle)
|
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)
|
memoryDeleteProcessor, processorErr := memorylifecycle.NewDeleteProcessor(databasePool)
|
||||||
if processorErr != nil {
|
if processorErr != nil {
|
||||||
return fmt.Errorf("create memory delete processor: %w", processorErr)
|
return fmt.Errorf("create memory delete processor: %w", processorErr)
|
||||||
@@ -349,7 +411,7 @@ func Run(ctx context.Context, serviceName string) error {
|
|||||||
return fmt.Errorf("create privacy access processor: %w", processorErr)
|
return fmt.Errorf("create privacy access processor: %w", processorErr)
|
||||||
}
|
}
|
||||||
outboxWorker.Register("privacy.access_requested", privacyAccessProcessor.Handle)
|
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 {
|
if processorErr != nil {
|
||||||
return fmt.Errorf("create privacy delete processor: %w", processorErr)
|
return fmt.Errorf("create privacy delete processor: %w", processorErr)
|
||||||
}
|
}
|
||||||
@@ -426,6 +488,8 @@ func Run(ctx context.Context, serviceName string) error {
|
|||||||
AlertService: alertService,
|
AlertService: alertService,
|
||||||
DiagnosticService: diagnosticService,
|
DiagnosticService: diagnosticService,
|
||||||
OpsAccessService: opsAccessService,
|
OpsAccessService: opsAccessService,
|
||||||
|
OpsDeviceService: opsDeviceService,
|
||||||
|
ContentModelService: contentModelService,
|
||||||
ConversationCreator: conversationCreator,
|
ConversationCreator: conversationCreator,
|
||||||
ConversationActivator: conversationCreator,
|
ConversationActivator: conversationCreator,
|
||||||
DeviceConversationStateReader: conversationCreator,
|
DeviceConversationStateReader: conversationCreator,
|
||||||
@@ -435,6 +499,11 @@ func Run(ctx context.Context, serviceName string) error {
|
|||||||
ProviderCallbackSecret: []byte(cfg.ProviderCallbackSecret),
|
ProviderCallbackSecret: []byte(cfg.ProviderCallbackSecret),
|
||||||
ProviderCallbackSkew: cfg.ProviderCallbackSkew,
|
ProviderCallbackSkew: cfg.ProviderCallbackSkew,
|
||||||
ProviderCallbackProcessor: providerEventProcessor,
|
ProviderCallbackProcessor: providerEventProcessor,
|
||||||
|
PlatformHealthCallbackSecret: []byte(cfg.PlatformHealthSecret),
|
||||||
|
PlatformHealthCallbackSkew: cfg.PlatformHealthSkew,
|
||||||
|
PlatformHealthProcessor: diagnosticService,
|
||||||
|
MemoryToolService: memoryToolService,
|
||||||
|
MemoryToolSigner: memoryToolSigner,
|
||||||
})
|
})
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: cfg.HTTPAddr,
|
Addr: cfg.HTTPAddr,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func TestCallbacksApplicationStartsAndShutsDown(t *testing.T) {
|
|||||||
t.Setenv("REDIS_ADDR", "localhost:56379")
|
t.Setenv("REDIS_ADDR", "localhost:56379")
|
||||||
t.Setenv("SHUTDOWN_TIMEOUT", "1s")
|
t.Setenv("SHUTDOWN_TIMEOUT", "1s")
|
||||||
t.Setenv("DEPENDENCY_TIMEOUT", "1s")
|
t.Setenv("DEPENDENCY_TIMEOUT", "1s")
|
||||||
|
t.Setenv("PLATFORM_HEALTH_CALLBACK_SECRET", strings.Repeat("h", 32))
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := Run(ctx, "callbacks"); err != nil {
|
if err := Run(ctx, "callbacks"); err != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -376,7 +376,7 @@ func (s *Service) resolveActor(ctx context.Context, principal identity.Principal
|
|||||||
return actor{}, ErrInvalid
|
return actor{}, ErrInvalid
|
||||||
}
|
}
|
||||||
var result actor
|
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) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return actor{}, ErrAccessDenied
|
return actor{}, ErrAccessDenied
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ const (
|
|||||||
billingTestIssuer = "http://localhost:58180/realms/voice-local"
|
billingTestIssuer = "http://localhost:58180/realms/voice-local"
|
||||||
billingTenantPublicID = "10000000-0000-0000-0000-000000000010"
|
billingTenantPublicID = "10000000-0000-0000-0000-000000000010"
|
||||||
billingOtherTenantID = "20000000-0000-0000-0000-000000000010"
|
billingOtherTenantID = "20000000-0000-0000-0000-000000000010"
|
||||||
billingAdminSubject = "00000000-0000-0000-0000-000000000101"
|
billingAdminSubject = "00000000-0000-0000-0000-000000000104"
|
||||||
billingApproverSubject = "00000000-0000-0000-0000-000000000102"
|
billingApproverSubject = "00000000-0000-0000-0000-000000000105"
|
||||||
billingDevicePublicID = "10000000-0000-0000-0000-000000000511"
|
billingDevicePublicID = "10000000-0000-0000-0000-000000000511"
|
||||||
billingTestSource = "integration://billing-service"
|
billingTestSource = "integration://billing-service"
|
||||||
billingTestChangePrefix = "费用集成测试"
|
billingTestChangePrefix = "费用集成测试"
|
||||||
|
|||||||
+105
-13
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -46,7 +47,8 @@ type Config struct {
|
|||||||
ProviderStartBurst int
|
ProviderStartBurst int
|
||||||
ProviderMaxActiveTasks int
|
ProviderMaxActiveTasks int
|
||||||
DeviceLocalSecret string
|
DeviceLocalSecret string
|
||||||
DeviceSecretDirectory string
|
DeviceCredentialMasterKey string
|
||||||
|
ContentModelMasterKey string
|
||||||
DeviceClockSkew time.Duration
|
DeviceClockSkew time.Duration
|
||||||
RTCAppID string
|
RTCAppID string
|
||||||
RTCAppKey string
|
RTCAppKey string
|
||||||
@@ -59,6 +61,8 @@ type Config struct {
|
|||||||
HeartbeatInterval int
|
HeartbeatInterval int
|
||||||
ProviderCallbackSecret string
|
ProviderCallbackSecret string
|
||||||
ProviderCallbackSkew time.Duration
|
ProviderCallbackSkew time.Duration
|
||||||
|
PlatformHealthSecret string
|
||||||
|
PlatformHealthSkew time.Duration
|
||||||
MockProviderBaseURL string
|
MockProviderBaseURL string
|
||||||
MockProviderAPIKey string
|
MockProviderAPIKey string
|
||||||
VoiceProvider string
|
VoiceProvider string
|
||||||
@@ -105,6 +109,15 @@ type Config struct {
|
|||||||
ContentSafetyTimeout time.Duration
|
ContentSafetyTimeout time.Duration
|
||||||
EmbeddingProvider string
|
EmbeddingProvider string
|
||||||
ArkEmbeddingModel 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) {
|
func Load(serviceName string) (Config, error) {
|
||||||
@@ -160,6 +173,10 @@ func Load(serviceName string) (Config, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
|
platformHealthSkew, err := durationEnv("PLATFORM_HEALTH_CALLBACK_CLOCK_SKEW", 5*time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
providerRequestTimeout, err := durationEnv("PROVIDER_REQUEST_TIMEOUT", 5*time.Second)
|
providerRequestTimeout, err := durationEnv("PROVIDER_REQUEST_TIMEOUT", 5*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
@@ -192,6 +209,14 @@ func Load(serviceName string) (Config, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
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)
|
objectStoreSecure, err := boolEnv("OBJECT_STORE_SECURE", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
@@ -202,15 +227,17 @@ func Load(serviceName string) (Config, error) {
|
|||||||
}
|
}
|
||||||
environment := stringEnv("APP_ENV", EnvironmentLocal)
|
environment := stringEnv("APP_ENV", EnvironmentLocal)
|
||||||
secretValues, err := loadSecretValues(
|
secretValues, err := loadSecretValues(
|
||||||
"DATABASE_URL", "REDIS_PASSWORD", "DEVICE_LOCAL_SECRET", "RTC_APP_KEY", "RTC_TOKEN_SIGNING_SECRET",
|
"DATABASE_URL", "REDIS_PASSWORD", "DEVICE_LOCAL_SECRET", "DEVICE_CREDENTIAL_MASTER_KEY", "MODEL_CREDENTIAL_MASTER_KEY", "RTC_APP_KEY", "RTC_TOKEN_SIGNING_SECRET",
|
||||||
"CURSOR_SIGNING_SECRET", "SHARE_TOKEN_SIGNING_SECRET", "PROVIDER_CALLBACK_SECRET",
|
"CURSOR_SIGNING_SECRET", "SHARE_TOKEN_SIGNING_SECRET", "PROVIDER_CALLBACK_SECRET", "PLATFORM_HEALTH_CALLBACK_SECRET",
|
||||||
"MOCK_PROVIDER_API_KEY", "VOLC_ACCESS_KEY_ID", "VOLC_SECRET_ACCESS_KEY", "VOLC_SESSION_TOKEN",
|
"MOCK_PROVIDER_API_KEY", "VOLC_ACCESS_KEY_ID", "VOLC_SECRET_ACCESS_KEY", "VOLC_SESSION_TOKEN",
|
||||||
"OBJECT_STORE_ACCESS_KEY", "OBJECT_STORE_SECRET_KEY", "ALERT_WEBHOOK_SECRET", "ARK_API_KEY", "CONTENT_SAFETY_API_KEY",
|
"OBJECT_STORE_ACCESS_KEY", "OBJECT_STORE_SECRET_KEY", "ALERT_WEBHOOK_SECRET", "ARK_API_KEY", "CONTENT_SAFETY_API_KEY", "MEMORY_TOOL_SIGNING_SECRET", "PROVIDER_MEMORY_API_KEY",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
deviceLocalSecret := secretValues["DEVICE_LOCAL_SECRET"]
|
deviceLocalSecret := secretValues["DEVICE_LOCAL_SECRET"]
|
||||||
|
deviceCredentialMasterKey := secretValues["DEVICE_CREDENTIAL_MASTER_KEY"]
|
||||||
|
contentModelMasterKey := secretValues["MODEL_CREDENTIAL_MASTER_KEY"]
|
||||||
rtcTokenSecret := secretValues["RTC_TOKEN_SIGNING_SECRET"]
|
rtcTokenSecret := secretValues["RTC_TOKEN_SIGNING_SECRET"]
|
||||||
cursorSigningSecret := secretValues["CURSOR_SIGNING_SECRET"]
|
cursorSigningSecret := secretValues["CURSOR_SIGNING_SECRET"]
|
||||||
shareTokenSecret := secretValues["SHARE_TOKEN_SIGNING_SECRET"]
|
shareTokenSecret := secretValues["SHARE_TOKEN_SIGNING_SECRET"]
|
||||||
@@ -224,6 +251,12 @@ func Load(serviceName string) (Config, error) {
|
|||||||
if deviceLocalSecret == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
|
if deviceLocalSecret == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
|
||||||
deviceLocalSecret = "local-simulated-device-secret-32-bytes-minimum"
|
deviceLocalSecret = "local-simulated-device-secret-32-bytes-minimum"
|
||||||
}
|
}
|
||||||
|
if deviceCredentialMasterKey == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
|
||||||
|
deviceCredentialMasterKey = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
|
||||||
|
}
|
||||||
|
if contentModelMasterKey == "" && (serviceName == "api" || serviceName == "worker") && (environment == EnvironmentLocal || environment == EnvironmentTest) {
|
||||||
|
contentModelMasterKey = "1f1e1d1c1b1a191817161514131211100f0e0d0c0b0a09080706050403020100"
|
||||||
|
}
|
||||||
if rtcTokenSecret == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
|
if rtcTokenSecret == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
|
||||||
rtcTokenSecret = "local-simulated-rtc-token-secret-32-bytes-minimum"
|
rtcTokenSecret = "local-simulated-rtc-token-secret-32-bytes-minimum"
|
||||||
}
|
}
|
||||||
@@ -233,6 +266,10 @@ func Load(serviceName string) (Config, error) {
|
|||||||
if shareTokenSecret == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
|
if shareTokenSecret == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
|
||||||
shareTokenSecret = "local-share-token-signing-secret-32-bytes-minimum"
|
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) {
|
if providerCallbackSecret == "" && serviceName == "callbacks" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
|
||||||
providerCallbackSecret = "local-provider-callback-secret-32-bytes-minimum"
|
providerCallbackSecret = "local-provider-callback-secret-32-bytes-minimum"
|
||||||
}
|
}
|
||||||
@@ -278,7 +315,8 @@ func Load(serviceName string) (Config, error) {
|
|||||||
RecoveryStartingAfter: recoveryStartingAfter,
|
RecoveryStartingAfter: recoveryStartingAfter,
|
||||||
RecoveryStoppingAfter: recoveryStoppingAfter,
|
RecoveryStoppingAfter: recoveryStoppingAfter,
|
||||||
DeviceLocalSecret: deviceLocalSecret,
|
DeviceLocalSecret: deviceLocalSecret,
|
||||||
DeviceSecretDirectory: strings.TrimSpace(os.Getenv("DEVICE_SECRET_DIRECTORY")),
|
DeviceCredentialMasterKey: deviceCredentialMasterKey,
|
||||||
|
ContentModelMasterKey: contentModelMasterKey,
|
||||||
DeviceClockSkew: deviceClockSkew,
|
DeviceClockSkew: deviceClockSkew,
|
||||||
RTCAppID: stringEnv("RTC_APP_ID", "local-public-rtc-app"),
|
RTCAppID: stringEnv("RTC_APP_ID", "local-public-rtc-app"),
|
||||||
RTCAppKey: secretValues["RTC_APP_KEY"],
|
RTCAppKey: secretValues["RTC_APP_KEY"],
|
||||||
@@ -290,6 +328,8 @@ func Load(serviceName string) (Config, error) {
|
|||||||
AllocationTTL: allocationTTL,
|
AllocationTTL: allocationTTL,
|
||||||
ProviderCallbackSecret: providerCallbackSecret,
|
ProviderCallbackSecret: providerCallbackSecret,
|
||||||
ProviderCallbackSkew: providerCallbackSkew,
|
ProviderCallbackSkew: providerCallbackSkew,
|
||||||
|
PlatformHealthSecret: secretValues["PLATFORM_HEALTH_CALLBACK_SECRET"],
|
||||||
|
PlatformHealthSkew: platformHealthSkew,
|
||||||
MockProviderBaseURL: stringEnv("MOCK_PROVIDER_BASE_URL", "http://localhost:59200"),
|
MockProviderBaseURL: stringEnv("MOCK_PROVIDER_BASE_URL", "http://localhost:59200"),
|
||||||
MockProviderAPIKey: secretDefault(secretValues["MOCK_PROVIDER_API_KEY"], "local-mock-provider-key"),
|
MockProviderAPIKey: secretDefault(secretValues["MOCK_PROVIDER_API_KEY"], "local-mock-provider-key"),
|
||||||
VoiceProvider: stringEnv("VOICE_PROVIDER", "mock"),
|
VoiceProvider: stringEnv("VOICE_PROVIDER", "mock"),
|
||||||
@@ -328,6 +368,13 @@ func Load(serviceName string) (Config, error) {
|
|||||||
ContentSafetyTimeout: contentSafetyTimeout,
|
ContentSafetyTimeout: contentSafetyTimeout,
|
||||||
EmbeddingProvider: stringEnv("EMBEDDING_PROVIDER", "local"),
|
EmbeddingProvider: stringEnv("EMBEDDING_PROVIDER", "local"),
|
||||||
ArkEmbeddingModel: strings.TrimSpace(os.Getenv("ARK_EMBEDDING_MODEL")),
|
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)
|
cfg.RateLimitRPS, err = floatEnv("RATE_LIMIT_RPS", 50)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -373,6 +420,14 @@ func Load(serviceName string) (Config, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
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)
|
cfg.HeartbeatInterval, err = intEnv("HEARTBEAT_INTERVAL_SECONDS", 15)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
@@ -419,14 +474,21 @@ func (c Config) Validate() error {
|
|||||||
c.RateLimitUserRPS <= 0 || c.RateLimitUserBurst <= 0 ||
|
c.RateLimitUserRPS <= 0 || c.RateLimitUserBurst <= 0 ||
|
||||||
c.RateLimitTenantRPS <= 0 || c.RateLimitTenantBurst <= 0 ||
|
c.RateLimitTenantRPS <= 0 || c.RateLimitTenantBurst <= 0 ||
|
||||||
c.RateLimitDeviceRPS <= 0 || c.RateLimitDeviceBurst <= 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")
|
return errors.New("rate limits, bursts and provider task capacity must be positive")
|
||||||
}
|
}
|
||||||
if c.ServiceName == "api" && c.DeviceSecretDirectory == "" && len(c.DeviceLocalSecret) < 32 {
|
if c.ServiceName == "api" {
|
||||||
return errors.New("DEVICE_LOCAL_SECRET must contain at least 32 characters when DEVICE_SECRET_DIRECTORY is not configured")
|
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.ServiceName == "worker" {
|
||||||
|
decodedModelKey, err := hex.DecodeString(c.ContentModelMasterKey)
|
||||||
|
if err != nil || len(decodedModelKey) != 32 {
|
||||||
|
return errors.New("MODEL_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 {
|
if c.DeviceClockSkew <= 0 {
|
||||||
return errors.New("DEVICE_CLOCK_SKEW must be positive")
|
return errors.New("DEVICE_CLOCK_SKEW must be positive")
|
||||||
@@ -451,12 +513,27 @@ func (c Config) Validate() error {
|
|||||||
if c.ServiceName == "api" && len(c.ShareTokenSecret) < 32 {
|
if c.ServiceName == "api" && len(c.ShareTokenSecret) < 32 {
|
||||||
return errors.New("SHARE_TOKEN_SIGNING_SECRET must contain at least 32 characters")
|
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 {
|
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")
|
return errors.New("RTC app, token TTL, allocation TTL and heartbeat interval must be valid")
|
||||||
}
|
}
|
||||||
if c.ServiceName == "callbacks" && len(c.ProviderCallbackSecret) < 32 {
|
if c.ServiceName == "callbacks" && len(c.ProviderCallbackSecret) < 32 {
|
||||||
return errors.New("PROVIDER_CALLBACK_SECRET must contain at least 32 characters")
|
return errors.New("PROVIDER_CALLBACK_SECRET must contain at least 32 characters")
|
||||||
}
|
}
|
||||||
|
if c.ServiceName == "callbacks" && len(c.PlatformHealthSecret) < 32 {
|
||||||
|
return errors.New("PLATFORM_HEALTH_CALLBACK_SECRET must contain at least 32 characters")
|
||||||
|
}
|
||||||
if c.ServiceName == "worker" {
|
if c.ServiceName == "worker" {
|
||||||
if c.VoiceProvider != "mock" && c.VoiceProvider != "volcengine" {
|
if c.VoiceProvider != "mock" && c.VoiceProvider != "volcengine" {
|
||||||
return errors.New("VOICE_PROVIDER must be mock or volcengine")
|
return errors.New("VOICE_PROVIDER must be mock or volcengine")
|
||||||
@@ -470,7 +547,7 @@ func (c Config) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c.ProviderCallbackSkew <= 0 || c.ProviderRequestTimeout <= 0 || c.ProviderFailureThreshold < 1 || c.ProviderCircuitCooldown <= 0 {
|
if c.ProviderCallbackSkew <= 0 || c.PlatformHealthSkew <= 0 || c.ProviderRequestTimeout <= 0 || c.ProviderFailureThreshold < 1 || c.ProviderCircuitCooldown <= 0 {
|
||||||
return errors.New("provider callback skew and request timeout must be positive")
|
return errors.New("provider callback skew and request timeout must be positive")
|
||||||
}
|
}
|
||||||
if c.AlertWebhookTimeout <= 0 {
|
if c.AlertWebhookTimeout <= 0 {
|
||||||
@@ -559,8 +636,8 @@ func (c Config) validateProduction() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if c.ServiceName == "api" {
|
if c.ServiceName == "api" {
|
||||||
if c.DeviceSecretDirectory == "" {
|
if c.DeviceCredentialMasterKey == "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" {
|
||||||
return errors.New("production API must use DEVICE_SECRET_DIRECTORY for per-device credentials")
|
return errors.New("production API must use a non-default DEVICE_CREDENTIAL_MASTER_KEY")
|
||||||
}
|
}
|
||||||
if isPlaceholderSecret(c.CursorSigningSecret) || isPlaceholderSecret(c.ShareTokenSecret) {
|
if isPlaceholderSecret(c.CursorSigningSecret) || isPlaceholderSecret(c.ShareTokenSecret) {
|
||||||
return errors.New("production API signing secrets must not use local or example values")
|
return errors.New("production API signing secrets must not use local or example values")
|
||||||
@@ -568,6 +645,12 @@ func (c Config) validateProduction() error {
|
|||||||
if c.CursorSigningSecret == c.ShareTokenSecret {
|
if c.CursorSigningSecret == c.ShareTokenSecret {
|
||||||
return errors.New("production API signing secrets must be distinct")
|
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) {
|
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")
|
return errors.New("production RTC credentials must use a real app ID, Volcengine token issuer and non-placeholder app key")
|
||||||
}
|
}
|
||||||
@@ -578,6 +661,9 @@ func (c Config) validateProduction() error {
|
|||||||
if c.ServiceName == "callbacks" && isPlaceholderSecret(c.ProviderCallbackSecret) {
|
if c.ServiceName == "callbacks" && isPlaceholderSecret(c.ProviderCallbackSecret) {
|
||||||
return errors.New("production PROVIDER_CALLBACK_SECRET must not use a local or example value")
|
return errors.New("production PROVIDER_CALLBACK_SECRET must not use a local or example value")
|
||||||
}
|
}
|
||||||
|
if c.ServiceName == "callbacks" && isPlaceholderSecret(c.PlatformHealthSecret) {
|
||||||
|
return errors.New("production PLATFORM_HEALTH_CALLBACK_SECRET must not use a local or example value")
|
||||||
|
}
|
||||||
if c.ServiceName == "worker" {
|
if c.ServiceName == "worker" {
|
||||||
if c.VoiceProvider != "volcengine" || len(c.RTCAppID) != 24 || isLocalValue(c.RTCAppID) || !isHTTPSURL(c.VolcRTCBaseURL) || isLocalValue(c.VolcRTCBaseURL) || isPlaceholderSecret(c.VolcAccessKeyID) || isPlaceholderSecret(c.VolcSecretAccessKey) {
|
if c.VoiceProvider != "volcengine" || len(c.RTCAppID) != 24 || isLocalValue(c.RTCAppID) || !isHTTPSURL(c.VolcRTCBaseURL) || isLocalValue(c.VolcRTCBaseURL) || isPlaceholderSecret(c.VolcAccessKeyID) || isPlaceholderSecret(c.VolcSecretAccessKey) {
|
||||||
return errors.New("production voice provider must use signed Volcengine RTC HTTPS with non-placeholder AK/SK")
|
return errors.New("production voice provider must use signed Volcengine RTC HTTPS with non-placeholder AK/SK")
|
||||||
@@ -596,6 +682,12 @@ func (c Config) validateProduction() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c.ServiceName == "api" || c.ServiceName == "worker" {
|
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 {
|
if !c.ObjectStoreSecure || !c.ObjectStorePublicSecure {
|
||||||
return errors.New("production object storage must use TLS")
|
return errors.New("production object storage must use TLS")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
func TestLoadUsesServicePortDefaults(t *testing.T) {
|
func TestLoadUsesServicePortDefaults(t *testing.T) {
|
||||||
t.Setenv("HTTP_ADDR", "")
|
t.Setenv("HTTP_ADDR", "")
|
||||||
t.Setenv("APP_ENV", "test")
|
t.Setenv("APP_ENV", "test")
|
||||||
|
t.Setenv("PLATFORM_HEALTH_CALLBACK_SECRET", strings.Repeat("h", 32))
|
||||||
|
|
||||||
tests := map[string]string{
|
tests := map[string]string{
|
||||||
"api": "0.0.0.0:58080",
|
"api": "0.0.0.0:58080",
|
||||||
@@ -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) {
|
func TestLoadReadsSecretsFromAbsoluteFiles(t *testing.T) {
|
||||||
secret := strings.Repeat("k", 40)
|
secret := strings.Repeat("k", 40)
|
||||||
path := filepath.Join(t.TempDir(), "ark-api-key")
|
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.AuthIssuerURL = "https://identity.example.com/realms/voice"
|
||||||
cfg.AuthJWKSURL = "https://identity.example.com/realms/voice/certs"
|
cfg.AuthJWKSURL = "https://identity.example.com/realms/voice/certs"
|
||||||
cfg.DeviceLocalSecret = strings.Repeat("d", 40)
|
cfg.DeviceLocalSecret = strings.Repeat("d", 40)
|
||||||
cfg.DeviceSecretDirectory = t.TempDir()
|
cfg.DeviceCredentialMasterKey = strings.Repeat("ab", 32)
|
||||||
cfg.RTCTokenSecret = strings.Repeat("t", 40)
|
cfg.RTCTokenSecret = strings.Repeat("t", 40)
|
||||||
cfg.RTCTokenIssuer = "volcengine"
|
cfg.RTCTokenIssuer = "volcengine"
|
||||||
cfg.RTCAppKey = strings.Repeat("v", 40)
|
cfg.RTCAppKey = strings.Repeat("v", 40)
|
||||||
cfg.CursorSigningSecret = strings.Repeat("c", 40)
|
cfg.CursorSigningSecret = strings.Repeat("c", 40)
|
||||||
cfg.ShareTokenSecret = strings.Repeat("h", 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.RTCAppID = "661e7ae5543cf00000000000"
|
||||||
cfg.ObjectStoreEndpoint = "objects.internal.example.com:9000"
|
cfg.ObjectStoreEndpoint = "objects.internal.example.com:9000"
|
||||||
cfg.ObjectStorePublic = "objects.example.com"
|
cfg.ObjectStorePublic = "objects.example.com"
|
||||||
@@ -150,7 +179,7 @@ func TestProductionRejectsReusedSigningSecrets(t *testing.T) {
|
|||||||
cfg.AuthIssuerURL = "https://identity.example.com/realms/voice"
|
cfg.AuthIssuerURL = "https://identity.example.com/realms/voice"
|
||||||
cfg.AuthJWKSURL = "https://identity.example.com/realms/voice/certs"
|
cfg.AuthJWKSURL = "https://identity.example.com/realms/voice/certs"
|
||||||
cfg.DeviceLocalSecret = strings.Repeat("x", 40)
|
cfg.DeviceLocalSecret = strings.Repeat("x", 40)
|
||||||
cfg.DeviceSecretDirectory = t.TempDir()
|
cfg.DeviceCredentialMasterKey = strings.Repeat("cd", 32)
|
||||||
cfg.RTCTokenSecret = strings.Repeat("x", 40)
|
cfg.RTCTokenSecret = strings.Repeat("x", 40)
|
||||||
cfg.RTCTokenIssuer = "volcengine"
|
cfg.RTCTokenIssuer = "volcengine"
|
||||||
cfg.RTCAppKey = strings.Repeat("v", 40)
|
cfg.RTCAppKey = strings.Repeat("v", 40)
|
||||||
@@ -196,6 +225,7 @@ func TestProductionWorkerRequiresRealArkContentGenerator(t *testing.T) {
|
|||||||
cfg.ObjectStoreSecretKey = strings.Repeat("s", 40)
|
cfg.ObjectStoreSecretKey = strings.Repeat("s", 40)
|
||||||
cfg.ObjectStoreSecure = true
|
cfg.ObjectStoreSecure = true
|
||||||
cfg.ObjectStorePublicSecure = true
|
cfg.ObjectStorePublicSecure = true
|
||||||
|
cfg.MemoryToolSigningSecret = strings.Repeat("m", 40)
|
||||||
if err = cfg.Validate(); err == nil || !strings.Contains(err.Error(), "content generation") {
|
if err = cfg.Validate(); err == nil || !strings.Contains(err.Error(), "content generation") {
|
||||||
t.Fatalf("Validate() error=%v, want local content generator production gate", err)
|
t.Fatalf("Validate() error=%v, want local content generator production gate", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
package contentmodel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"data-terminal-cloud/internal/audit"
|
||||||
|
"data-terminal-cloud/internal/database"
|
||||||
|
"data-terminal-cloud/internal/extraction"
|
||||||
|
"data-terminal-cloud/internal/identity"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAccessDenied = errors.New("content model access denied")
|
||||||
|
ErrNotFound = errors.New("content model not found")
|
||||||
|
ErrInvalid = errors.New("invalid content model configuration")
|
||||||
|
ErrVersion = errors.New("content model version conflict")
|
||||||
|
ErrTestRequired = errors.New("content model must pass a connection test before activation")
|
||||||
|
ErrActiveRevoke = errors.New("active content model must be replaced before revocation")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Model struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
ModelID string `json:"model_id"`
|
||||||
|
CredentialHint string `json:"credential_hint"`
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
TestStatus string `json:"test_status"`
|
||||||
|
LastTestedAt *time.Time `json:"last_tested_at,omitempty"`
|
||||||
|
LastErrorCode string `json:"last_error_code,omitempty"`
|
||||||
|
ActivatedAt *time.Time `json:"activated_at,omitempty"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateInput struct {
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
ModelID string `json:"model_id"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Registry struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
aead cipher.AEAD
|
||||||
|
fallback extraction.Generator
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
registry *Registry
|
||||||
|
audit *audit.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
type storedModel struct {
|
||||||
|
Model
|
||||||
|
tenantInternalID string
|
||||||
|
encrypted []byte
|
||||||
|
nonce []byte
|
||||||
|
keyVersion int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type actor struct {
|
||||||
|
tenantID, tenantPublicID, userID, userPublicID string
|
||||||
|
canManage bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRegistry(pool *pgxpool.Pool, masterKeyHex string, fallback extraction.Generator) (*Registry, error) {
|
||||||
|
if pool == nil || fallback == nil {
|
||||||
|
return nil, errors.New("content model database and fallback generator are required")
|
||||||
|
}
|
||||||
|
key, err := hex.DecodeString(strings.TrimSpace(masterKeyHex))
|
||||||
|
if err != nil || len(key) != 32 {
|
||||||
|
return nil, errors.New("content model master key must be 32 bytes encoded as 64 hexadecimal characters")
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create content model cipher: %w", err)
|
||||||
|
}
|
||||||
|
aead, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create content model AEAD: %w", err)
|
||||||
|
}
|
||||||
|
return &Registry{pool: pool, aead: aead, fallback: fallback}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(registry *Registry, auditRepository *audit.Repository) (*Service, error) {
|
||||||
|
if registry == nil || auditRepository == nil {
|
||||||
|
return nil, errors.New("content model registry and audit repository are required")
|
||||||
|
}
|
||||||
|
return &Service{registry: registry, audit: auditRepository}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate resolves the active tenant model at execution time. This makes an
|
||||||
|
// activation change effective for the next summary job without restarting the worker.
|
||||||
|
func (r *Registry) Generate(ctx context.Context, input extraction.GenerationInput) (extraction.GenerationResult, error) {
|
||||||
|
if strings.TrimSpace(input.TenantID) == "" {
|
||||||
|
return r.fallback.Generate(ctx, input)
|
||||||
|
}
|
||||||
|
item, err := r.active(ctx, input.TenantID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return r.fallback.Generate(ctx, input)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return extraction.GenerationResult{}, err
|
||||||
|
}
|
||||||
|
generator, err := r.generator(item)
|
||||||
|
if err != nil {
|
||||||
|
return extraction.GenerationResult{}, err
|
||||||
|
}
|
||||||
|
return generator.Generate(ctx, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]Model, error) {
|
||||||
|
current, err := s.resolveActor(ctx, principal, tenantPublicID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := s.registry.pool.Query(ctx, modelSelect+` WHERE cm.tenant_id=$1::uuid AND cm.status<>'revoked' ORDER BY (cm.status='active') DESC,cm.updated_at DESC`, current.tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []Model{}
|
||||||
|
for rows.Next() {
|
||||||
|
item, scanErr := scanModel(rows)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, scanErr
|
||||||
|
}
|
||||||
|
items = append(items, item.Model)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Create(ctx context.Context, principal identity.Principal, input CreateInput, requestID string) (Model, error) {
|
||||||
|
normalizeInput(&input)
|
||||||
|
if err := validateInput(input); err != nil {
|
||||||
|
return Model{}, err
|
||||||
|
}
|
||||||
|
current, err := s.resolveActor(ctx, principal, input.TenantID)
|
||||||
|
if err != nil {
|
||||||
|
return Model{}, err
|
||||||
|
}
|
||||||
|
if !current.canManage {
|
||||||
|
return Model{}, ErrAccessDenied
|
||||||
|
}
|
||||||
|
var result Model
|
||||||
|
err = database.Transact(ctx, s.registry.pool, func(tx pgx.Tx) error {
|
||||||
|
publicID := uuid.NewString()
|
||||||
|
nonce := make([]byte, s.registry.aead.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
const keyVersion int64 = 1
|
||||||
|
encrypted := s.registry.aead.Seal(nil, nonce, []byte(input.APIKey), modelAAD(current.tenantID, publicID, keyVersion))
|
||||||
|
row := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO content_model_config(public_id,tenant_id,display_name,provider,base_url,model_id,encrypted_api_key,nonce,key_version,credential_hint,temperature,max_tokens,created_by,updated_by)
|
||||||
|
VALUES($1::uuid,$2::uuid,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13::uuid,$13::uuid)
|
||||||
|
RETURNING public_id::text,$14::text,display_name,provider,base_url,model_id,credential_hint,temperature::float8,max_tokens,status,test_status,last_tested_at,COALESCE(last_error_code,''),activated_at,version,created_at,updated_at,tenant_id::text,encrypted_api_key,nonce,key_version`,
|
||||||
|
publicID, current.tenantID, input.DisplayName, input.Provider, input.BaseURL, input.ModelID, encrypted, nonce, keyVersion, credentialHint(input.APIKey), input.Temperature, input.MaxTokens, current.userID, current.tenantPublicID)
|
||||||
|
item, scanErr := scanModel(row)
|
||||||
|
if scanErr != nil {
|
||||||
|
return scanErr
|
||||||
|
}
|
||||||
|
result = item.Model
|
||||||
|
resourceID := result.ID
|
||||||
|
return s.audit.Record(ctx, tx, audit.Event{TenantID: ¤t.tenantID, ActorType: "user", ActorPublicID: ¤t.userPublicID, Action: "content_model.create", ResourceType: "content_model", ResourceID: &resourceID, RequestID: requestID, Purpose: "配置聊天内容总结模型", Result: "success", Detail: map[string]any{"provider": result.Provider, "model_id": result.ModelID}})
|
||||||
|
})
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Test(ctx context.Context, principal identity.Principal, tenantPublicID, publicID, requestID string) (Model, error) {
|
||||||
|
current, err := s.resolveActor(ctx, principal, tenantPublicID)
|
||||||
|
if err != nil || !current.canManage {
|
||||||
|
return Model{}, ErrAccessDenied
|
||||||
|
}
|
||||||
|
item, err := s.get(ctx, current.tenantID, publicID)
|
||||||
|
if err != nil {
|
||||||
|
return Model{}, err
|
||||||
|
}
|
||||||
|
generator, err := s.registry.generator(item)
|
||||||
|
testStatus, errorCode := "passed", ""
|
||||||
|
if err == nil {
|
||||||
|
_, err = generator.Generate(ctx, extraction.GenerationInput{TenantID: current.tenantID, Content: "模型连通性测试,不包含长者或家庭数据。"})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
testStatus, errorCode = "failed", normalizedErrorCode(err)
|
||||||
|
}
|
||||||
|
var result Model
|
||||||
|
auditResult := "success"
|
||||||
|
if testStatus != "passed" {
|
||||||
|
auditResult = "failure"
|
||||||
|
}
|
||||||
|
updateErr := database.Transact(ctx, s.registry.pool, func(tx pgx.Tx) error {
|
||||||
|
row := tx.QueryRow(ctx, modelUpdateReturning, current.tenantID, publicID, testStatus, errorCode, current.userID, current.tenantPublicID)
|
||||||
|
updated, scanErr := scanModel(row)
|
||||||
|
if scanErr != nil {
|
||||||
|
return scanErr
|
||||||
|
}
|
||||||
|
result = updated.Model
|
||||||
|
resourceID := result.ID
|
||||||
|
return s.audit.Record(ctx, tx, audit.Event{TenantID: ¤t.tenantID, ActorType: "user", ActorPublicID: ¤t.userPublicID, Action: "content_model.test", ResourceType: "content_model", ResourceID: &resourceID, RequestID: requestID, Purpose: "验证总结模型连通性", Result: auditResult, Detail: map[string]any{"test_status": testStatus, "error_code": errorCode}})
|
||||||
|
})
|
||||||
|
if updateErr != nil {
|
||||||
|
return Model{}, updateErr
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Activate(ctx context.Context, principal identity.Principal, tenantPublicID, publicID, requestID string, expectedVersion int64) (Model, error) {
|
||||||
|
current, err := s.resolveActor(ctx, principal, tenantPublicID)
|
||||||
|
if err != nil || !current.canManage {
|
||||||
|
return Model{}, ErrAccessDenied
|
||||||
|
}
|
||||||
|
var result Model
|
||||||
|
err = database.Transact(ctx, s.registry.pool, func(tx pgx.Tx) error {
|
||||||
|
var internalID string
|
||||||
|
var version int64
|
||||||
|
var testStatus, status string
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT id::text,version,test_status,status FROM content_model_config WHERE tenant_id=$1::uuid AND public_id=$2::uuid FOR UPDATE`, current.tenantID, publicID).Scan(&internalID, &version, &testStatus, &status); errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return ErrNotFound
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if version != expectedVersion {
|
||||||
|
return ErrVersion
|
||||||
|
}
|
||||||
|
if status == "revoked" {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if testStatus != "passed" {
|
||||||
|
return ErrTestRequired
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE content_model_config SET status='inactive',activated_at=NULL,updated_at=now(),version=version+1,updated_by=$2::uuid WHERE tenant_id=$1::uuid AND status='active' AND id<>$3::uuid`, current.tenantID, current.userID, internalID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
row := tx.QueryRow(ctx, `UPDATE content_model_config cm SET status='active',activated_at=now(),updated_at=now(),version=version+1,updated_by=$3::uuid WHERE tenant_id=$1::uuid AND public_id=$2::uuid RETURNING `+modelReturning, current.tenantID, publicID, current.userID, current.tenantPublicID)
|
||||||
|
updated, scanErr := scanModel(row)
|
||||||
|
if scanErr != nil {
|
||||||
|
return scanErr
|
||||||
|
}
|
||||||
|
result = updated.Model
|
||||||
|
resourceID := result.ID
|
||||||
|
return s.audit.Record(ctx, tx, audit.Event{TenantID: ¤t.tenantID, ActorType: "user", ActorPublicID: ¤t.userPublicID, Action: "content_model.activate", ResourceType: "content_model", ResourceID: &resourceID, RequestID: requestID, Purpose: "激活聊天总结模型", Result: "success", Detail: map[string]any{"model_id": result.ModelID}})
|
||||||
|
})
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Revoke(ctx context.Context, principal identity.Principal, tenantPublicID, publicID, requestID string, expectedVersion int64) (Model, error) {
|
||||||
|
current, err := s.resolveActor(ctx, principal, tenantPublicID)
|
||||||
|
if err != nil || !current.canManage {
|
||||||
|
return Model{}, ErrAccessDenied
|
||||||
|
}
|
||||||
|
var result Model
|
||||||
|
err = database.Transact(ctx, s.registry.pool, func(tx pgx.Tx) error {
|
||||||
|
item, lockErr := s.getTx(ctx, tx, current.tenantID, publicID, true)
|
||||||
|
if lockErr != nil {
|
||||||
|
return lockErr
|
||||||
|
}
|
||||||
|
if item.Version != expectedVersion {
|
||||||
|
return ErrVersion
|
||||||
|
}
|
||||||
|
if item.Status == "active" {
|
||||||
|
return ErrActiveRevoke
|
||||||
|
}
|
||||||
|
row := tx.QueryRow(ctx, `UPDATE content_model_config cm SET status='revoked',encrypted_api_key=decode('','hex'),nonce=decode('','hex'),updated_at=now(),version=version+1,updated_by=$3::uuid WHERE tenant_id=$1::uuid AND public_id=$2::uuid RETURNING `+modelReturning, current.tenantID, publicID, current.userID, current.tenantPublicID)
|
||||||
|
updated, scanErr := scanModel(row)
|
||||||
|
if scanErr != nil {
|
||||||
|
return scanErr
|
||||||
|
}
|
||||||
|
result = updated.Model
|
||||||
|
resourceID := result.ID
|
||||||
|
return s.audit.Record(ctx, tx, audit.Event{TenantID: ¤t.tenantID, ActorType: "user", ActorPublicID: ¤t.userPublicID, Action: "content_model.revoke", ResourceType: "content_model", ResourceID: &resourceID, RequestID: requestID, Purpose: "移除未激活的总结模型", Result: "success"})
|
||||||
|
})
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) active(ctx context.Context, tenantID string) (storedModel, error) {
|
||||||
|
row := r.pool.QueryRow(ctx, modelSelect+` WHERE cm.tenant_id=$1::uuid AND cm.status='active'`, tenantID)
|
||||||
|
return scanModel(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) generator(item storedModel) (extraction.Generator, error) {
|
||||||
|
key, err := r.aead.Open(nil, item.nonce, item.encrypted, modelAAD(item.tenantInternalID, item.ID, item.keyVersion))
|
||||||
|
if err != nil || len(key) < 8 {
|
||||||
|
return nil, errors.New("content model credential cannot be decrypted")
|
||||||
|
}
|
||||||
|
return extraction.NewCompatibleGenerator(item.Provider, item.BaseURL, string(key), item.ModelID, item.Temperature, item.MaxTokens, 30*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) get(ctx context.Context, tenantID, publicID string) (storedModel, error) {
|
||||||
|
return s.getTx(ctx, s.registry.pool, tenantID, publicID, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
type rowQueryer interface {
|
||||||
|
QueryRow(context.Context, string, ...any) pgx.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) getTx(ctx context.Context, q rowQueryer, tenantID, publicID string, lock bool) (storedModel, error) {
|
||||||
|
lockClause := ""
|
||||||
|
if lock {
|
||||||
|
lockClause = " FOR UPDATE OF cm"
|
||||||
|
}
|
||||||
|
item, err := scanModel(q.QueryRow(ctx, modelSelect+` WHERE cm.tenant_id=$1::uuid AND cm.public_id=$2::uuid`+lockClause, tenantID, publicID))
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return storedModel{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return item, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveActor(ctx context.Context, principal identity.Principal, tenantPublicID string) (actor, error) {
|
||||||
|
var result actor
|
||||||
|
err := s.registry.pool.QueryRow(ctx, `
|
||||||
|
SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,
|
||||||
|
ua.account_kind='operator' AND ua.platform_role IN ('ops_operator','ops_admin')
|
||||||
|
FROM tenant t JOIN user_account ua ON ua.tenant_id=t.id AND ua.status='active'
|
||||||
|
WHERE t.public_id=$1::uuid AND t.status='active' AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3
|
||||||
|
`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&result.tenantID, &result.tenantPublicID, &result.userID, &result.userPublicID, &result.canManage)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return actor{}, ErrAccessDenied
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeInput(input *CreateInput) {
|
||||||
|
input.TenantID = strings.TrimSpace(input.TenantID)
|
||||||
|
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||||
|
input.Provider = strings.ToLower(strings.TrimSpace(input.Provider))
|
||||||
|
input.BaseURL = strings.TrimRight(strings.TrimSpace(input.BaseURL), "/")
|
||||||
|
input.ModelID = strings.TrimSpace(input.ModelID)
|
||||||
|
input.APIKey = strings.TrimSpace(input.APIKey)
|
||||||
|
if input.Temperature == 0 {
|
||||||
|
input.Temperature = 0.2
|
||||||
|
}
|
||||||
|
if input.MaxTokens == 0 {
|
||||||
|
input.MaxTokens = 1200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateInput(input CreateInput) error {
|
||||||
|
parsed, err := url.Parse(input.BaseURL)
|
||||||
|
validProvider := extraction.SupportsCompatibleProvider(input.Provider)
|
||||||
|
validURL := err == nil && parsed.Host != "" && (parsed.Scheme == "https" || (parsed.Scheme == "http" && isApprovedLocalModelHost(parsed.Hostname())))
|
||||||
|
if !validUUID(input.TenantID) || len([]rune(input.DisplayName)) < 1 || len([]rune(input.DisplayName)) > 120 || !validProvider || !validURL || input.ModelID == "" || len(input.APIKey) < 8 || input.Temperature < 0 || input.Temperature > 2 || input.MaxTokens < 256 || input.MaxTokens > 8192 {
|
||||||
|
return ErrInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isApprovedLocalModelHost(host string) bool {
|
||||||
|
host = strings.ToLower(strings.TrimSpace(host))
|
||||||
|
return host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "ollama"
|
||||||
|
}
|
||||||
|
|
||||||
|
func credentialHint(key string) string {
|
||||||
|
runes := []rune(strings.TrimSpace(key))
|
||||||
|
if len(runes) <= 4 {
|
||||||
|
return "••••"
|
||||||
|
}
|
||||||
|
return "••••" + string(runes[len(runes)-4:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedErrorCode(err error) string {
|
||||||
|
value := strings.ToLower(err.Error())
|
||||||
|
switch {
|
||||||
|
case strings.Contains(value, "401"), strings.Contains(value, "403"):
|
||||||
|
return "AUTHENTICATION_FAILED"
|
||||||
|
case strings.Contains(value, "429"):
|
||||||
|
return "RATE_LIMITED"
|
||||||
|
case strings.Contains(value, "timeout"), strings.Contains(value, "deadline"):
|
||||||
|
return "TIMEOUT"
|
||||||
|
case strings.Contains(value, "validation"), strings.Contains(value, "decode"):
|
||||||
|
return "INVALID_RESPONSE"
|
||||||
|
default:
|
||||||
|
return "CONNECTION_FAILED"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelAAD(tenantID, publicID string, version int64) []byte {
|
||||||
|
return []byte(fmt.Sprintf("content-model-credential-v1\n%s\n%s\n%d", tenantID, publicID, version))
|
||||||
|
}
|
||||||
|
|
||||||
|
func validUUID(value string) bool { _, err := uuid.Parse(value); return err == nil }
|
||||||
|
|
||||||
|
const modelSelect = `SELECT cm.public_id::text,t.public_id::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version FROM content_model_config cm JOIN tenant t ON t.id=cm.tenant_id`
|
||||||
|
const modelReturning = `cm.public_id::text,$4::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version`
|
||||||
|
const modelUpdateReturning = `UPDATE content_model_config cm SET test_status=$3,last_tested_at=now(),last_error_code=NULLIF($4,''),updated_at=now(),version=version+1,updated_by=$5::uuid WHERE cm.tenant_id=$1::uuid AND cm.public_id=$2::uuid RETURNING cm.public_id::text,$6::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version`
|
||||||
|
|
||||||
|
type rowScanner interface{ Scan(...any) error }
|
||||||
|
|
||||||
|
func scanModel(row rowScanner) (storedModel, error) {
|
||||||
|
var item storedModel
|
||||||
|
err := row.Scan(&item.ID, &item.TenantID, &item.DisplayName, &item.Provider, &item.BaseURL, &item.ModelID, &item.CredentialHint, &item.Temperature, &item.MaxTokens, &item.Status, &item.TestStatus, &item.LastTestedAt, &item.LastErrorCode, &item.ActivatedAt, &item.Version, &item.CreatedAt, &item.UpdatedAt, &item.tenantInternalID, &item.encrypted, &item.nonce, &item.keyVersion)
|
||||||
|
return item, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package contentmodel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"data-terminal-cloud/internal/audit"
|
||||||
|
"data-terminal-cloud/internal/extraction"
|
||||||
|
"data-terminal-cloud/internal/identity"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServiceTestsAndActivatesCompatibleModel(t *testing.T) {
|
||||||
|
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if databaseURL == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL is not set")
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": `{"meaningful":false,"story_worthy":false,"decision_reason":"连通性测试内容不形成故事","summary":"","keywords":[],"open_topic":"","story_title":"","story_content":"","fact_content":"","confidence":0.99}`}}}})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
registry, err := NewRegistry(pool, "1f1e1d1c1b1a191817161514131211100f0e0d0c0b0a09080706050403020100", extraction.DeterministicGenerator{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
service, err := NewService(registry, audit.NewRepository())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
principal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
|
||||||
|
customerPrincipal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
||||||
|
if _, err := service.Create(ctx, customerPrincipal, CreateInput{
|
||||||
|
TenantID: "10000000-0000-0000-0000-000000000010", DisplayName: "家庭端不应创建", Provider: "openai_compatible",
|
||||||
|
BaseURL: server.URL + "/v1", ModelID: "customer-model", APIKey: "integration-key", Temperature: 0.1, MaxTokens: 512,
|
||||||
|
}, "content-model-customer-create"); !errors.Is(err, ErrAccessDenied) {
|
||||||
|
t.Fatalf("customer content model create error=%v want access denied", err)
|
||||||
|
}
|
||||||
|
created, err := service.Create(ctx, principal, CreateInput{
|
||||||
|
TenantID: "10000000-0000-0000-0000-000000000010", DisplayName: "集成测试模型", Provider: "openai_compatible",
|
||||||
|
BaseURL: server.URL + "/v1", ModelID: "test-model", APIKey: "integration-key", Temperature: 0.1, MaxTokens: 512,
|
||||||
|
}, "content-model-integration-create")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE resource_id=$1::uuid`, created.ID)
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM content_model_config WHERE public_id=$1::uuid`, created.ID)
|
||||||
|
}()
|
||||||
|
tested, err := service.Test(ctx, principal, created.TenantID, created.ID, "content-model-integration-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tested.TestStatus != "passed" || tested.Version != created.Version+1 {
|
||||||
|
t.Fatalf("tested model=%+v", tested)
|
||||||
|
}
|
||||||
|
activated, err := service.Activate(ctx, principal, created.TenantID, created.ID, "content-model-integration-activate", tested.Version)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if activated.Status != "active" || activated.Version != tested.Version+1 {
|
||||||
|
t.Fatalf("activated model=%+v", activated)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package contentmodel
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidateInputAcceptsSupportedDomesticProviders(t *testing.T) {
|
||||||
|
providers := []string{"ark", "dashscope", "deepseek", "moonshot", "zhipu", "qianfan", "hunyuan", "minimax", "baichuan", "siliconflow"}
|
||||||
|
for _, provider := range providers {
|
||||||
|
t.Run(provider, func(t *testing.T) {
|
||||||
|
input := CreateInput{
|
||||||
|
TenantID: "10000000-0000-0000-0000-000000000001", DisplayName: provider,
|
||||||
|
Provider: provider, BaseURL: "https://model.example.com/v1", ModelID: "summary-model",
|
||||||
|
APIKey: "test-api-key", Temperature: 0.2, MaxTokens: 1200,
|
||||||
|
}
|
||||||
|
if err := validateInput(input); err != nil {
|
||||||
|
t.Fatalf("supported provider was rejected: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateInputRejectsUnknownProvider(t *testing.T) {
|
||||||
|
input := CreateInput{
|
||||||
|
TenantID: "10000000-0000-0000-0000-000000000001", DisplayName: "unknown",
|
||||||
|
Provider: "unknown", BaseURL: "https://model.example.com/v1", ModelID: "summary-model",
|
||||||
|
APIKey: "test-api-key", Temperature: 0.2, MaxTokens: 1200,
|
||||||
|
}
|
||||||
|
if err := validateInput(input); err == nil {
|
||||||
|
t.Fatal("unknown provider was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateInputAllowsComposeOllamaEndpoint(t *testing.T) {
|
||||||
|
input := CreateInput{
|
||||||
|
TenantID: "10000000-0000-0000-0000-000000000001", DisplayName: "本地 Qwen",
|
||||||
|
Provider: "openai_compatible", BaseURL: "http://ollama:11434/v1", ModelID: "qwen2.5:7b",
|
||||||
|
APIKey: "ollama-local-key", Temperature: 0.1, MaxTokens: 1200,
|
||||||
|
}
|
||||||
|
if err := validateInput(input); err != nil {
|
||||||
|
t.Fatalf("compose Ollama endpoint was rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateInputRejectsUnapprovedPlainHTTPEndpoint(t *testing.T) {
|
||||||
|
input := CreateInput{
|
||||||
|
TenantID: "10000000-0000-0000-0000-000000000001", DisplayName: "不安全端点",
|
||||||
|
Provider: "openai_compatible", BaseURL: "http://metadata.internal/v1", ModelID: "model",
|
||||||
|
APIKey: "test-api-key", Temperature: 0.1, MaxTokens: 1200,
|
||||||
|
}
|
||||||
|
if err := validateInput(input); err == nil {
|
||||||
|
t.Fatal("unapproved plaintext endpoint was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¤tVersion)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package diagnostics
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -69,6 +71,30 @@ type AcceptanceAlertState struct {
|
|||||||
BlockingItemsTruncated bool `json:"blocking_items_truncated"`
|
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 {
|
type AcceptanceBlockingAlert struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
RuleCode string `json:"rule_code"`
|
RuleCode string `json:"rule_code"`
|
||||||
@@ -103,6 +129,8 @@ type AcceptanceSnapshot struct {
|
|||||||
ExternalDependencies []AcceptanceDependencyState `json:"external_dependencies"`
|
ExternalDependencies []AcceptanceDependencyState `json:"external_dependencies"`
|
||||||
Outbox AcceptanceOutboxState `json:"outbox"`
|
Outbox AcceptanceOutboxState `json:"outbox"`
|
||||||
Alerts AcceptanceAlertState `json:"alerts"`
|
Alerts AcceptanceAlertState `json:"alerts"`
|
||||||
|
PlatformHealth AcceptancePlatformHealthState `json:"platform_health"`
|
||||||
|
ProductionInfrastructure AcceptanceProductionInfrastructureState `json:"production_infrastructure"`
|
||||||
Gates []AcceptanceGate `json:"gates"`
|
Gates []AcceptanceGate `json:"gates"`
|
||||||
OverallGateSatisfied bool `json:"overall_gate_satisfied"`
|
OverallGateSatisfied bool `json:"overall_gate_satisfied"`
|
||||||
Disclaimer string `json:"disclaimer"`
|
Disclaimer string `json:"disclaimer"`
|
||||||
@@ -275,6 +303,12 @@ func buildAcceptanceSnapshot(ctx context.Context, tx pgx.Tx, tenantPublicID, ten
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return AcceptanceSnapshot{}, err
|
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")
|
runtimeSatisfied := input.Environment == "LAB" || (snapshot.RuntimeBuild.Version == input.CandidateVersion && snapshot.RuntimeBuild.Commit != "unknown" && snapshot.RuntimeBuild.BuildTime != "unknown")
|
||||||
dependenciesSatisfied := true
|
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: "integration_issues_clear", Satisfied: snapshot.IntegrationIssues.ReleaseGateSatisfied, Summary: "不得存在未关闭的高或严重级联调问题"},
|
||||||
{Code: "outbox_clear", Satisfied: outboxSatisfied, Summary: "租户及平台级待处理、处理中和死信任务必须清零"},
|
{Code: "outbox_clear", Satisfied: outboxSatisfied, Summary: "租户及平台级待处理、处理中和死信任务必须清零"},
|
||||||
{Code: "critical_alerts_clear", Satisfied: alertsSatisfied, 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
|
snapshot.OverallGateSatisfied = true
|
||||||
for _, gate := range snapshot.Gates {
|
for _, gate := range snapshot.Gates {
|
||||||
@@ -298,6 +334,124 @@ func buildAcceptanceSnapshot(ctx context.Context, tx pgx.Tx, tenantPublicID, ten
|
|||||||
return snapshot, nil
|
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 {
|
func acceptanceRuntimeSummary(environment string) string {
|
||||||
if environment == "LAB" {
|
if environment == "LAB" {
|
||||||
return "实验室环境允许开发构建,但候选版本与镜像摘要必须明确"
|
return "实验室环境允许开发构建,但候选版本与镜像摘要必须明确"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func TestAcceptanceSnapshotIsAuditedIdempotentAndTenantScoped(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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"
|
tenantPublicID := "10000000-0000-0000-0000-000000000010"
|
||||||
key := "acceptance-snapshot-" + uuid.NewString()
|
key := "acceptance-snapshot-" + uuid.NewString()
|
||||||
requestID := "acceptance-snapshot-test-" + uuid.NewString()
|
requestID := "acceptance-snapshot-test-" + uuid.NewString()
|
||||||
@@ -59,7 +59,7 @@ func TestAcceptanceSnapshotIsAuditedIdempotentAndTenantScoped(t *testing.T) {
|
|||||||
if err != nil || !created {
|
if err != nil || !created {
|
||||||
t.Fatalf("first=%+v created=%t err=%v", first, created, err)
|
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)
|
t.Fatalf("unexpected snapshot=%+v", first)
|
||||||
}
|
}
|
||||||
if first.ExternalDependencies[0].Dependency != "oidc_jwks" || first.ExternalDependencies[0].ServiceName != "api" {
|
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 {
|
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)
|
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)
|
replayed, createdAgain, err := service.ExportAcceptanceSnapshot(ctx, admin, tenantPublicID, key, requestID, input)
|
||||||
if err != nil || createdAgain || replayed.ID != first.ID || !replayed.GeneratedAt.Equal(first.GeneratedAt) {
|
if err != nil || createdAgain || replayed.ID != first.ID || !replayed.GeneratedAt.Equal(first.GeneratedAt) {
|
||||||
t.Fatalf("replayed=%+v created=%t err=%v", replayed, createdAgain, err)
|
t.Fatalf("replayed=%+v created=%t err=%v", replayed, createdAgain, err)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user