From 804ddb51f217ffda4f18eba334913b8621b5ed0f Mon Sep 17 00:00:00 2001 From: Tyke Chen <190473011+chentyke@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:00:13 +0800 Subject: [PATCH] feat: add FastAPI manager API compatibility baseline --- .dockerignore | 21 +- docs/manager-api-fastapi-compatibility.md | 267 + docs/manager-api-fastapi-migration.md | 290 + docs/manager-api-fastapi-test-report.md | 582 ++ main/manager-api-fastapi/.env.example | 20 + main/manager-api-fastapi/.gitignore | 16 + main/manager-api-fastapi/.python-version | 1 + main/manager-api-fastapi/Dockerfile | 54 + .../manager-api-fastapi/Dockerfile.migrations | 35 + main/manager-api-fastapi/Dockerfile.nginx | 14 + main/manager-api-fastapi/README.md | 38 + main/manager-api-fastapi/app/__init__.py | 1 + main/manager-api-fastapi/app/__main__.py | 7 + main/manager-api-fastapi/app/core/__init__.py | 1 + main/manager-api-fastapi/app/core/config.py | 72 + main/manager-api-fastapi/app/core/crypto.py | 63 + main/manager-api-fastapi/app/core/database.py | 96 + main/manager-api-fastapi/app/core/errors.py | 44 + main/manager-api-fastapi/app/core/i18n.py | 90 + main/manager-api-fastapi/app/core/ids.py | 56 + main/manager-api-fastapi/app/core/redis.py | 226 + .../manager-api-fastapi/app/core/responses.py | 66 + main/manager-api-fastapi/app/core/security.py | 181 + .../app/core/serialization.py | 105 + .../app/integrations/__init__.py | 1 + .../app/integrations/llm.py | 68 + .../app/integrations/mcp.py | 106 + .../app/integrations/mqtt_gateway.py | 63 + .../app/integrations/ragflow.py | 490 + .../app/integrations/voice_clone.py | 96 + .../app/integrations/voiceprint.py | 102 + main/manager-api-fastapi/app/jobs/__init__.py | 1 + main/manager-api-fastapi/app/jobs/tasks.py | 18 + main/manager-api-fastapi/app/jobs/worker.py | 135 + main/manager-api-fastapi/app/main.py | 217 + .../app/repositories/agent.py | 665 ++ .../app/repositories/config.py | 105 + .../app/repositories/correctword.py | 115 + .../app/repositories/device.py | 294 + .../app/repositories/knowledge.py | 344 + .../app/repositories/model.py | 243 + .../app/repositories/security.py | 153 + .../app/repositories/sys.py | 494 + .../app/repositories/timbre.py | 71 + .../app/repositories/voiceclone.py | 170 + .../app/routers/__init__.py | 30 + main/manager-api-fastapi/app/routers/agent.py | 386 + .../manager-api-fastapi/app/routers/config.py | 34 + .../app/routers/correctword.py | 97 + .../manager-api-fastapi/app/routers/device.py | 505 + .../app/routers/knowledge.py | 267 + main/manager-api-fastapi/app/routers/model.py | 178 + .../app/routers/security.py | 111 + main/manager-api-fastapi/app/routers/sys.py | 334 + .../manager-api-fastapi/app/routers/timbre.py | 74 + .../app/routers/voiceclone.py | 222 + .../app/schemas/__init__.py | 1 + main/manager-api-fastapi/app/schemas/agent.py | 238 + .../manager-api-fastapi/app/schemas/common.py | 57 + .../manager-api-fastapi/app/schemas/config.py | 25 + .../app/schemas/correctword.py | 9 + .../manager-api-fastapi/app/schemas/device.py | 145 + .../app/schemas/knowledge.py | 53 + main/manager-api-fastapi/app/schemas/model.py | 25 + .../app/schemas/security.py | 59 + main/manager-api-fastapi/app/schemas/sys.py | 45 + .../manager-api-fastapi/app/schemas/timbre.py | 15 + .../app/schemas/voiceclone.py | 19 + .../app/services/__init__.py | 1 + .../manager-api-fastapi/app/services/agent.py | 1973 ++++ .../app/services/config.py | 521 + .../app/services/correctword.py | 133 + .../app/services/device.py | 978 ++ .../app/services/java_validation.py | 21 + .../app/services/knowledge.py | 723 ++ .../manager-api-fastapi/app/services/model.py | 306 + .../app/services/security.py | 486 + main/manager-api-fastapi/app/services/sys.py | 715 ++ .../app/services/system_params.py | 50 + .../app/services/timbre.py | 157 + .../app/services/voiceclone.py | 334 + .../authenticated-route-results.json | 8757 +++++++++++++++++ .../compatibility/consumer-routes.json | 1164 +++ .../compatibility/contract-results.json | 2905 ++++++ .../compatibility/java-routes.json | 1701 ++++ .../compatibility/performance-results.json | 164 + .../compatibility/route-surface-results.json | 6749 +++++++++++++ .../deploy/nginx-entrypoint.sh | 16 + main/manager-api-fastapi/deploy/nginx.conf | 45 + main/manager-api-fastapi/docker-compose.yml | 93 + main/manager-api-fastapi/migration-pom.xml | 84 + .../migration/LiquibaseMigrationRunner.java | 57 + main/manager-api-fastapi/pyproject.toml | 75 + .../scripts/container-entrypoint.sh | 14 + .../scripts/extract_consumer_routes.py | 267 + .../scripts/extract_java_routes.py | 168 + .../scripts/isolated-env.sh | 146 + .../scripts/render_compatibility_doc.py | 640 ++ .../scripts/run-isolated-contract-tests.sh | 220 + .../scripts/run-migrations.sh | 51 + main/manager-api-fastapi/scripts/start-api.sh | 20 + .../manager-api-fastapi/scripts/start-jobs.sh | 14 + main/manager-api-fastapi/tests/__init__.py | 1 + .../tests/compatibility/__init__.py | 1 + .../authenticated_route_runner.py | 341 + .../compatibility/differential_runner.py | 952 ++ .../tests/compatibility/external_mock.py | 229 + .../tests/compatibility/performance_runner.py | 217 + .../compatibility/route_surface_runner.py | 97 + .../tests/compatibility/seed_contract_data.py | 286 + .../tests/compatibility/tcp_proxy.py | 70 + .../test_authenticated_route_runner.py | 134 + .../test_differential_reporting.py | 56 + .../tests/domain_support.py | 110 + .../tests/fixtures/agent-mcp-aes-golden.json | 17 + .../tests/fixtures/sm2-c1c3c2-golden.json | 8 + .../tests/integration/__init__.py | 1 + .../integration/test_isolated_runtime.py | 254 + .../tests/java/AgentMcpCryptoInteropCli.java | 19 + .../tests/java/RedisCodecVectors.java | 63 + .../tests/java/Sm2InteropCli.java | 18 + .../tests/test_agent_compatibility.py | 678 ++ .../tests/test_compatibility_document.py | 81 + .../tests/test_config_domain.py | 281 + .../tests/test_consumer_route_manifest.py | 84 + .../tests/test_core_compatibility.py | 226 + .../tests/test_deployment_artifacts.py | 192 + .../tests/test_deployment_runtime.py | 173 + main/manager-api-fastapi/tests/test_device.py | 348 + .../tests/test_java_route_manifest.py | 69 + .../tests/test_knowledge_ragflow.py | 447 + .../tests/test_model_timbre_correctword.py | 242 + .../tests/test_redis_java_interop.py | 136 + .../tests/test_schema_alias_compatibility.py | 37 + .../tests/test_security_domain.py | 239 + .../tests/test_sm2_interop.py | 86 + .../tests/test_sys_domain.py | 310 + .../tests/test_voiceclone.py | 234 + main/manager-api-fastapi/uv.lock | 1266 +++ scripts/restart-local-services.sh | 193 + 140 files changed, 47169 insertions(+), 1 deletion(-) create mode 100644 docs/manager-api-fastapi-compatibility.md create mode 100644 docs/manager-api-fastapi-migration.md create mode 100644 docs/manager-api-fastapi-test-report.md create mode 100644 main/manager-api-fastapi/.env.example create mode 100644 main/manager-api-fastapi/.gitignore create mode 100644 main/manager-api-fastapi/.python-version create mode 100644 main/manager-api-fastapi/Dockerfile create mode 100644 main/manager-api-fastapi/Dockerfile.migrations create mode 100644 main/manager-api-fastapi/Dockerfile.nginx create mode 100644 main/manager-api-fastapi/README.md create mode 100644 main/manager-api-fastapi/app/__init__.py create mode 100644 main/manager-api-fastapi/app/__main__.py create mode 100644 main/manager-api-fastapi/app/core/__init__.py create mode 100644 main/manager-api-fastapi/app/core/config.py create mode 100644 main/manager-api-fastapi/app/core/crypto.py create mode 100644 main/manager-api-fastapi/app/core/database.py create mode 100644 main/manager-api-fastapi/app/core/errors.py create mode 100644 main/manager-api-fastapi/app/core/i18n.py create mode 100644 main/manager-api-fastapi/app/core/ids.py create mode 100644 main/manager-api-fastapi/app/core/redis.py create mode 100644 main/manager-api-fastapi/app/core/responses.py create mode 100644 main/manager-api-fastapi/app/core/security.py create mode 100644 main/manager-api-fastapi/app/core/serialization.py create mode 100644 main/manager-api-fastapi/app/integrations/__init__.py create mode 100644 main/manager-api-fastapi/app/integrations/llm.py create mode 100644 main/manager-api-fastapi/app/integrations/mcp.py create mode 100644 main/manager-api-fastapi/app/integrations/mqtt_gateway.py create mode 100644 main/manager-api-fastapi/app/integrations/ragflow.py create mode 100644 main/manager-api-fastapi/app/integrations/voice_clone.py create mode 100644 main/manager-api-fastapi/app/integrations/voiceprint.py create mode 100644 main/manager-api-fastapi/app/jobs/__init__.py create mode 100644 main/manager-api-fastapi/app/jobs/tasks.py create mode 100644 main/manager-api-fastapi/app/jobs/worker.py create mode 100644 main/manager-api-fastapi/app/main.py create mode 100644 main/manager-api-fastapi/app/repositories/agent.py create mode 100644 main/manager-api-fastapi/app/repositories/config.py create mode 100644 main/manager-api-fastapi/app/repositories/correctword.py create mode 100644 main/manager-api-fastapi/app/repositories/device.py create mode 100644 main/manager-api-fastapi/app/repositories/knowledge.py create mode 100644 main/manager-api-fastapi/app/repositories/model.py create mode 100644 main/manager-api-fastapi/app/repositories/security.py create mode 100644 main/manager-api-fastapi/app/repositories/sys.py create mode 100644 main/manager-api-fastapi/app/repositories/timbre.py create mode 100644 main/manager-api-fastapi/app/repositories/voiceclone.py create mode 100644 main/manager-api-fastapi/app/routers/__init__.py create mode 100644 main/manager-api-fastapi/app/routers/agent.py create mode 100644 main/manager-api-fastapi/app/routers/config.py create mode 100644 main/manager-api-fastapi/app/routers/correctword.py create mode 100644 main/manager-api-fastapi/app/routers/device.py create mode 100644 main/manager-api-fastapi/app/routers/knowledge.py create mode 100644 main/manager-api-fastapi/app/routers/model.py create mode 100644 main/manager-api-fastapi/app/routers/security.py create mode 100644 main/manager-api-fastapi/app/routers/sys.py create mode 100644 main/manager-api-fastapi/app/routers/timbre.py create mode 100644 main/manager-api-fastapi/app/routers/voiceclone.py create mode 100644 main/manager-api-fastapi/app/schemas/__init__.py create mode 100644 main/manager-api-fastapi/app/schemas/agent.py create mode 100644 main/manager-api-fastapi/app/schemas/common.py create mode 100644 main/manager-api-fastapi/app/schemas/config.py create mode 100644 main/manager-api-fastapi/app/schemas/correctword.py create mode 100644 main/manager-api-fastapi/app/schemas/device.py create mode 100644 main/manager-api-fastapi/app/schemas/knowledge.py create mode 100644 main/manager-api-fastapi/app/schemas/model.py create mode 100644 main/manager-api-fastapi/app/schemas/security.py create mode 100644 main/manager-api-fastapi/app/schemas/sys.py create mode 100644 main/manager-api-fastapi/app/schemas/timbre.py create mode 100644 main/manager-api-fastapi/app/schemas/voiceclone.py create mode 100644 main/manager-api-fastapi/app/services/__init__.py create mode 100644 main/manager-api-fastapi/app/services/agent.py create mode 100644 main/manager-api-fastapi/app/services/config.py create mode 100644 main/manager-api-fastapi/app/services/correctword.py create mode 100644 main/manager-api-fastapi/app/services/device.py create mode 100644 main/manager-api-fastapi/app/services/java_validation.py create mode 100644 main/manager-api-fastapi/app/services/knowledge.py create mode 100644 main/manager-api-fastapi/app/services/model.py create mode 100644 main/manager-api-fastapi/app/services/security.py create mode 100644 main/manager-api-fastapi/app/services/sys.py create mode 100644 main/manager-api-fastapi/app/services/system_params.py create mode 100644 main/manager-api-fastapi/app/services/timbre.py create mode 100644 main/manager-api-fastapi/app/services/voiceclone.py create mode 100644 main/manager-api-fastapi/compatibility/authenticated-route-results.json create mode 100644 main/manager-api-fastapi/compatibility/consumer-routes.json create mode 100644 main/manager-api-fastapi/compatibility/contract-results.json create mode 100644 main/manager-api-fastapi/compatibility/java-routes.json create mode 100644 main/manager-api-fastapi/compatibility/performance-results.json create mode 100644 main/manager-api-fastapi/compatibility/route-surface-results.json create mode 100755 main/manager-api-fastapi/deploy/nginx-entrypoint.sh create mode 100644 main/manager-api-fastapi/deploy/nginx.conf create mode 100644 main/manager-api-fastapi/docker-compose.yml create mode 100644 main/manager-api-fastapi/migration-pom.xml create mode 100644 main/manager-api-fastapi/migration-src/xiaozhi/migration/LiquibaseMigrationRunner.java create mode 100644 main/manager-api-fastapi/pyproject.toml create mode 100755 main/manager-api-fastapi/scripts/container-entrypoint.sh create mode 100644 main/manager-api-fastapi/scripts/extract_consumer_routes.py create mode 100644 main/manager-api-fastapi/scripts/extract_java_routes.py create mode 100755 main/manager-api-fastapi/scripts/isolated-env.sh create mode 100644 main/manager-api-fastapi/scripts/render_compatibility_doc.py create mode 100755 main/manager-api-fastapi/scripts/run-isolated-contract-tests.sh create mode 100755 main/manager-api-fastapi/scripts/run-migrations.sh create mode 100755 main/manager-api-fastapi/scripts/start-api.sh create mode 100755 main/manager-api-fastapi/scripts/start-jobs.sh create mode 100644 main/manager-api-fastapi/tests/__init__.py create mode 100644 main/manager-api-fastapi/tests/compatibility/__init__.py create mode 100644 main/manager-api-fastapi/tests/compatibility/authenticated_route_runner.py create mode 100644 main/manager-api-fastapi/tests/compatibility/differential_runner.py create mode 100644 main/manager-api-fastapi/tests/compatibility/external_mock.py create mode 100644 main/manager-api-fastapi/tests/compatibility/performance_runner.py create mode 100644 main/manager-api-fastapi/tests/compatibility/route_surface_runner.py create mode 100644 main/manager-api-fastapi/tests/compatibility/seed_contract_data.py create mode 100644 main/manager-api-fastapi/tests/compatibility/tcp_proxy.py create mode 100644 main/manager-api-fastapi/tests/compatibility/test_authenticated_route_runner.py create mode 100644 main/manager-api-fastapi/tests/compatibility/test_differential_reporting.py create mode 100644 main/manager-api-fastapi/tests/domain_support.py create mode 100644 main/manager-api-fastapi/tests/fixtures/agent-mcp-aes-golden.json create mode 100644 main/manager-api-fastapi/tests/fixtures/sm2-c1c3c2-golden.json create mode 100644 main/manager-api-fastapi/tests/integration/__init__.py create mode 100644 main/manager-api-fastapi/tests/integration/test_isolated_runtime.py create mode 100644 main/manager-api-fastapi/tests/java/AgentMcpCryptoInteropCli.java create mode 100644 main/manager-api-fastapi/tests/java/RedisCodecVectors.java create mode 100644 main/manager-api-fastapi/tests/java/Sm2InteropCli.java create mode 100644 main/manager-api-fastapi/tests/test_agent_compatibility.py create mode 100644 main/manager-api-fastapi/tests/test_compatibility_document.py create mode 100644 main/manager-api-fastapi/tests/test_config_domain.py create mode 100644 main/manager-api-fastapi/tests/test_consumer_route_manifest.py create mode 100644 main/manager-api-fastapi/tests/test_core_compatibility.py create mode 100644 main/manager-api-fastapi/tests/test_deployment_artifacts.py create mode 100644 main/manager-api-fastapi/tests/test_deployment_runtime.py create mode 100644 main/manager-api-fastapi/tests/test_device.py create mode 100644 main/manager-api-fastapi/tests/test_java_route_manifest.py create mode 100644 main/manager-api-fastapi/tests/test_knowledge_ragflow.py create mode 100644 main/manager-api-fastapi/tests/test_model_timbre_correctword.py create mode 100644 main/manager-api-fastapi/tests/test_redis_java_interop.py create mode 100644 main/manager-api-fastapi/tests/test_schema_alias_compatibility.py create mode 100644 main/manager-api-fastapi/tests/test_security_domain.py create mode 100644 main/manager-api-fastapi/tests/test_sm2_interop.py create mode 100644 main/manager-api-fastapi/tests/test_sys_domain.py create mode 100644 main/manager-api-fastapi/tests/test_voiceclone.py create mode 100644 main/manager-api-fastapi/uv.lock create mode 100755 scripts/restart-local-services.sh diff --git a/.dockerignore b/.dockerignore index bc155a7c..61463b02 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,4 +4,23 @@ __pycache__ .env Dockerfile tmp/ -data/ \ No newline at end of file +data/ + +# Repository-local runtimes and test/build products are several gigabytes and +# are never inputs to either manager-api-fastapi image. +.runtime/ +.venv-*/ +**/.venv/ +**/.test-runtime/ +**/.pytest_cache/ +**/.mypy_cache/ +**/.ruff_cache/ +**/node_modules/ +**/dist/ +**/target/ +**/uploadfile/ + +# Runtime state and local model assets must not enter the Docker build context. +main/xiaozhi-server/mysql/ +main/xiaozhi-server/models/ +main/xiaozhi-server/data/ diff --git a/docs/manager-api-fastapi-compatibility.md b/docs/manager-api-fastapi-compatibility.md new file mode 100644 index 00000000..849aeac6 --- /dev/null +++ b/docs/manager-api-fastapi-compatibility.md @@ -0,0 +1,267 @@ +# manager-api FastAPI 兼容性矩阵 + +> 生成依据:`main/manager-api-fastapi/compatibility/java-routes.json`、 +> `main/manager-api-fastapi/compatibility/consumer-routes.json`、`route-surface-results.json`、 +> `authenticated-route-results.json`、`contract-results.json` 和当前 Java 源码。接口路径均省略 +> 共同前缀 `/xiaozhi`。 + +## 结论与状态口径 + +Java 基线共有 **154** 条 Spring MVC 路由;FastAPI 已注册 **154/154(100%)**,并由 +`tests/test_java_route_manifest.py` 对源码清单 freshness、数量和 method/path 注册闭合进行检查。 +此外实现 3 条仅由仓库消费者使用、Java Controller 中不存在的兼容路由,因此这 3 条不计入 +154 条 Java 覆盖率。三端 188 个调用点均能解析到 FastAPI 路由。 + +矩阵状态必须按下列含义阅读: + +- `结构✓`:method/path 已注册且清单闭合;它不等于业务行为逐接口实测。 +- `请求面差分✓1`:本行已向隔离 Java/FastAPI 各发送一次缺少鉴权或安全非法输入,精确比较 + HTTP status、body 与 Content-Type;最终为 **154/154 通过、0 失败、0 跳过**,且不发送成功写请求。 +- `认证业务面差分✓1`:本行已使用有效 DB Token、server-secret 或匿名身份,再向隔离 + Java/FastAPI 各发送一次安全业务/校验请求,精确比较 HTTP status、body 与 Content-Type; + 最终为 **154/154 通过、0 失败、0 跳过**,且不主动发送成功写请求。该状态不等于每条路由的 + 完整成功生命周期均已差分,完整副作用证据仍以 `差分✓N` 为准。 +- `领域✓(x,域级)`:该领域有 service/repository/协议自动测试,但不保证本行每条成功与错误路径 + 都被直接请求。`领域—` 表示除结构测试外没有可归属的域级直接测试证据。 +- `差分✓N`:本行除安全请求面外,还参与了成功、主要错误、协议或数据库副作用的深度对照; + 括号说明覆盖面。深度结果为 **49/49 checks 通过、0 失败、0 跳过**,覆盖 **21/154** 条路由。 + `差分间接✓` 表示 J125 作为下载链路的 URL 生成步骤被间接覆盖;`差分—` 表示没有深度对照, + 不能把 154/154 请求面差分误读成 154 条全部成功路径与副作用都已逐接口对照。 +- 所有 `Result` 均表示 `{code,msg,data}` envelope;原 Java 为 HTTP 200 的认证、权限、业务和 + 参数错误由全局兼容层维持 HTTP 200。二进制/OTA 裸响应在“响应类型”列单独标明。 + +## 三端消费者闭合 + +| 消费者 | 调用点 | 唯一结构路由 | 方法分布 | +|---|---:|---:|---| +| `manager-web` | 134 | 130 | DELETE 12、GET 59、POST 40、PUT 23 | +| `manager-mobile` | 46 | 40 | DELETE 3、GET 26、POST 12、PUT 5 | +| `xiaozhi-server` | 8 | 8 | GET 2、POST 6 | +| **合计** | **188** | **140** | — | + +### 3 条消费者孤儿兼容路由 + +| Method/path | 来源 | FastAPI 语义 | 鉴权 | 状态 | +|---|---|---|---|---| +| `GET /api/ping` | manager-mobile 环境设置探活 | `{code:0,msg:"success",data:"pong"}` | 匿名 | 实现✓;consumer resolve✓ | +| `PUT /user/configDevice/{device_id}` | manager-web 遗留设备配置调用 | 按现有设备更新契约处理 body | DB Token | 实现✓;consumer resolve✓ | +| `GET /device/address-book/lookup` | xiaozhi-server 管理客户端 | `callerMac/nickname/answer` 地址簿查询/呼叫兼容别名 | server-secret | 实现✓;consumer resolve✓;device 域测试✓ | + +`GET /admin/dict/data/type/FIRMWARE_TYPE` 是动态 Java 路由 +`GET /admin/dict/data/type/{dictType}` 的一个字面调用,不是第四条孤儿路由。 + +## Java 基线静态盘点 + +- Controller:24 个、154 条映射。按 Controller 的路由数为:`AdminController`(5)、`AgentChatHistoryController`(4)、`AgentController`(21)、`AgentMcpAccessPointController`(2)、`AgentSnapshotController`(4)、`AgentTemplateController`(6)、`AgentVoicePrintController`(4)、`ConfigController`(3)、`CorrectWordController`(7)、`DeviceController`(13)、`KnowledgeBaseController`(7)、`KnowledgeFilesController`(8)、`LoginController`(8)、`ModelController`(11)、`ModelProviderController`(5)、`OTAController`(3)、`OTAMagController`(9)、`ServerSideManageController`(2)、`SysDictDataController`(6)、`SysDictTypeController`(5)、`SysParamsController`(5)、`TimbreController`(4)、`VoiceCloneController`(6)、`VoiceResourceController`(6)。 +- 数据分层:`entity/` 29 个 Java 文件(28 个 `*Entity.java` 加 `BaseEntity`)、`dto/` 58 个、 + `vo/` 14 个、`dao/` 29 个、`service/` 树 78 个文件(其中 + `service/impl/` 38 个)。FastAPI 对应落在 `schemas/`、`repositories/`、 + `services/`、`routers/`、`integrations/` 与 `jobs/`,没有把跨表事务放进路由。 +- MyBatis XML:20 个,分别是 `mapper/agent/AgentCorrectWordMappingDao.xml`、`mapper/agent/AgentDao.xml`、`mapper/agent/AgentPluginMappingMapper.xml`、`mapper/agent/AgentSnapshotDao.xml`、`mapper/agent/AgentTagDao.xml`、`mapper/agent/AgentTagRelationDao.xml`、`mapper/agent/AgentTemplateMapper.xml`、`mapper/agent/AiAgentChatHistoryDao.xml`、`mapper/correctword/CorrectWordItemDao.xml`、`mapper/device/DeviceAddressBookDao.xml`、`mapper/device/DeviceDao.xml`、`mapper/knowledge/KnowledgeBaseDao.xml`、`mapper/model/ModelConfigDao.xml`、`mapper/model/ModelProviderDao.xml`、`mapper/security/SysUserTokenDao.xml`、`mapper/sys/SysDictDataDao.xml`、`mapper/sys/SysDictTypeDao.xml`、`mapper/sys/SysParamsDao.xml`、`mapper/sys/SysUserDao.xml`、`mapper/voiceclone/VoiceCloneDao.xml`。 +- Liquibase:`db.changelog-master.yaml` 含 101 个 `changeSet` 引用,目录中恰有 + 101 个 SQL;Python 部署继续执行这 101 个原始 SQL,不改写历史。 +- 定时工作:`DocumentStatusSyncTask` 每次完成后延迟 30 秒,扫描 RAGFlow RUNNING 文档并 + 回写 SUCCESS/FAIL/CANCEL 与统计;当前 Java 源码另有 `AgentSnapshotRedactionRunner`,启动时 + 执行一次并在滚动部署期每 15 秒补偿脱敏旧快照。FastAPI 将工作移到独立 jobs 进程,并以 + Redis 分布式锁/watchdog 防止多 worker 重复执行。 +- 外部集成:RAGFlow dataset/document/chunk/retrieval/upload;阿里云短信;火山语音克隆训练与 + 音频;声纹 HTTP;OpenAI-compatible LLM 摘要/标题;MQTT gateway HTTP;MCP/管理动作 + WebSocket;OTA/WS/MQTT 的 HMAC、Base64、时间戳与下载文件存储。自动测试只访问可重复 mock, + 未使用真实付费凭证。 + +## 154 条 Java→FastAPI 逐接口矩阵 + +副作用缩写:`DB-R/W`=数据库读/写,`Redis-R/W/DEL`=缓存读/写/失效,`文件-R/W`=文件 +读取/写入;外部调用均在 service/integration 层。权限为空时表示只需对应鉴权身份。 + +| # | Method/path | Java Controller.handler | 请求面 | 响应类型 | 鉴权 / 权限 | DB/Redis/文件/外部副作用 | 实现与测试状态 | +|---:|---|---|---|---|---|---|---| +| J001 | `GET /admin/device/all` | `AdminController.pageDevice` | Query:params:Map | envelope > | DB Token / `sys:role:superAdmin` | DB-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分— | +| J002 | `POST /admin/dict/data/delete` | `SysDictDataController.delete` | Body:Long[] | envelope | DB Token / `sys:role:superAdmin` | DB-W; Redis-DEL(dict cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分— | +| J003 | `GET /admin/dict/data/page` | `SysDictDataController.page` | Query:params:Map | envelope > | DB Token / `sys:role:superAdmin` | DB-R; Redis-R/W(dict cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分— | +| J004 | `POST /admin/dict/data/save` | `SysDictDataController.save` | Body:SysDictDataDTO | envelope | DB Token / `sys:role:superAdmin` | DB-W; Redis-DEL(dict cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分— | +| J005 | `GET /admin/dict/data/type/{dictType}` | `SysDictDataController.getDictDataByType` | Path:dictType | envelope > | DB Token / `sys:role:normal` | DB-R; Redis-R/W(dict cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分— | +| J006 | `PUT /admin/dict/data/update` | `SysDictDataController.update` | Body:SysDictDataDTO | envelope | DB Token / `sys:role:superAdmin` | DB-W; Redis-DEL(dict cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分— | +| J007 | `GET /admin/dict/data/{id}` | `SysDictDataController.get` | Path:id | envelope | DB Token / `sys:role:superAdmin` | DB-R; Redis-R/W(dict cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分— | +| J008 | `POST /admin/dict/type/delete` | `SysDictTypeController.delete` | Body:Long[] | envelope | DB Token / `sys:role:superAdmin` | DB-W; Redis-DEL(dict cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分— | +| J009 | `GET /admin/dict/type/page` | `SysDictTypeController.page` | Query:params:Map | envelope > | DB Token / `sys:role:superAdmin` | DB-R; Redis-R/W(dict cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分— | +| J010 | `POST /admin/dict/type/save` | `SysDictTypeController.save` | Body:SysDictTypeDTO | envelope | DB Token / `sys:role:superAdmin` | DB-W; Redis-DEL(dict cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分— | +| J011 | `PUT /admin/dict/type/update` | `SysDictTypeController.update` | Body:SysDictTypeDTO | envelope | DB Token / `sys:role:superAdmin` | DB-W; Redis-DEL(dict cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分— | +| J012 | `GET /admin/dict/type/{id}` | `SysDictTypeController.get` | Path:id | envelope | DB Token / `sys:role:superAdmin` | DB-R; Redis-R/W(dict cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分— | +| J013 | `POST /admin/params` | `SysParamsController.save` | Body:SysParamsDTO | envelope | DB Token / `sys:role:superAdmin` | DB-W; Redis-W/DEL | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分— | +| J014 | `PUT /admin/params` | `SysParamsController.update` | Body:SysParamsDTO | envelope | DB Token / `sys:role:superAdmin` | DB-W; Redis-W; 外部-配置端点探测(按 paramCode) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分— | +| J015 | `POST /admin/params/delete` | `SysParamsController.delete` | Body:String[] | envelope | DB Token / `sys:role:superAdmin` | DB-W; Redis-W/DEL | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分— | +| J016 | `GET /admin/params/page` | `SysParamsController.page` | Query:params:Map | envelope > | DB Token / `sys:role:superAdmin` | DB-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分— | +| J017 | `GET /admin/params/{id}` | `SysParamsController.get` | Path:id | envelope | DB Token / `sys:role:superAdmin` | DB-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分— | +| J018 | `POST /admin/server/emit-action` | `ServerSideManageController.emitServerAction` | Body:EmitSeverActionDTO | envelope | DB Token / `sys:role:superAdmin` | DB/Redis-R(secret/WS); Redis-W(one-shot); 外部-WebSocket | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分— | +| J019 | `GET /admin/server/server-list` | `ServerSideManageController.getWsServerList` | — | envelope > | DB Token / `sys:role:superAdmin` | DB/Redis-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分— | +| J020 | `GET /admin/users` | `AdminController.pageUser` | Query:params:Map | envelope > | DB Token / `sys:role:superAdmin` | DB-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分✓3(权限/序列化/非法分页) | +| J021 | `PUT /admin/users/changeStatus/{status}` | `AdminController.changeStatus` | Path:status; Body:String[] | envelope | DB Token / `sys:role:superAdmin` | DB-W(user/password/status/token) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分— | +| J022 | `DELETE /admin/users/{id}` | `AdminController.delete` | Path:id | envelope | DB Token / `sys:role:superAdmin` | DB-W(用户/token/device/agent 级联) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分— | +| J023 | `PUT /admin/users/{id}` | `AdminController.update` | Path:id | envelope | DB Token / `sys:role:superAdmin` | DB-W(user/password/status/token) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(sys,域级);差分— | +| J024 | `POST /agent` | `AgentController.save` | Body:AgentCreateDTO | envelope | DB Token / `sys:role:normal` | DB-W(含快照/映射/标签事务); Redis-DEL | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J025 | `GET /agent/all` | `AgentController.adminAgentList` | Query:params:Map | envelope > | DB Token / `sys:role:superAdmin` | DB-R; Redis-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J026 | `POST /agent/audio/{audioId}` | `AgentController.getAudioId` | Path:audioId | envelope | DB Token / `sys:role:normal` | DB-R(audio); Redis-W(one-shot URL) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J027 | `GET /agent/chat-history/download/{uuid}/current` | `AgentChatHistoryController.downloadCurrentSession` | Path:uuid | 流式/二进制 + 原下载 headers | 匿名 / — | DB/Redis-R(one-shot); 文件-R/流式 | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J028 | `GET /agent/chat-history/download/{uuid}/previous` | `AgentChatHistoryController.downloadCurrentSessionWithPrevious` | Path:uuid | 流式/二进制 + 原下载 headers | 匿名 / — | DB/Redis-R(one-shot); 文件-R/流式 | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J029 | `POST /agent/chat-history/getDownloadUrl/{agentId}/{sessionId}` | `AgentChatHistoryController.getDownloadUrl` | Path:agentId,sessionId | envelope | DB Token / — | DB-R(chat/session); Redis-W(download token TTL) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J030 | `POST /agent/chat-history/report` | `AgentChatHistoryController.uploadFile` | Body:AgentChatHistoryReportDTO | envelope | server-secret / — | DB-W(chat/session); server-secret | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J031 | `POST /agent/chat-summary/{sessionId}/save` | `AgentController.generateAndSaveChatSummary` | Path:sessionId | envelope | server-secret / — | DB-R/W(chat); 外部-OpenAI-compatible LLM | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J032 | `POST /agent/chat-title/{sessionId}/generate` | `AgentController.generateAndSaveChatTitle` | Path:sessionId | envelope | server-secret / — | DB-R/W(chat); 外部-OpenAI-compatible LLM | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J033 | `GET /agent/list` | `AgentController.getUserAgents` | Query:keyword:String,searchType:String | envelope > | DB Token / `sys:role:normal` | DB-R; Redis-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分✓1 | +| J034 | `GET /agent/mcp/address/{agentId}` | `AgentMcpAccessPointController.getAgentMcpAccessAddress` | Path:agentId | envelope | DB Token / `sys:role:normal` | DB/Redis-R; AES token 生成 | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J035 | `GET /agent/mcp/tools/{agentId}` | `AgentMcpAccessPointController.getAgentMcpToolsList` | Path:agentId | envelope > | DB Token / `sys:role:normal` | DB/Redis-R; 外部-WebSocket MCP | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J036 | `GET /agent/play/{uuid}` | `AgentController.playAudio` | Path:uuid | 流式/二进制 + 原下载 headers | 匿名 / — | DB/Redis-R(one-shot); 文件-R/流式 | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J037 | `PUT /agent/saveMemory/{macAddress}` | `AgentController.updateByDeviceId` | Path:macAddress; Body:AgentMemoryDTO | envelope | DB Token / `sys:role:normal` | DB-W(含快照/映射/标签事务); Redis-DEL | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J038 | `POST /agent/tag` | `AgentController.createTag` | Body:Map | envelope | DB Token / `sys:role:normal` | DB-W(含快照/映射/标签事务); Redis-DEL | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J039 | `GET /agent/tag/list` | `AgentController.getAllTags` | — | envelope > | DB Token / `sys:role:normal` | DB-R; Redis-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J040 | `DELETE /agent/tag/{id}` | `AgentController.deleteTag` | Path:id | envelope | DB Token / `sys:role:normal` | DB-W(含快照/映射/标签事务); Redis-DEL | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J041 | `GET /agent/template` | `AgentController.templateList` | — | envelope > | DB Token / `sys:role:normal` | DB-R; Redis-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J042 | `POST /agent/template` | `AgentTemplateController.createAgentTemplate` | Body:AgentTemplateEntity | envelope | DB Token / `sys:role:superAdmin` | DB-W(含快照/映射/标签事务); Redis-DEL | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J043 | `PUT /agent/template` | `AgentTemplateController.updateAgentTemplate` | Body:AgentTemplateEntity | envelope | DB Token / `sys:role:superAdmin` | DB-W(含快照/映射/标签事务); Redis-DEL | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J044 | `POST /agent/template/batch-remove` | `AgentTemplateController.batchRemoveAgentTemplates` | Body:List | envelope | DB Token / `sys:role:superAdmin` | DB-W(含快照/映射/标签事务); Redis-DEL | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J045 | `GET /agent/template/page` | `AgentTemplateController.getAgentTemplatesPage` | Query:params:Map | envelope > | DB Token / `sys:role:superAdmin` | DB-R; Redis-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J046 | `DELETE /agent/template/{id}` | `AgentTemplateController.deleteAgentTemplate` | Path:id | envelope | DB Token / `sys:role:superAdmin` | DB-W(含快照/映射/标签事务); Redis-DEL | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J047 | `GET /agent/template/{id}` | `AgentTemplateController.getAgentTemplateById` | Path:id | envelope | DB Token / `sys:role:superAdmin` | DB-R; Redis-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J048 | `POST /agent/voice-print` | `AgentVoicePrintController.save` | Body:AgentVoicePrintSaveDTO | envelope | DB Token / `sys:role:normal` | DB-W; 外部-voiceprint HTTP | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J049 | `PUT /agent/voice-print` | `AgentVoicePrintController.update` | Body:AgentVoicePrintUpdateDTO | envelope | DB Token / `sys:role:normal` | DB-W; 外部-voiceprint HTTP | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J050 | `GET /agent/voice-print/list/{id}` | `AgentVoicePrintController.list` | Path:id | envelope > | DB Token / `sys:role:normal` | DB-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J051 | `DELETE /agent/voice-print/{id}` | `AgentVoicePrintController.delete` | Path:id | envelope | DB Token / `sys:role:normal` | DB-W; 外部-voiceprint HTTP | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J052 | `GET /agent/{agentId}/snapshots` | `AgentSnapshotController.page` | Path:agentId; Query:params:AgentSnapshotPageDTO | envelope > | DB Token / `sys:role:normal` | DB-R; Redis-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J053 | `DELETE /agent/{agentId}/snapshots/{snapshotId}` | `AgentSnapshotController.deleteSnapshot` | Path:agentId,snapshotId | envelope | DB Token / `sys:role:normal` | DB-W(含快照/映射/标签事务); Redis-DEL | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J054 | `GET /agent/{agentId}/snapshots/{snapshotId}` | `AgentSnapshotController.getSnapshot` | Path:agentId,snapshotId | envelope | DB Token / `sys:role:normal` | DB-R; Redis-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J055 | `POST /agent/{agentId}/snapshots/{snapshotId}/restore` | `AgentSnapshotController.restore` | Path:agentId,snapshotId; Body:AgentSnapshotRestoreDTO | envelope | DB Token / `sys:role:normal` | DB-W(含快照/映射/标签事务); Redis-DEL | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J056 | `DELETE /agent/{id}` | `AgentController.delete` | Path:id | envelope | DB Token / `sys:role:normal` | DB-W(含快照/映射/标签事务); Redis-DEL | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J057 | `GET /agent/{id}` | `AgentController.getAgentById` | Path:id | envelope | DB Token / `sys:role:normal` | DB-R; Redis-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J058 | `PUT /agent/{id}` | `AgentController.update` | Path:id; Body:AgentUpdateDTO | envelope | DB Token / `sys:role:normal` | DB-W(含快照/映射/标签事务); Redis-DEL | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J059 | `GET /agent/{id}/chat-history/audio` | `AgentController.getContentByAudioId` | Path:id | envelope | DB Token / `sys:role:normal` | DB-R; Redis-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J060 | `GET /agent/{id}/chat-history/user` | `AgentController.getRecentlyFiftyByAgentId` | Path:id | envelope > | DB Token / `sys:role:normal` | DB-R; Redis-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J061 | `GET /agent/{id}/chat-history/{sessionId}` | `AgentController.getAgentChatHistory` | Path:id,sessionId | envelope > | DB Token / `sys:role:normal` | DB-R; Redis-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J062 | `GET /agent/{id}/sessions` | `AgentController.getAgentSessions` | Path:id; Query:params:Map | envelope > | DB Token / `sys:role:normal` | DB-R; Redis-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J063 | `GET /agent/{id}/tags` | `AgentController.getAgentTags` | Path:id | envelope > | DB Token / `sys:role:normal` | DB-R; Redis-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J064 | `PUT /agent/{id}/tags` | `AgentController.saveAgentTags` | Path:id; Body:Map | envelope | DB Token / `sys:role:normal` | DB-W(含快照/映射/标签事务); Redis-DEL | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(agent,域级);差分— | +| J065 | `POST /config/agent-models` | `ConfigController.getAgentModels` | Body:AgentModelsDTO | envelope | server-secret / — | DB-R; Redis-R/W(runtime/model/timbre cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(config,域级);差分— | +| J066 | `POST /config/correct-words` | `ConfigController.getCorrectWords` | Body:CorrectWordsDTO | envelope | server-secret / — | DB-R; Redis-R/W(runtime/model/timbre cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(config,域级);差分— | +| J067 | `POST /config/server-base` | `ConfigController.getConfig` | — | envelope | server-secret / — | DB-R; Redis-R/W(runtime/model/timbre cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(config,域级);差分✓3(缺失/错误/正确 secret) | +| J068 | `POST /correct-word/file` | `CorrectWordController.createFile` | Body:CorrectWordFileCreateDTO | envelope | DB Token / `sys:role:normal` | DB-W(file/items/mapping 事务) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(correctword,域级);差分✓2(响应/DB) | +| J069 | `POST /correct-word/file/batch-delete` | `CorrectWordController.batchDeleteFiles` | Body:List | envelope | DB Token / `sys:role:normal` | DB-W(file/items/mapping 事务) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(correctword,域级);差分— | +| J070 | `GET /correct-word/file/download/{fileId}` | `CorrectWordController.downloadFile` | Path:fileId | 流式/二进制 + 原下载 headers | DB Token / `sys:role:normal` | DB-R(content); 二进制 | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(correctword,域级);差分✓2(二进制/更新后下载) | +| J071 | `GET /correct-word/file/list` | `CorrectWordController.listFiles` | Query:params:Map | envelope > | DB Token / `sys:role:normal` | DB-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(correctword,域级);差分✓1 | +| J072 | `GET /correct-word/file/select` | `CorrectWordController.listAllFiles` | — | envelope > | DB Token / `sys:role:normal` | DB-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(correctword,域级);差分— | +| J073 | `DELETE /correct-word/file/{fileId}` | `CorrectWordController.deleteFile` | Path:fileId | envelope | DB Token / `sys:role:normal` | DB-W(file/items/mapping 事务) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(correctword,域级);差分✓1(级联副作用) | +| J074 | `PUT /correct-word/file/{fileId}` | `CorrectWordController.updateFile` | Path:fileId; Body:CorrectWordFileCreateDTO | envelope | DB Token / `sys:role:normal` | DB-W(file/items/mapping 事务) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(correctword,域级);差分✓2(响应/DB) | +| J075 | `GET /datasets` | `KnowledgeBaseController.getPageList` | Query:name:String,page:Integer,page_size:Integer | envelope > | DB Token / `sys:role:normal` | DB-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(knowledge,域级);差分— | +| J076 | `POST /datasets` | `KnowledgeBaseController.save` | Body:KnowledgeBaseDTO | envelope | DB Token / `sys:role:normal` | DB-R/W; 外部-RAGFlow HTTP(upload/dataset/document/chunk/retrieval) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(knowledge,域级);差分— | +| J077 | `DELETE /datasets/batch` | `KnowledgeBaseController.deleteBatch` | Query:ids:String | envelope | DB Token / `sys:role:normal` | DB-R/W; 外部-RAGFlow HTTP(upload/dataset/document/chunk/retrieval) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(knowledge,域级);差分— | +| J078 | `GET /datasets/rag-models` | `KnowledgeBaseController.getRAGModels` | — | envelope > | DB Token / `sys:role:normal` | DB-R(model config) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(knowledge,域级);差分— | +| J079 | `DELETE /datasets/{dataset_id}` | `KnowledgeBaseController.delete` | Path:dataset_id | envelope | DB Token / `sys:role:normal` | DB-R/W; 外部-RAGFlow HTTP(upload/dataset/document/chunk/retrieval) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(knowledge,域级);差分— | +| J080 | `GET /datasets/{dataset_id}` | `KnowledgeBaseController.getByDatasetId` | Path:dataset_id | envelope | DB Token / `sys:role:normal` | DB-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(knowledge,域级);差分— | +| J081 | `PUT /datasets/{dataset_id}` | `KnowledgeBaseController.update` | Path:dataset_id; Body:KnowledgeBaseDTO | envelope | DB Token / `sys:role:normal` | DB-R/W; 外部-RAGFlow HTTP(upload/dataset/document/chunk/retrieval) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(knowledge,域级);差分— | +| J082 | `POST /datasets/{dataset_id}/chunks` | `KnowledgeFilesController.parseDocuments` | Path:dataset_id; Body:Map> | envelope | DB Token / `sys:role:normal` | DB-R/W; 外部-RAGFlow HTTP(upload/dataset/document/chunk/retrieval) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(knowledge,域级);差分— | +| J083 | `DELETE /datasets/{dataset_id}/documents` | `KnowledgeFilesController.delete` | Path:dataset_id; Body:DocumentDTO.BatchIdReq | envelope | DB Token / `sys:role:normal` | DB-R/W; 外部-RAGFlow HTTP(upload/dataset/document/chunk/retrieval) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(knowledge,域级);差分— | +| J084 | `GET /datasets/{dataset_id}/documents` | `KnowledgeFilesController.getPageList` | Path:dataset_id; Query:name:String,status:String,page:Integer,page_size:Integer | envelope > | DB Token / `sys:role:normal` | DB-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(knowledge,域级);差分— | +| J085 | `POST /datasets/{dataset_id}/documents` | `KnowledgeFilesController.uploadDocument` | Path:dataset_id; Query:name:String,chunkMethod:String,metaFields:String,parserConfig:String; Multipart:file | envelope | DB Token / `sys:role:normal` | DB-R/W; 外部-RAGFlow HTTP(upload/dataset/document/chunk/retrieval) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(knowledge,域级);差分— | +| J086 | `GET /datasets/{dataset_id}/documents/status/{status}` | `KnowledgeFilesController.getPageListByStatus` | Path:dataset_id,status; Query:page:Integer,page_size:Integer | envelope > | DB Token / `sys:role:normal` | DB-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(knowledge,域级);差分— | +| J087 | `DELETE /datasets/{dataset_id}/documents/{document_id}` | `KnowledgeFilesController.deleteSingle` | Path:dataset_id,document_id | envelope | DB Token / `sys:role:normal` | DB-R/W; 外部-RAGFlow HTTP(upload/dataset/document/chunk/retrieval) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(knowledge,域级);差分— | +| J088 | `GET /datasets/{dataset_id}/documents/{document_id}/chunks` | `KnowledgeFilesController.listChunks` | Path:dataset_id,document_id; Query:page:Integer,pageSize:Integer,keywords:String,id:String | envelope | DB Token / `sys:role:normal` | DB-R/W; 外部-RAGFlow HTTP(upload/dataset/document/chunk/retrieval) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(knowledge,域级);差分— | +| J089 | `POST /datasets/{dataset_id}/retrieval-test` | `KnowledgeFilesController.retrievalTest` | Path:dataset_id; Body:RetrievalDTO.TestReq | envelope | DB Token / `sys:role:normal` | DB-R/W; 外部-RAGFlow HTTP(upload/dataset/document/chunk/retrieval) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(knowledge,域级);差分— | +| J090 | `PUT /device/address-book/alias` | `DeviceController.updateAlias` | Body:DeviceAddressBookAliasDTO | envelope | DB Token / `sys:role:normal` | DB-W(device/bind/address-book); Redis-R/W | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分— | +| J091 | `GET /device/address-book/call` | `DeviceController.callByNickname` | Query:callerMac:String,nickname:String,answer:boolean | envelope > | server-secret / — | DB-R; 外部-MQTT gateway HTTP; server-secret | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分— | +| J092 | `PUT /device/address-book/permission` | `DeviceController.updatePermission` | Body:DeviceAddressBookPermissionDTO | envelope | DB Token / `sys:role:normal` | DB-W(device/bind/address-book); Redis-R/W | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分— | +| J093 | `GET /device/address-book/{macAddress}` | `DeviceController.getAddressBook` | Path:macAddress | envelope | DB Token / `sys:role:normal` | DB-R; Redis-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分— | +| J094 | `GET /device/bind/{agentId}` | `DeviceController.getUserDevices` | Path:agentId | envelope > | DB Token / `sys:role:normal` | DB-R; Redis-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分✓1 | +| J095 | `POST /device/bind/{agentId}` | `DeviceController.forwardToMqttGateway` | Path:agentId; Body:String | envelope | DB Token / `sys:role:normal` | DB/Redis-R; 外部-MQTT gateway HTTP + daily auth | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分— | +| J096 | `POST /device/bind/{agentId}/{deviceCode}` | `DeviceController.bindDevice` | Path:agentId,deviceCode | envelope | DB Token / `sys:role:normal` | DB-W(device/bind/address-book); Redis-R/W | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分— | +| J097 | `POST /device/manual-add` | `DeviceController.manualAddDevice` | Body:DeviceManualAddDTO | envelope | DB Token / `sys:role:normal` | DB-W(device/bind/address-book); Redis-R/W | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分— | +| J098 | `POST /device/register` | `DeviceController.registerDevice` | Body:DeviceRegisterDTO | envelope | DB Token / — | DB-W(device/bind/address-book); Redis-R/W | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分— | +| J099 | `POST /device/tools/call/{deviceId}` | `DeviceController.callDeviceTool` | Path:deviceId; Body:DeviceToolsCallReqDTO | envelope | DB Token / `sys:role:normal` | DB/Redis-R; 外部-MQTT gateway HTTP + daily auth | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分— | +| J100 | `POST /device/tools/list/{deviceId}` | `DeviceController.getDeviceTools` | Path:deviceId | envelope | DB Token / `sys:role:normal` | DB/Redis-R; 外部-MQTT gateway HTTP + daily auth | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分✓2(响应/外呼格式) | +| J101 | `POST /device/unbind` | `DeviceController.unbindDevice` | Body:DeviceUnBindDTO | envelope | DB Token / `sys:role:normal` | DB-W(device/bind/address-book); Redis-R/W | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分— | +| J102 | `PUT /device/update/{id}` | `DeviceController.updateDeviceInfo` | Path:id; Body:DeviceUpdateDTO | envelope | DB Token / `sys:role:normal` | DB-W(device/bind/address-book); Redis-R/W | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分✓3(上下界/UTF-16 长度) | +| J103 | `PUT /models/default/{id}` | `ModelController.setDefaultModel` | Path:id | envelope | DB Token / `sys:role:superAdmin` | DB-W; Redis-DEL(model/config cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(model,域级);差分— | +| J104 | `PUT /models/enable/{id}/{status}` | `ModelController.enableModelConfig` | Path:id,status | envelope | DB Token / `sys:role:superAdmin` | DB-W; Redis-DEL(model/config cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(model,域级);差分— | +| J105 | `GET /models/list` | `ModelController.getModelConfigList` | Query:modelType:String,modelName:String,page:String,limit:String | envelope > | DB Token / `sys:role:superAdmin` | DB-R; Redis-R/W(model cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(model,域级);差分— | +| J106 | `GET /models/llm/names` | `ModelController.getLlmModelCodeList` | Query:modelName:String | envelope > | DB Token / `sys:role:normal` | DB-R; Redis-R/W(model cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(model,域级);差分— | +| J107 | `GET /models/names` | `ModelController.getModelNames` | Query:modelType:String,modelName:String | envelope > | DB Token / `sys:role:normal` | DB-R; Redis-R/W(model cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(model,域级);差分— | +| J108 | `GET /models/provider` | `ModelProviderController.getListPage` | Query:modelProviderDTO:ModelProviderDTO,page:String,limit:String | envelope > | DB Token / `sys:role:superAdmin` | DB-R; Redis-R/W(model cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(model,域级);差分✓1 | +| J109 | `POST /models/provider` | `ModelProviderController.add` | Body:ModelProviderDTO | envelope | DB Token / `sys:role:superAdmin` | DB-W; Redis-DEL(model/config cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(model,域级);差分✓1(约束集合) | +| J110 | `PUT /models/provider` | `ModelProviderController.edit` | Body:ModelProviderDTO | envelope | DB Token / `sys:role:superAdmin` | DB-W; Redis-DEL(model/config cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(model,域级);差分— | +| J111 | `POST /models/provider/delete` | `ModelProviderController.delete` | Body:List | envelope | DB Token / `sys:role:superAdmin` | DB-W; Redis-DEL(model/config cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(model,域级);差分— | +| J112 | `GET /models/provider/plugin/names` | `ModelProviderController.getPluginNameList` | — | envelope > | DB Token / — | DB-R; Redis-R/W(model cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(model,域级);差分— | +| J113 | `DELETE /models/{id}` | `ModelController.deleteModelConfig` | Path:id | envelope | DB Token / `sys:role:superAdmin` | DB-W; Redis-DEL(model/config cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(model,域级);差分— | +| J114 | `GET /models/{id}` | `ModelController.getModelConfig` | Path:id | envelope | DB Token / `sys:role:superAdmin` | DB-R; Redis-R/W(model cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(model,域级);差分— | +| J115 | `GET /models/{modelId}/voices` | `ModelController.getVoiceList` | Path:modelId; Query:voiceName:String | envelope > | DB Token / `sys:role:normal` | DB-R; Redis-R/W(model cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(model,域级);差分— | +| J116 | `GET /models/{modelType}/provideTypes` | `ModelController.getModelProviderList` | Path:modelType | envelope > | DB Token / `sys:role:superAdmin` | DB-R; Redis-R/W(model cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(model,域级);差分— | +| J117 | `POST /models/{modelType}/{provideCode}` | `ModelController.addModelConfig` | Path:modelType,provideCode; Body:ModelConfigBodyDTO | envelope | DB Token / `sys:role:superAdmin` | DB-W; Redis-DEL(model/config cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(model,域级);差分— | +| J118 | `PUT /models/{modelType}/{provideCode}/{id}` | `ModelController.editModelConfig` | Path:modelType,provideCode,id; Body:ModelConfigBodyDTO | envelope | DB Token / `sys:role:superAdmin` | DB-W; Redis-DEL(model/config cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(model,域级);差分— | +| J119 | `GET /ota/` | `OTAController.getOTA` | — | 裸 text/plain | 匿名 / — | — | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分✓1(MIME/body) | +| J120 | `POST /ota/` | `OTAController.checkOTAVersion` | Header:Device-Id,Client-Id; Body:DeviceReportReqDTO | 裸 application/json | 匿名 / — | DB/Redis-R(设备/固件/配置); HMAC/Base64/时间戳凭证 | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分✓4(必填/格式/凭证/密码学) | +| J121 | `POST /ota/activate` | `OTAController.activateDevice` | Header:Device-Id,Client-Id | 裸 application/json | 匿名 / — | DB-R/W(device activation); Redis-R/W(TTL) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分✓3 | +| J122 | `GET /otaMag` | `OTAMagController.page` | Query:params:Map | envelope > | DB Token / `sys:role:superAdmin` | DB-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分— | +| J123 | `POST /otaMag` | `OTAMagController.save` | Body:OtaEntity | envelope | DB Token / `sys:role:superAdmin` | DB-W(OTA metadata) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分✓2(响应/DB) | +| J124 | `GET /otaMag/download/{uuid}` | `OTAMagController.downloadFirmware` | Path:uuid | 流式/二进制 + 原下载 headers | 匿名 / — | Redis-R/W(一次性/次数); 文件-R/流式 | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分✓4(次数限制及二进制) | +| J125 | `GET /otaMag/getDownloadUrl/{id}` | `OTAMagController.getDownloadUrl` | Path:id | envelope | DB Token / `sys:role:superAdmin` | DB-R; Redis-W(download token TTL) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分间接✓(供下载链路) | +| J126 | `POST /otaMag/upload` | `OTAMagController.uploadFirmware` | Multipart:file | envelope | DB Token / `sys:role:superAdmin` | 文件-W(MD5/扩展名/大小) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分✓2(上传/扩展名错误) | +| J127 | `POST /otaMag/uploadAssetsBin` | `OTAMagController.uploadAssetsBin` | Multipart:file | envelope | DB Token / `sys:role:normal` | 文件-W(MD5/扩展名/大小) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分— | +| J128 | `DELETE /otaMag/{id}` | `OTAMagController.delete` | Path:id | envelope | DB Token / `sys:role:superAdmin` | DB-W(OTA metadata); 文件-DEL | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分— | +| J129 | `GET /otaMag/{id}` | `OTAMagController.get` | Path:id | envelope | DB Token / `sys:role:superAdmin` | DB-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分— | +| J130 | `PUT /otaMag/{id}` | `OTAMagController.update` | Path:id; Body:OtaEntity | envelope | DB Token / `sys:role:superAdmin` | DB-W(OTA metadata) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(device,域级);差分— | +| J131 | `GET /ttsVoice` | `TimbreController.page` | Query:params:Map | envelope > | DB Token / `sys:role:superAdmin` | DB-R; Redis-R/W(timbre cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(timbre,域级);差分— | +| J132 | `POST /ttsVoice` | `TimbreController.save` | Body:TimbreDataDTO | envelope | DB Token / `sys:role:superAdmin` | DB-W; Redis-DEL(timbre/config cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(timbre,域级);差分— | +| J133 | `POST /ttsVoice/delete` | `TimbreController.delete` | Body:String[] | envelope | DB Token / `sys:role:superAdmin` | DB-W; Redis-DEL(timbre/config cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(timbre,域级);差分— | +| J134 | `PUT /ttsVoice/{id}` | `TimbreController.update` | Path:id; Body:TimbreDataDTO | envelope | DB Token / `sys:role:superAdmin` | DB-W; Redis-DEL(timbre/config cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(timbre,域级);差分— | +| J135 | `GET /user/captcha` | `LoginController.captcha` | Query:uuid:String | image/gif 二进制 | 匿名 / — | Redis-W(captcha TTL); GIF | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(security,域级);差分— | +| J136 | `PUT /user/change-password` | `LoginController.changePassword` | Body:PasswordDTO | envelope | DB Token / — | DB-W(user/token); Redis-R/DEL(SMS) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(security,域级);差分— | +| J137 | `GET /user/info` | `LoginController.info` | — | envelope | DB Token / — | DB-R; Redis-R/W(cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(security,域级);差分✓9(七语言/过期 Token/Long) | +| J138 | `POST /user/login` | `LoginController.login` | Body:LoginDTO | envelope | 匿名 / — | DB-R/W(token); Redis-R/DEL(captcha) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(security,域级);差分— | +| J139 | `GET /user/pub-config` | `LoginController.pubConfig` | — | envelope > | 匿名 / — | DB-R; Redis-R/W(cache) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(security,域级);差分✓1 | +| J140 | `POST /user/register` | `LoginController.register` | Body:LoginDTO | envelope | 匿名 / — | DB-W(user/token); Redis-R/DEL(SMS) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(security,域级);差分— | +| J141 | `PUT /user/retrieve-password` | `LoginController.retrievePassword` | Body:RetrievePasswordDTO | envelope | 匿名 / — | DB-W(user/token); Redis-R/DEL(SMS) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(security,域级);差分— | +| J142 | `POST /user/smsVerification` | `LoginController.smsVerification` | Body:SmsVerificationDTO | envelope | 匿名 / — | Redis-R/W(TTL/频控); 外部-Aliyun SMS | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(security,域级);差分— | +| J143 | `GET /voiceClone` | `VoiceCloneController.page` | Query:params:Map | envelope > | DB Token / `sys:role:normal` | DB-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(voiceclone,域级);差分— | +| J144 | `POST /voiceClone/audio/{id}` | `VoiceCloneController.getAudioId` | Path:id | envelope | DB Token / `sys:role:normal` | DB-R; Redis-W(one-shot URL) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(voiceclone,域级);差分— | +| J145 | `POST /voiceClone/cloneAudio` | `VoiceCloneController.cloneAudio` | Body:Map | envelope | DB Token / `sys:role:normal` | DB-R/W(train state); 文件-W; 外部-火山语音克隆 HTTP | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(voiceclone,域级);差分— | +| J146 | `GET /voiceClone/play/{uuid}` | `VoiceCloneController.playVoice` | Path:uuid | 流式/二进制 + 原下载 headers | 匿名 / — | Redis-R/DEL(one-shot); 文件/外部音频-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(voiceclone,域级);差分— | +| J147 | `POST /voiceClone/updateName` | `VoiceCloneController.updateName` | Body:Map | envelope | DB Token / `sys:role:normal` | DB-W(train record name) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(voiceclone,域级);差分— | +| J148 | `POST /voiceClone/upload` | `VoiceCloneController.uploadVoice` | Query:id:String; Multipart:voiceFile | envelope | DB Token / `sys:role:normal` | DB-R/W(train state); 文件-W; 外部-火山语音克隆 HTTP | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(voiceclone,域级);差分— | +| J149 | `GET /voiceResource` | `VoiceResourceController.page` | Query:params:Map | envelope > | DB Token / `sys:role:superAdmin` | DB-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(voiceclone,域级);差分— | +| J150 | `POST /voiceResource` | `VoiceResourceController.save` | Body:VoiceCloneDTO | envelope | DB Token / `sys:role:superAdmin` | DB-W(voice resource) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(voiceclone,域级);差分— | +| J151 | `GET /voiceResource/ttsPlatforms` | `VoiceResourceController.getTtsPlatformList` | — | envelope >> | DB Token / `sys:role:superAdmin` | DB-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(voiceclone,域级);差分— | +| J152 | `GET /voiceResource/user/{userId}` | `VoiceResourceController.getByUserId` | Path:userId | envelope > | DB Token / `sys:role:normal` | DB-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(voiceclone,域级);差分— | +| J153 | `DELETE /voiceResource/{id}` | `VoiceResourceController.delete` | Path:id | envelope | DB Token / `sys:role:superAdmin` | DB-W(voice resource) | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(voiceclone,域级);差分— | +| J154 | `GET /voiceResource/{id}` | `VoiceResourceController.get` | Path:id | envelope | DB Token / `sys:role:superAdmin` | DB-R | 结构✓;请求面差分✓1;认证业务面差分✓1;领域✓(voiceclone,域级);差分— | + +## 已观测差异与未覆盖面 + +- 154 条安全请求面差分最终全部一致。首轮曾发现 5 个空 Body 映射差异;修复 FastAPI 对 + Spring `HttpMessageNotReadableException` 的 code-500 语义后,重新从零执行才得到 154/154。 +- 154 条认证业务面差分最终全部一致;该轮使用有效鉴权与安全业务/校验输入,在不主动成功 + 写入的前提下逐路由对照。证据是 `authenticated-route-results.json`,渲染器会在结果不是 + 154/154、存在失败或跳过时硬失败。 +- 2026-07-20 的隔离差分报告未在 49 个 checks 中观测到响应/所选 headers/数据库副作用 + 不一致;证据是 `main/manager-api-fastapi/compatibility/contract-results.json`,不是人工推断。 +- Hibernate Validator 的 `ConstraintViolation Set` 首条消息无稳定顺序;模型 provider 必填 + 用例比较“消息属于 Java 声明约束集合”与相同错误码,而不伪造一个固定顺序。 +- OTA 时间戳/token 是动态值,差分先比较归一化结构,再分别校验两端 HMAC/Base64 密码学 + 有效性;这属于有意的测试归一化,不是声称字节恒等。 +- 深度差分未直接命中的 133 条中,J125 是下载链路间接覆盖,另 132 条标为 `差分—`; + 它们有请求面、认证业务面与所属领域测试,但尚无逐路由成功+主要错误+副作用深度对照,不能据此宣称 + 每一种业务状态均已逐接口行为等价。 +- FastAPI 额外提供上述 3 条消费者兼容路由与 live/ready 健康检查;它们没有 Java + Controller 基线,属于明确、可回退的加法差异。 +- Java 把定时任务放在 Spring 进程;FastAPI 使用独立 jobs 进程和 Redis 分布式锁。这是 + 部署拓扑差异,业务状态和幂等目标保持一致。 +- RAGFlow、阿里云短信、火山语音克隆、真实声纹、真实 LLM、真实 MQTT/MCP/WS 均未用 + 生产凭证联调;自动化只证明 mock 请求格式、超时/错误映射/重试中的已覆盖场景。 + +## 可复现检查 + +```bash +cd main/manager-api-fastapi +.venv/bin/python scripts/extract_java_routes.py --output compatibility/java-routes.json +.venv/bin/python scripts/extract_consumer_routes.py > /tmp/consumer-routes.json +.venv/bin/pytest -q tests/test_java_route_manifest.py tests/test_consumer_route_manifest.py tests/test_compatibility_document.py +``` + +逐接口差分的启动、隔离库、mock 与执行命令见 `docs/manager-api-fastapi-test-report.md`; +本文件只陈述已落盘的结果,不把缺少真实密钥的外部联调列为通过。 diff --git a/docs/manager-api-fastapi-migration.md b/docs/manager-api-fastapi-migration.md new file mode 100644 index 00000000..1f0b035f --- /dev/null +++ b/docs/manager-api-fastapi-migration.md @@ -0,0 +1,290 @@ +# manager-api 到 FastAPI 迁移说明 + +## 目标与边界 + +`main/manager-api-fastapi` 是 `main/manager-api` 的兼容替代实现。迁移只替换管理 API +进程,不改变 MySQL 表、Liquibase 历史、Redis 业务语义,也不要求 manager-web、 +manager-mobile 或 xiaozhi-server 修改现有 URL。Java 实现继续保留,作为行为基线和 +回滚实现。 + +本次迁移不采用双写。灰度期间,每个业务域在任一时刻只有一个写入方;读流量可以按 +请求切分,写流量必须按业务域整体切换。 + +## 架构 + +```mermaid +flowchart LR + C["Web / Mobile / xiaozhi-server / Device"] --> N["Nginx /xiaozhi"] + N --> A["FastAPI API workers"] + A --> R["Routers"] + R --> S["Services / transaction boundaries"] + S --> P["Repositories / SQLAlchemy async"] + S --> I["External integration clients"] + P --> M[("Existing MySQL schema")] + S --> D[("Redis Java compatibility layer")] + J["Standalone jobs worker"] --> S + L["Original Liquibase changelog"] --> X["Migration runner"] + X --> M +``` + +代码按职责分为: + +- `app/routers`:URL、HTTP 方法、Header/Query/Body 绑定和响应形态。 +- `app/services`:权限后的业务规则、事务边界、跨表级联和外部调用编排。 +- `app/repositories`:现有 MySQL 表上的参数化 SQL、行锁和分页。 +- `app/schemas`:兼容现有字段别名及请求模型。 +- `app/integrations`:LLM、MQTT gateway、MCP、RAGFlow、语音克隆和声纹客户端。 +- `app/jobs`:独立的定时任务进程;API worker 不启动定时任务。 +- `app/core`:数据库 Token 认证、国际化、Java JSON/Long 兼容、SM2、Redis 编解码、 + Snowflake ID 和健康检查。 + +所有业务路由保留 `/xiaozhi` 前缀。普通 API 返回 `{code,msg,data}`;认证过滤器、 +业务异常和请求校验均由兼容处理器转换,不把 FastAPI 默认的 401、403 或 422 直接 +暴露给客户端。OTA、播放和文件下载等原本返回裸 JSON、文本或二进制的接口继续保持 +其原始响应类型。 + +## 数据库兼容策略 + +### Schema 与迁移 + +原目录 `main/manager-api/src/main/resources/db/changelog/` 仍是唯一 schema source of +truth。不得把既有 changeset 改写成 Alembic,也不得修改已应用 changeset 的 ID、作者或 +校验和。 + +Python 部署必须先运行独立迁移镜像或 `scripts/run-migrations.sh`。迁移 runner 直接打包 +原 Java Liquibase 资源,因此与保留的 Java 服务使用相同的 `DATABASECHANGELOG` 历史。 +宿主机执行脚本时,`JAVA_RESOURCES_DIR` 必须指向仓库中的 +`main/manager-api/src/main/resources`;不要使用只存在于应用镜像内的 +`/opt/xiaozhi/java-resources`。本机隔离数据库的完整命令见“配置与启动 / 本地”。 + +`docker compose` 中 `manager-api-fastapi` 和 `manager-api-jobs` 都等待 +`manager-api-migrate` 成功退出,避免应用在未完成迁移时接流量。 + +### 事务、锁与 ID + +- Service 层在一次事务中完成智能体创建/更新/删除、插件/标签/纠错词映射、设备解绑、 + 快照恢复及其他多表操作;异常时显式回滚。 +- 智能体更新和恢复先对 `ai_agent` 执行 `SELECT ... FOR UPDATE`,序列化同一智能体的 + 快照版本分配和状态令牌校验。 +- 快照版本仍使用 `(agent_id, version_no)` 唯一约束及同一事务内的 `MAX+1` 插入;行锁 + 防止并发恢复或更新竞争。 +- 需要 Long 主键的管理表继续使用与 Java epoch/node/sequence 布局一致的 Snowflake + 生成器;原本使用 32 位 UUID 的业务表仍使用无连字符 UUID。 +- 不新增数据库外键,也不改表、索引、字符集或 MySQL 类型。 + +### Java/Python 并存 + +并存期必须给写请求建立确定的域路由,例如 `agent/*` 全部指向一个实现,不能把同一域 +中的增删改请求在 Java 与 Python 之间随机分配。建议域切换顺序为只读配置、系统管理、 +模型/音色、设备、智能体、知识库和外部集成。域回切前先停止该域的新写入并等待在途 +请求完成。 + +## Redis 兼容策略 + +默认继续使用 Java 已有 key 名称和 TTL。`app/core/redis.py` 实现 Spring Data +`RedisSerializer.json()` 使用的 Jackson wire format,包括: + +- Map 的 `@class`、List/Set 的 wrapper-array; +- Object 槽位中 Long 的 `java.lang.Long` 包装; +- `java.util.Date` 的 epoch 毫秒包装; +- Java DTO/Entity 缓存所需的具体类名和字段类型; +- Hash 缓存写入后的 86400 秒默认 TTL。 + +该兼容层使 Java 回滚进程可以读取 FastAPI 写入的缓存。应用启动和正常测试不会执行 +`FLUSHALL`;隔离测试脚本的 `reset` 只操作其自建 Redis 实例。定时任务使用 Redis +分布式锁和自动续租 watchdog,即使启动多个 jobs 容器,同一个任务也只有一个执行者。 + +## 安全与协议兼容 + +- 用户 Token 仍保存在 `sys_user_token`,按数据库过期时间校验;没有替换为 JWT。 +- 登录密文继续使用 SM2 C1C3C2,黄金向量由 Java 和 Python 双向解密测试校验。 +- `/config/*`、聊天记录上报/摘要/标题及地址簿内部接口继续校验数据库中的 + `server.secret` Bearer 值。 +- OTA、WebSocket 和 MQTT 保留原 HMAC、Base64、时间戳、Client-Id/Device-Id 及 token + 格式。 +- 密钥只通过环境变量或原参数表提供;`.env.example` 和部署文档不包含真实凭证。 + +## 配置与启动 + +### 本地 + +`.env.example` 是容器 Compose 模板,其中的 `mysql`、`redis` 是 Compose 服务名, +`/opt/xiaozhi/java-resources` 是镜像内路径,不能原样复制后用于宿主机进程。下面的流程 +显式使用 `127.0.0.1:13316` 上的隔离 MySQL、`127.0.0.1:16379` 上的隔离 Redis,以及 +仓库原始 Liquibase/i18n 资源;不会连接或迁移现有开发数据库: + +```bash +cd main/manager-api-fastapi +uv sync --locked + +./scripts/isolated-env.sh start + +LIQUIBASE_URL='jdbc:mysql://127.0.0.1:13316/manager_fastapi_test?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowMultiQueries=true' \ +LIQUIBASE_USERNAME='xiaozhi_test' \ +LIQUIBASE_PASSWORD='isolated-test-only' \ +JAVA_RESOURCES_DIR="$PWD/../manager-api/src/main/resources" \ +MAVEN_BIN="$PWD/../../.runtime/maven/bin/mvn" \ +MAVEN_LOCAL_REPOSITORY="$PWD/../../.runtime/m2" \ +JAVA_HOME="$PWD/../../.runtime/jdk" \ +./scripts/run-migrations.sh + +eval "$(./scripts/isolated-env.sh env)" +export APP_ENVIRONMENT=development +export APP_DATABASE_URL="$TEST_FASTAPI_DATABASE_URL" +export APP_REDIS_URL="$TEST_FASTAPI_REDIS_URL" +export APP_JAVA_RESOURCES_DIR="$PWD/../manager-api/src/main/resources" +export APP_UPLOAD_DIR="$PWD/.test-runtime/local-uploadfile" +mkdir -p "$APP_UPLOAD_DIR" +./scripts/start-api.sh +``` + +上述 `eval` 会得到明确的 localhost URL(FastAPI 测试库和 Redis DB 2)。定时任务必须 +作为单独进程启动;新终端需要重复 `eval` 及四个 `APP_*` 路径/URL 导出,不能让 jobs +进程落回 `.env.example` 的 Docker DNS: + +```bash +cd main/manager-api-fastapi +eval "$(./scripts/isolated-env.sh env)" +export APP_ENVIRONMENT=development +export APP_DATABASE_URL="$TEST_FASTAPI_DATABASE_URL" +export APP_REDIS_URL="$TEST_FASTAPI_REDIS_URL" +export APP_JAVA_RESOURCES_DIR="$PWD/../manager-api/src/main/resources" +export APP_UPLOAD_DIR="$PWD/.test-runtime/local-uploadfile" +./scripts/start-jobs.sh +``` + +API 默认监听 `0.0.0.0:8002`,兼容根路径为 +`http://127.0.0.1:8002/xiaozhi`。`APP_WORKERS` 可以大于 1;任务不会随 API worker +复制。验证结束后运行 `./scripts/isolated-env.sh stop`。生产环境不得设置 +`APP_ALLOW_START_WITHOUT_DEPENDENCIES=true`。 + +仓库统一启动脚本继承当前 shell 的上述环境变量;FastAPI 是默认实现,保留的 Java +实现可直接用于本地回滚: + +```bash +cd "$(git rev-parse --show-toplevel)" +scripts/restart-local-services.sh --manager-api fastapi --wait 180 +scripts/restart-local-services.sh --manager-api java --wait 180 +``` + +### 容器 + +下面的 `mysql`、`redis` 只在容器网络确实提供对应 DNS 名时有效,否则必须替换为该网络 +可访问的真实主机名。API 镜像内的 Java 资源路径是 `/opt/xiaozhi/java-resources`,迁移 +镜像则把同一仓库资源打包到 `/migration/java-resources`。以下示例中的凭证必须替换, +并应通过部署平台的 secret 注入而不是提交到仓库: + +```bash +cd main/manager-api-fastapi +export LIQUIBASE_URL='jdbc:mysql://mysql:3306/xiaozhi_esp32_server?serverTimezone=Asia/Shanghai' +export MYSQL_USER='xiaozhi' +export MYSQL_PASSWORD='replace-me' +export FASTAPI_DATABASE_URL='mysql+asyncmy://xiaozhi:replace-me@mysql:3306/xiaozhi_esp32_server?charset=utf8mb4' +export REDIS_URL='redis://redis:6379/0' +export MANAGER_API_UPSTREAM='manager-api-fastapi:8002' +docker compose build +docker compose up -d +``` + +若 `MANAGER_API_UPLOAD_SOURCE` 指向保留 Java 服务的宿主机上传目录,必须在启动前让 Java +运行用户与容器 UID 10001 都具备读写和目录遍历权限;不要盲目 `chown -R` 导致 Java 失去 +访问权,应使用部署环境的共享组或 ACL。空 named volume 在 Docker 通常会继承镜像中 +`/data/uploads` 的 UID,但并非所有 OCI runtime 都实现相同 copy-up 语义;必须以容器内 +UID 10001 做一次写入预检。`/xiaozhi/health/ready` 同时检查上传目录可写性,权限不正确时 +返回 HTTP 503 和 `data.uploads=false`,不得绕过该检查接入流量。Apple Container 的一次性 +卷初始化实测命令和结果记录在测试报告中。 + +`MANAGER_API_UPSTREAM` 默认值是 `manager-api-fastapi:8002`。修改变量后必须重建 Nginx +容器才能重新渲染配置;切到 FastAPI 和整服务回滚到 Java 的命令分别为: + +```bash +MANAGER_API_UPSTREAM='manager-api-fastapi:8002' \ + docker compose up -d --no-deps --force-recreate manager-api-nginx + +MANAGER_API_UPSTREAM=':8002' \ + docker compose up -d --no-deps --force-recreate manager-api-nginx +``` + +Java 地址必须能从 Nginx 容器网络解析和访问。内置 Nginx 的这个变量会切换整个 +`/xiaozhi`;逐业务域灰度需要在上层网关按路径配置两个 upstream,仍须遵守“同一业务域 +只有一个写入方”。 + +API 镜像(包括 jobs 命令)和迁移镜像都声明 `USER 10001:10001`,以非 root 用户运行。 +Compose 还为 API/jobs 设置只读根文件系统和 `/tmp` tmpfs;`/data/uploads` 是它们唯一的 +持久写目录,并通过 `/app/uploadfile` 符号链接兼容数据库中的 Java 相对路径。迁移容器是 +一次性非 root 进程,但 Compose 没有把它标为只读根文件系统。Nginx 镜像没有声明 +非 root `USER`,不能将其描述为非 root 镜像;它通过 Compose 的 `read_only: true` 及 +`/var/cache/nginx`、`/var/run`、`/tmp` 三个 tmpfs 加固。Nginx 保留 `/xiaozhi/` 路径, +关闭上传请求缓冲并设置 100 MiB 上限。健康检查分为: + +- `/xiaozhi/health/live`:进程存活; +- `/xiaozhi/health/ready`:MySQL、Redis 和上传目录写权限都可用才返回 HTTP 200,否则 + HTTP 503;`data.database`、`data.redis`、`data.uploads` 可直接定位失败项。 + +SIGTERM 触发 Uvicorn 优雅关闭;compose 给 API 40 秒清理在途请求和连接池。 + +### 容器验证边界 + +本次仓库内的实际容器运行验证使用 Apple Container 1.0.0,覆盖镜像构建、隔离 MySQL +迁移、双 API worker、独立 jobs、Nginx 路由、只读文件系统、上传卷和 SIGTERM 优雅 +关闭。`docker-compose.yml` 已通过 YAML 解析和自动化部署断言,但当前验证主机没有执行 +`docker compose up`,因此不能把 Apple Container 的运行结果表述为 Docker Compose +端到端通过。镜像摘要、实际命令和运行结果见 +`docs/manager-api-fastapi-test-report.md`;在目标 Docker/Compose 环境切流前,仍需按上面 +命令执行一次迁移、ready 检查和 Nginx upstream 冒烟测试。 + +## 灰度切流 + +1. 备份当前配置并确认 Java 基线健康;不要停止 Java。 +2. 对目标数据库执行原 Liquibase runner,确认 changeset 数量和校验和无差异。 +3. 启动 FastAPI API 但暂不接写流量,检查 live/ready、日志和外部 mock。Python jobs + 只在隔离环境验证后停止,生产中暂不持续运行,避免与 Java 调度器同时写入。 +4. 先镜像或回放脱敏的只读请求,比较状态、Body、Header、数据库读结果和缓存读取。 +5. 按业务域把只读流量从 1% 提升到 10%、50%、100%,监控错误码、P95、数据库连接、 + Redis 命中与外部服务错误。 +6. 对一个完整业务域建立维护窗口,停止该域 Java 新写入,等待在途事务结束,然后把该域 + 写路由切到 FastAPI。记录切换时间和最后写入方。 +7. 逐域重复;稳定观察期内保留 Java 镜像、配置和回切路由。 +8. 所有域稳定后才把 jobs 所有权切给 Python;Java 的定时任务进程必须同时停用,避免 + 两套调度器并行。 + +## 回滚 + +1. 冻结待回滚业务域的新写入,等待 FastAPI 在途请求和 jobs 当前轮次结束。 +2. 停止 Python jobs,确认 Redis 分布式锁已释放;不要清空 Redis。 +3. 将该域的 Nginx upstream 切回原 Java `manager-api`,保持 `/xiaozhi` 路径不变。 +4. 用 Java 健康检查和代表性读请求确认 Token、缓存、上传文件与数据库数据可读。 +5. 恢复 Java 写流量并记录回滚边界;不要让 FastAPI 继续写该域。 +6. 若问题来自新 changeset,只能新增一个经评审的 Liquibase 前向修复;不得删除或改写 + `DATABASECHANGELOG` 历史。 + +容器整服务回滚时,先把 `` 替换为 Nginx 容器可访问的真实地址,再只 +重建代理;`--no-deps` 可避免回滚命令意外重启 FastAPI 或重复执行迁移: + +```bash +MANAGER_API_UPSTREAM=':8002' \ + docker compose up -d --no-deps --force-recreate manager-api-nginx +``` + +FastAPI 没有改变现有 schema,且 Redis 写入采用 Java 兼容格式,因此正常应用回滚不需要 +数据反向迁移。若外部系统已接收不可撤销操作,按对应供应商的业务补偿流程处理,不能用 +数据库回滚伪造外部成功或失败。 + +## 隔离验证 + +`scripts/isolated-env.sh` 只创建 `manager_java_test`、`manager_fastapi_test` 两个测试库和 +端口 `16379` 上的独立 Redis;其中的测试密码仅用于本机隔离环境。标准流程是: + +```bash +cd main/manager-api-fastapi +./scripts/isolated-env.sh start +./scripts/isolated-env.sh reset +./scripts/isolated-env.sh migrate +eval "$(./scripts/isolated-env.sh env)" +.venv/bin/pytest -m integration -q +./scripts/isolated-env.sh stop +``` + +实际执行结果、差分用例和不能使用真实凭证完成的联调项记录在 +`docs/manager-api-fastapi-test-report.md`;逐接口状态记录在 +`docs/manager-api-fastapi-compatibility.md`。 diff --git a/docs/manager-api-fastapi-test-report.md b/docs/manager-api-fastapi-test-report.md new file mode 100644 index 00000000..f59e19a3 --- /dev/null +++ b/docs/manager-api-fastapi-test-report.md @@ -0,0 +1,582 @@ +# manager-api FastAPI 迁移测试报告 + +> 执行日期:2026-07-20(Asia/Shanghai) +> +> 工作目录:`/Users/mie/Desktop/Repo/xiaozhi-esp32-server` +> +> FastAPI 目标:`main/manager-api-fastapi` +> Java 基线:`main/manager-api` + +本报告只记录实际执行并有输出或落盘证据的检查。结构路由闭合、154 条未认证/非法请求面 +差分、154 条已认证安全业务/校验差分、领域测试和 49 条深度 Java/FastAPI 差分是不同强度 +的证据,不互相替代。生产外部服务和真实硬件没有验证的部分,均不会写成通过。 + +## 1. 结果摘要 + +| 检查项 | 通过 | 失败/错误 | 跳过 | 结论 | +|---|---:|---:|---:|---| +| Java 基线测试 | 98 | 0 | 0 | 最终复跑 `BUILD SUCCESS`,15.602 秒 | +| FastAPI 全量 pytest(最终回归) | 139 | 0 | 0 | 12.75 秒;含上传卷 readiness 与证据脱敏回归 | +| 隔离 MySQL/Redis 集成测试复跑 | 7 | 0 | 0 | 事务、锁、TTL、job 单实例与 watchdog 全绿 | +| Java→FastAPI 未认证/非法请求面差分 | 154 | 0 | 0 | 每条 Java 路由各 1 个无成功写入的缺认证或非法请求,逐项比较 status/body/Content-Type | +| Java→FastAPI 已认证安全业务/校验差分 | 154 | 0 | 0 | 每条 Java 路由各 1 个带正确认证的安全业务或校验请求;runner 有意不执行成功写入 | +| Java→FastAPI 深度差分契约 | 49 | 0 | 0 | 成功、主要错误与数据库副作用;直接覆盖 21/154 条 Java 路由 | +| 简单性能测试 | 480 请求 | 0 请求错误 | 不适用 | 4 场景 × 2 服务 × 60 次计量请求 | +| Java 路由结构清单 | 154/154 | 0 | 0 | FastAPI 注册闭合;结构证据,不等同逐接口行为证据 | +| 三端消费者调用点 | 188/188 | 0 | 0 | Web 134、Mobile 46、xiaozhi-server 8 个调用点均可解析 | +| FastAPI Ruff | 通过 | 0 | 不适用 | `app tests scripts` | +| FastAPI mypy | 70 个源文件 | 0 | 不适用 | strict 配置下通过 | +| FastAPI compileall | 通过 | 0 | 不适用 | `app tests scripts` | +| 锁文件与依赖同步 | 通过 | 0 | 不适用 | locked 环境共 55 packages | +| Python sdist/wheel | 2 个产物 | 0 | 不适用 | 冷环境完整依赖安装后可导入,路由数 163 | +| manager-web | i18n、5 unit、13 snapshot、build 全过 | 0 | 0 | build 有 4 条既有 size/precache warning | +| manager-mobile | type、lint、14 snapshot、mp-weixin build 全过 | 0 | 0 | 使用仓库声明的 pnpm 10.10.0 | +| xiaozhi-server | compileall 通过 | 0 | 不适用 | 除性能脚本外没有自动单测;8 个调用由 consumer 契约检查 | +| 真实付费/生产外部服务 | 0 | 不适用 | 不适用 | 无真实凭证,不声称联调通过 | +| 容器迁移、API、jobs 与 Nginx | 通过 | 0 | 0 | Apple Container 1.0.0 实际 build/run;Compose 仅做静态验证,未伪装为 `docker compose up` | + +最终可重复执行的全量测试、隔离差分、集成、构建和容器运行验证均为绿色。以下范围限制必须 +与绿色测试分开陈述: + +1. 全部 154 条 Java 路由均执行了两次差分:一次未认证/非法请求,一次已认证安全业务/校验 + 请求。第二个 runner 为保护隔离 fixture,有意不执行成功写入;49 个深度 checks 直接命中 + 21 条路由并覆盖代表性成功、错误和数据库副作用。因此两层全路由差分仍不等同于每条路由 + 的完整成功写入生命周期和全部错误路径差分。 +2. 没有真实凭证、生产网络或硬件的外部集成未被计入通过。 + +## 2. 验证环境 + +| 组件 | 实际版本/配置 | +|---|---| +| 主机 | macOS 27.0,arm64 | +| Java | Oracle JDK 21.0.11 LTS | +| Maven | 3.9.9,使用仓库 `.runtime/m2` | +| Python | 3.10.20,`main/manager-api-fastapi/.venv` | +| MySQL | Community Server 8.0.46;隔离端口 `13316` | +| Redis | 8.8.0;隔离端口 `16379` | +| Node.js | v24.18.0 | +| npm | 11.16.0 | +| manager-mobile pnpm | Corepack 解析的 10.10.0 | +| OCI runtime | Apple Container 1.0.0;本机没有可用 Docker/Podman daemon | +| 容器架构 | linux/arm64 | +| 时区 | Asia/Shanghai | + +隔离测试只重置 `manager_java_test`、`manager_fastapi_test` 两个测试 schema 和端口 +`16379` 上的专用 Redis;没有连接、修改或清空开发 MySQL/Redis。Java 差分使用 Redis DB 1, +FastAPI 使用 DB 2;Java 单元验证显式使用 DB 3。 + +## 3. 实际执行命令 + +### 3.1 Java 基线 + +```bash +cd main/manager-api && \ +JAVA_HOME=../../.runtime/jdk \ +PATH="../../.runtime/jdk/bin:../../.runtime/maven/bin:$PATH" \ +../../.runtime/maven/bin/mvn -o \ + -Dmaven.repo.local=../../.runtime/m2 \ + -Dspring.datasource.druid.url='jdbc:mysql://127.0.0.1:13316/manager_java_test?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true&allowMultiQueries=true' \ + -Dspring.datasource.druid.username=xiaozhi_test \ + -Dspring.datasource.druid.password=isolated-test-only \ + -Dspring.data.redis.host=127.0.0.1 \ + -Dspring.data.redis.port=16379 \ + -Dspring.data.redis.database=3 \ + -Dspring.data.redis.password= \ + -DskipTests=false test +``` + +最终复跑结果:98 tests,0 failures,0 errors,0 skipped,`BUILD SUCCESS`,15.602 秒。Surefire +XML 位于 `main/manager-api/target/surefire-reports/`,各 suite 的 tests 合计为 98。 + +### 3.2 FastAPI 全量测试 + +```bash +cd main/manager-api-fastapi && \ +eval "$(./scripts/isolated-env.sh env)" && \ +APP_DATABASE_URL="$TEST_FASTAPI_DATABASE_URL" \ +APP_REDIS_URL="$TEST_FASTAPI_REDIS_URL" \ +APP_ENVIRONMENT=test \ +.venv/bin/pytest -q +``` + +最终结果:139 passed、0 failed、0 skipped,12.75 秒。 + +### 3.3 隔离集成、两层全路由差分、深度差分和性能测试 + +```bash +cd main/manager-api-fastapi && ./scripts/run-isolated-contract-tests.sh +``` + +最终执行结果为 exit 0。脚本实际完成以下阶段: + +- 启动并重置隔离 MySQL `13316`、Redis `16379`; +- 对 Java 与 FastAPI 两个 schema 分别执行原 Liquibase 101 个 changeSets; +- 启动 Java 基线 `18082`、FastAPI `18083`、确定性外部 mock `18084`; +- 生成并镜像固定用户、DB Token、Long ID、设备、agent、模型、纠错词和 OTA fixture; +- 执行 7 条隔离集成测试; +- 对 154 条 Java 路由各执行一条未认证或非法的安全请求面差分,不产生成功写入; +- 对 154 条 Java 路由各执行一条带正确认证的安全业务或校验差分,仍不产生成功写入; +- 执行 49 条 Java/FastAPI 差分检查并写入 JSON; +- 执行 480 次计量性能请求并写入 JSON; +- 对 FastAPI/mock 日志执行 warning、traceback、error 门禁; +- 退出时关闭 Java、FastAPI 和 mock,最终 `18082`~`18084` 没有监听进程。 + +最终阶段摘要(两行 154 分别对应未认证/非法和已认证安全业务/校验): + +```text +7 passed +{"total": 154, "passed": 154, "failed": 0, "skipped": 0} +{"total": 154, "passed": 154, "failed": 0, "skipped": 0} +{"total": 49, "passed": 49, "failed": 0, "skipped": 0} +{"measurements": 8, "requests_measured": 480, "errors": 0} +Isolated integration, two 154-route surfaces, deep differential, and performance tests passed. +``` + +机器结果: + +- `main/manager-api-fastapi/compatibility/route-surface-results.json` + - 生成时间:`2026-07-20T07:12:28.099864+00:00` + - 154 passed、0 failed、0 skipped +- `main/manager-api-fastapi/compatibility/authenticated-route-results.json` + - 生成时间:`2026-07-20T07:12:30.818073+00:00` + - 154 passed、0 failed、0 skipped +- `main/manager-api-fastapi/compatibility/contract-results.json` + - 生成时间:`2026-07-20T07:12:31.520810+00:00` + - 49 passed、0 failed、0 skipped +- `main/manager-api-fastapi/compatibility/performance-results.json` + - 生成时间:`2026-07-20T07:12:33.158826+00:00` + - 480 requests、0 errors + +### 3.4 FastAPI 静态、依赖和构建验证 + +```bash +cd main/manager-api-fastapi +.venv/bin/ruff check app tests scripts +.venv/bin/mypy app +.venv/bin/python -m compileall -q app tests scripts +uv lock --check && uv sync --locked +uv build --no-cache +``` + +结果: + +- Ruff 通过; +- mypy:70 个源文件无问题; +- compileall:exit 0; +- lock check 与 locked sync:exit 0,共 55 packages; +- 无缓存构建 sdist 与 wheel 均成功。 + +构建产物另在临时冷虚拟环境验证。首次执行 `uv pip install --python +<临时环境>/bin/python --no-deps ` 后直接 import,因刻意没有安装 FastAPI 等运行依赖而 +失败;这暴露的是冷 wheel 检查命令不完整,不是把失败隐藏为通过。随后执行带依赖的安装: + +```bash +uv pip install --python <临时环境>/bin/python +``` + +共安装 39 个锁定依赖。从仓库外 `/tmp` 导入成功,输出 +`xiaozhi-manager-api 163`,证明不是依赖当前工作目录导入源码。 + +### 3.5 manager-web + +```bash +cd main/manager-web && \ +npm run check:i18n && \ +npm run test:unit && \ +npm run test:snapshot && \ +npm run build +``` + +结果: + +- i18n:6 个 locale,每个 1527 keys,key 结构一致; +- unit:5/5; +- snapshot:13/13; +- Vue 生产构建 exit 0(hash `71b64d002eb434ee`,1069 ms); +- 输出有 4 条既有 bundle size/precache warning,没有将 warning 写成失败,也没有删除或放宽 + 测试来取得绿色结果;另有 `caniuse-lite` 数据过期 17 个月提示,未擅自更新依赖。 + +### 3.6 manager-mobile + +```bash +cd main/manager-mobile && \ +corepack pnpm type-check && \ +corepack pnpm lint && \ +corepack pnpm test:snapshot && \ +corepack pnpm build:mp +``` + +结果:type-check exit 0、lint exit 0、snapshot 14/14、mp-weixin build exit 0。构建提示 +`caniuse-lite` 数据过期 20 个月及 uni-app 有新版本;两项均不影响退出码,未擅自更新依赖。 + +一次较早的失败尝试直接调用普通 `pnpm`,环境解析到 v11,并因非 TTY 下依赖目录清理提示而 +终止;没有把该次尝试写成通过。最终命令显式使用 Corepack,解析到仓库声明的 pnpm 10.10.0。 + +### 3.7 xiaozhi-server + +```bash +cd main/xiaozhi-server && \ +../manager-api-fastapi/.venv/bin/python --version && \ +PYTHONPYCACHEPREFIX=/tmp/xiaozhi-server-pycache \ + ../manager-api-fastapi/.venv/bin/python -m compileall -q . && \ +rg --files -g '*test*.py' -g '!performance_tester/**' +``` + +结果:Python 3.10.20,compileall exit 0。排除性能测试目录后没有自动单测文件,因此没有虚构 +pytest 通过数量;`xiaozhi-server` 的 8 个 manager-api 调用点由 consumer manifest 和 FastAPI +兼容测试验证为可解析。 + +首次误用不存在的 `../../.runtime/python/bin/python3.10`,结果为 exit 127;改用上面实际存在的 +FastAPI Python 3.10.20 后通过。 + +## 4. 两层全路由差分与深度差分覆盖 + +### 4.1 154 条未认证/非法安全请求面差分 + +`tests/compatibility/route_surface_runner.py` 从 Java route manifest 逐条构造不发生成功写入的请求: +133 条 DB Token 路由省略 Token,14 条匿名路由发送安全非法输入,7 条内部路由省略 +server-secret。Java 与 FastAPI 精确比较 HTTP status、解析后的 body 和 Content-Type;最终 +154 passed、0 failed、0 skipped。它证明每条 Java 路由至少有一个请求路径兼容,不代表每条 +路由的成功、全部错误与数据库副作用均已逐项对照。 + +### 4.2 154 条已认证安全业务/校验差分 + +`tests/compatibility/authenticated_route_runner.py` 使用与 Java 基线语义一致的 DB Token、 +server-secret 或匿名认证方式,对同一份 154 路由清单逐条发送安全业务/校验请求。runner 对 +动态管理员密码和 OTA 下载 UUID 先独立验证格式再做最小归一化,并同步会影响响应的固定审计 +时间;最终 154 passed、0 failed、0 skipped。为不污染 fixture 或产生不可逆副作用,该 runner +有意选择资源不存在、单一约束失败、幂等空操作等不会成功写入的路径。因此这里的“已认证” +证明请求已经越过认证层并进入业务/校验逻辑,不表示每条写接口都完成了一次成功写入。 + +### 4.3 49 项深度结果分布 + +| 类别 | 通过 | 失败 | +|---|---:|---:| +| configuration | 1 | 0 | +| authentication-i18n | 7 | 0 | +| authentication | 1 | 0 | +| authorization | 1 | 0 | +| serialization | 2 | 0 | +| agent | 1 | 0 | +| device | 1 | 0 | +| model | 1 | 0 | +| correct-word | 1 | 0 | +| binary-download | 6 | 0 | +| validation | 5 | 0 | +| server-secret | 3 | 0 | +| ota | 1 | 0 | +| ota-validation | 2 | 0 | +| ota-signing | 2 | 0 | +| activation | 3 | 0 | +| external-mock | 2 | 0 | +| crud | 3 | 0 | +| database-side-effect | 4 | 0 | +| upload | 1 | 0 | +| upload-validation | 1 | 0 | +| **合计** | **49** | **0** | + +直接覆盖内容包括: + +- 默认语言、`zh-CN`、`zh-TW`、`en-US`、`de-DE`、`vi-VN`、`pt-BR` 七种 + `Accept-Language` 情形; +- 未登录、DB Token 过期、普通用户访问管理员接口; +- Long ID 字符串、日期、Asia/Shanghai/UTC 兼容、null、别名和分页; +- 缺字段、数字格式、最小/最大边界和 Java UTF-16 长度语义; +- server-secret 缺失、错误和正确三条路径; +- OTA health、缺失/非法 `Device-Id`、激活、WS/MQTT credential; +- HMAC-SHA256、URL-safe Base64、MQTT Base64 密码的独立密码学验证; +- 纠错词创建、更新、下载、删除及数据库副作用; +- OTA multipart 上传、扩展名错误、元数据副作用、三次下载与第四次 404; +- 二进制 MIME、`Content-Disposition`、`Content-Length` 和字节摘要; +- MQTT mock 的请求 body 和按日期生成的 Authorization。 + +### 4.4 有意的动态值处理 + +- OTA timestamp、WebSocket token 和生成 UUID 先做最小范围归一化,再分别验证格式与 HMAC; + 不是把动态字段全部忽略。 +- Hibernate Validator 使用无序 `ConstraintViolation Set`。模型 provider 空 body 用例要求 + Java/FastAPI 均返回 HTTP 200、错误码 10034,消息必须属于 Java DTO 声明的五个精确约束, + 不强行固定 Java 本身不稳定的首条消息。 +- 报告落盘前递归脱敏 `private_key`、server secret、MQTT signature key、Token、password 和 + Authorization;比较与密码学验证仍使用未脱敏的内存值。 + +### 4.5 覆盖边界 + +Java 清单共有 154 条路由,FastAPI 结构注册为 154/154;另有 3 条消费者兼容路由。全部 154 +条均有一次未认证/非法差分和一次已认证安全业务/校验差分;49 个深度 checks 直接命中 +21/154 条 Java 路由,并对代表性成功、主要错误和数据库副作用做更完整的生命周期验证。 +其余路由虽有两层逐路由差分及相关领域 service/repository/protocol 测试,仍不能宣称其全部 +成功写入和错误路径均已逐项深度差分。逐行状态见 +`docs/manager-api-fastapi-compatibility.md`。 + +## 5. 隔离数据库、Redis 和 job 测试 + +7 条集成测试均连接隔离 MySQL/Redis,而不是纯 mock: + +1. MySQL 事务异常回滚; +2. `SELECT FOR UPDATE` 在并发写入下串行化; +3. Redis key TTL 到期且不删除无关 key; +4. Redis 分布式锁只允许一个并发 job 执行; +5. watchdog 在任务超过原始 lease 后续租,仍保持单实例; +6. 实际 knowledge job 函数在 Redis 锁下只执行一次; +7. Java hash 兼容写入的默认 TTL 为 86400 秒。 + +差分 CRUD 另检查 Java/FastAPI 各自隔离 schema 的行级副作用;没有实施双写,也没有操作开发 +数据库。最终 FastAPI 与 external mock 日志没有 warning/error/traceback。两层全路由差分会 +让 Java 基线按其既有全局异常处理记录 36 条预期 ERROR,严格门禁将其精确分为 8 类:缺 body +13 条、缺 `Device-Id` 5 条、对象/数组反序列化 8 条、缺 query 4 条、非 multipart 3 条、 +`callerMac` 空值 1 条、空消息 1 条及 `null` 消息 1 条。未分类 Java ERROR 为 0;任一类别数量 +变化或出现未分类日志都会使脚本失败。 + +## 6. 性能对比 + +参数:每个服务每场景先顺序 warmup 10 次,再以并发 6 计量 60 次;4 个场景、2 个服务共 +480 次计量请求。结果来自最终 `performance-results.json`: + +| 场景 | 服务 | p50 ms | p95 ms | 吞吐 req/s | 错误 | +|---|---|---:|---:|---:|---:| +| representative-read | Java | 7.662 | 16.239 | 601.128 | 0 | +| representative-read | FastAPI | 6.749 | 12.552 | 751.538 | 0 | +| representative-crud-update | Java | 9.331 | 15.302 | 543.013 | 0 | +| representative-crud-update | FastAPI | 11.252 | 20.599 | 476.870 | 0 | +| runtime-configuration | Java | 8.746 | 16.127 | 568.863 | 0 | +| runtime-configuration | FastAPI | 6.488 | 13.722 | 765.126 | 0 | +| ota-check-and-signing | Java | 11.301 | 19.854 | 454.655 | 0 | +| ota-check-and-signing | FastAPI | 16.208 | 20.344 | 354.807 | 0 | + +FastAPI/Java 比率:读取 p50 `0.881`、p95 `0.773`、吞吐 `1.250`;CRUD p50 `1.206`、 +p95 `1.346`、吞吐 `0.878`;配置 p50 `0.742`、p95 `0.851`、吞吐 `1.345`;OTA p50 +`1.434`、p95 `1.025`、吞吐 `0.780`。因此不能笼统声称所有 FastAPI 接口都更快:本轮读取和 +配置场景更快,CRUD p50/p95 分别较慢约 20.6%/34.6%,OTA p50 较慢约 43.4%、p95 接近、 +吞吐低约 22.0%。 + +这是同机、短时、固定 fixture 的简单对比,用于发现数量级回退,不是容量、长稳、生产网络或 +多 worker 极限测试。 + +## 7. 三端兼容验证 + +- `manager-web`:134 个调用点、130 条唯一结构路由;i18n、unit、snapshot 与 production build + 均通过,现有调用无需修改 URL。 +- `manager-mobile`:46 个调用点、40 条唯一结构路由;type-check、lint、snapshot 与微信小程序 + build 均通过。 +- `xiaozhi-server`:8 个调用点、8 条唯一结构路由;compileall 通过,consumer manifest 确认 + 全部能解析到 FastAPI。该模块没有可执行的一般单测,不能把 compileall 写成运行时集成通过。 + +三端合计 188 个调用点、140 条唯一结构路由。此结论证明 path/method 解析闭合;全部 Java +路由另有未认证/非法和已认证安全业务/校验两层差分,参与 49 项深度差分或领域测试的调用拥有 +更完整的成功、错误或副作用证据。 + +## 8. 迁移验证过程中发现并修正的问题 + +测试没有通过删除、跳过或放宽失败用例获得绿色。差分在实现过程中实际暴露并促成修复的兼容 +问题包括:分页 `total` 类型、运行时配置字段查询、设备日期时区、认证与 OTA MIME、缺失 +`Device-Id` envelope、provider 校验语义、非法分页消息、运行时配置 key 命名。修复后才生成 +当前 49/49 深度报告。 + +154 路由请求面 runner 首次执行只有 149/154 通过,准确暴露 5 条“整个 JSON body 缺失”差异: +`POST /ota/`、`POST /user/login`、`POST /user/register`、 +`POST /user/retrieve-password`、`POST /user/smsVerification`。Java 对整个 body 缺失返回 +HTTP 200、`code=500` 的通用 envelope,FastAPI 当时返回 `code=10034`。全局校验兼容层随后 +只对定位恰为 body 根节点的 missing 错误映射 `code=500`,字段级 missing 仍保持 `10034`; +新增回归用例并完整重跑后才得到 154/154。 + +已认证安全业务/校验 runner 首次执行为 111/154,通过实际差分先后修正了根 body 类型、必填 +query/multipart 的 Java envelope、knowledge/device/agent/voice resource 的检查顺序、权限与资源 +不存在语义,以及 DTO 单约束消息等差异;后续结果依次为 149/154、152/154,最终才达到 +154/154。对 Hibernate Validator 无序约束的请求,runner 改用只触发一个约束的定向 payload, +没有跳过路由、忽略响应字段或放宽比较。该 runner 始终保持“已认证但不成功写入”的安全边界。 + +另有测试基础设施、运行时和构建问题被明确记录: + +- Pydantic 2.13 与 FastAPI 0.116 的 TypeAdapter alias 路径产生 + `UnsupportedFieldAttributeWarning`;依赖锁定到 Pydantic 2.11.7/core 2.33.2 后,实际 OTA + body alias 验证与最终日志门禁均无 warning。 +- 第一版日志门禁把 Logback 初始化文本 `ERROR_FILE` 误判为运行时 ERROR,虽然 7、49、480 + 阶段均绿,脚本仍按门禁返回 1。正则收紧为带完整日期的 Java 应用 ERROR 后,重新从头执行 + 完整流程并获得 exit 0;没有直接忽略门禁失败。 +- fixture 的 MySQL `VALUES()` upsert 产生 8.0 弃用 warning;改为 row alias 语法并重新执行后 + 无该 warning。 +- 已认证差分报告最初虽未含真实凭证,但 `paramCode=server.secret` / `paramValue` 结构仍写入了 + 隔离 fixture 值;递归脱敏器补充键值对识别及回归测试后,再次完整执行差分。最终四份 JSON + 对 `contract-server-secret`、测试 Token 和测试数据库密码的扫描均为 0 命中。 +- Python 3.10 下 fixed-delay jobs 等待超时抛出 `asyncio.TimeoutError`;原捕获路径导致 worker + 首轮后退出。worker 改为捕获该异常并增加 Python 3.10 回归测试;实际 jobs 容器随后观察到 + knowledge job 跨 30 秒重复运行,snapshot redaction 多轮执行,SIGTERM 后干净退出。 +- API 镜像构建初期遇到 uv/pip registry 传输失败;Dockerfile 固定 uv 版本并增加 timeout、retry + 与缓存后完成构建。迁移镜像的 Maven Central 并发下载两次卡住,改为串行 resolver、超时与 + retry 后成功。Nginx 初版配置在镜像 build 期校验失败,改用 template + `envsubst` 并在 build + 内执行 `nginx -t` 后通过。 +- Apple Container 自定义网络没有提供本次验证所需的容器名 DNS,host publish 和单文件挂载也 + 与 Docker 行为不同;验证改用容器 IP、显式 TCP bridge、named volume 和运行时 upstream 模板。它们 + 是测试 runtime 限制,不被记作应用通过或失败,也没有据此声称 Docker Compose 已实际启动。 + +## 9. 外部服务与真实联调状态 + +所有自动化外部调用只访问本地确定性 mock/fixture,不访问真实付费服务。 + +| 外部能力 | 自动化证据 | 真实联调状态 | +|---|---|---| +| RAGFlow dataset/document/chunk/retrieval/upload | 请求 JSON/query/header、30 秒 timeout、强 DTO、Long/null、错误映射与补偿路径测试 | 无真实 RAGFlow 凭证/实例,未联调 | +| 阿里云短信 | 配置、错误 envelope 与业务路径测试 | 无真实 AccessKey,不发送短信,未联调 | +| 火山语音克隆/音频 | multipart/JSON、状态及错误映射 mock | 无真实付费凭证,未联调 | +| 声纹 HTTP | Java multipart 形状与错误映射 mock | 无真实声纹服务,未联调 | +| OpenAI-compatible LLM | 请求格式、thinking policy、摘要/标题相关 mock | 无真实模型 key,不访问付费模型,未联调 | +| MQTT gateway HTTP | 差分验证 body、按日期 Authorization 和 401 retry 语义 | 无真实 MQTT broker/gateway,未联调 | +| MCP/管理 WebSocket | token、URL、path/scheme/form 兼容测试 | 无真实远端 MCP/WS,未联调 | +| OTA/WS/MQTT credential | 本地 HMAC/Base64/时间戳和下载行为实测 | 无 ESP32 真机和生产 broker,不属于硬件联调 | + +因此,本报告只证明 mock 下已覆盖的请求格式、超时、错误映射、重试和本地密码学行为;不能把 +任何一项写成供应商或生产环境端到端通过。 + +## 10. 已知行为/部署差异 + +- Java 的 Hibernate Validator 首条约束消息顺序不稳定;FastAPI 保持相同 envelope、错误码和 + 声明消息集合,而不是伪造固定顺序。 +- Java 在 Spring 进程内运行定时任务;FastAPI 把 jobs 分离为独立进程,并用 Redis 锁和 + watchdog 防止多 worker 重复执行。集成测试验证单实例和续租语义,但部署拓扑有意不同。 +- FastAPI 增加 3 条消费者兼容路由和 live/ready health endpoints;它们没有 Java Controller + 基线,属于明确的加法差异。 +- 49 项已执行深度差分中没有观测到响应、所选 header 或数据库副作用差异;这句话只适用于 + 报告中的 49 项,不外推为全部 154 条路由均完成了成功写入和全部错误路径生命周期验证。 + +## 11. 实际容器与 Nginx 验证 + +### 11.1 Runtime、镜像与 Compose 口径 + +本机没有可用的 Docker/Podman daemon,实际 OCI build/run 使用 Apple Container 1.0.0 的 +linux/arm64 VM,并显式使用隔离 app/log/install root: + +```bash +CLI=/Users/mie/.cache/xiaozhi-migration-tools/container-1.0.0-prefix/bin/container +ROOT=/Users/mie/.cache/xiaozhi-migration-tools/container-1.0.0-prefix +"$CLI" system start \ + --app-root "$ROOT/runtime-data" \ + --install-root "$ROOT" \ + --log-root "$ROOT/runtime-logs" \ + --disable-kernel-install +"$CLI" builder start +"$CLI" build --tag xiaozhi/manager-api-fastapi:0.1.0 \ + --file main/manager-api-fastapi/Dockerfile . +"$CLI" build --tag xiaozhi/manager-api-migrate:fastapi-0.1.0 \ + --file main/manager-api-fastapi/Dockerfile.migrations . +"$CLI" build --tag xiaozhi/manager-api-nginx:fastapi-0.1.0 \ + --file main/manager-api-fastapi/Dockerfile.nginx . +``` + +三张镜像均实际构建并运行。迁移镜像 OCI index 为 +`sha256:613faace4314b03392e65b64d9b4a9ba7a694cdd751c1a45009824d55f0647f7`,其 arm64 +manifest 为 `sha256:6a10850841370d033a3b521fbb1100cb64b5cc6837fac35d00c4257343c0f2f9`; +Nginx 镜像 OCI index 为 +`sha256:2e6a188ad6d38b62fa4e77329a629ada00c4e773da9ded73c5f3289e40da477a`,其 arm64 +manifest 为 `sha256:ff653bc2d11d4a3b1640747626055d6551fb33324500fb2e65b9333142da8526`。 +API 镜像在上传目录 readiness 最后一处源码变更后重新 build;最终 OCI index 为 +`sha256:04ae1a98307b7369368b9665c6caf9f0911c8b2a967f5a91f23c6dde7c7baa16`,其 arm64 +manifest 为 `sha256:c3267d307c9898975372539121f118549bfe51012c6c9bfdc3e84f99f3e56214`, +config 为 `sha256:7c0b13757da041c0d118d14342e92c5310e4fb2140f029e385e97de9fe21d8cc`, +manifest size 为 84,551,252 bytes,镜像配置创建时间为 `2026-07-20T07:04:45Z`。 + +`docker-compose.yml` 已由 `tests/test_deployment_artifacts.py` 静态验证 migration dependency、 +read-only root、tmpfs、upload volume、healthcheck、graceful timeout 与可切换 upstream;Nginx +镜像 build 内也实际执行 `nginx -t`。由于本机没有 Docker Compose runtime,本报告明确只把 +Compose 记为静态通过,不声称执行过 `docker compose up`。 + +### 11.2 Liquibase migration + +迁移镜像以 UID 10001 一次性运行,只读取原 Java resources 内的 Liquibase 历史。目标为隔离 +schema `manager_container_test`;最终容器回归中再次运行并报告 101 个 changeSets 均 +up-to-date。随后实查 `DATABASECHANGELOG` 为 101 条、业务及 Liquibase 表合计 30 张, +`DATABASECHANGELOGLOCK.LOCKED=0`,证明历史完整且锁已释放。没有连接、修改或清空开发数据库。 + +### 11.3 API、jobs、health、卷与优雅关闭 + +Apple Container VM 访问 host-only MySQL/Redis 时使用仓库内 TCP bridge,而不是暴露开发服务: + +```bash +cd main/manager-api-fastapi +.venv/bin/python -m tests.compatibility.tcp_proxy \ + --listen-port 13317 --target-host 127.0.0.1 --target-port 13316 +.venv/bin/python -m tests.compatibility.tcp_proxy \ + --listen-port 16380 --target-host 127.0.0.1 --target-port 16379 +``` + +API 容器使用 `APP_WORKERS=2`、隔离 schema、Redis DB 4、read-only root、`/tmp` tmpfs 和 +named upload volume 启动。实测结果: + +- 容器内 UID 为 10001,最终层没有 `/bin/uv` 和 `/usr/bin/gcc`,应用路由数为 163; +- 日志确认 2 个 Uvicorn worker(容器内 PID 3、4);`/xiaozhi/health/live` 为 HTTP 200; +- `Accept-Language: en-US` 的未认证业务请求保持 HTTP 200、英文 `{code:401,...}`; + `POST /xiaozhi/user/login` 整个 JSON body 缺失保持 Java 的 HTTP 200、`code=500`; +- read-only root 生效。Apple Container 新建空 named volume 首次以其默认 root ownership 挂载, + 新增 readiness 检查准确返回 HTTP 503、`database=true`、`redis=true`、`uploads=false`,没有让 + 无法上传的实例接流量;该失败没有伪装为通过。随后用一次性 root 容器仅对卷执行 `chown` + ownership 初始化,ready 变为 HTTP 200 且 `uploads=true`,UID 10001 的 API 成功写入,重启后 + 文件 SHA256 `1ad4cb4f879aa1ddf43a14e1a84cc5dbf8f65e91295165e126b8b08be3cd9a50` 保持不变; +- 发送 SIGTERM 后 worker 完成 lifespan shutdown 并以 exit 0 退出,无 traceback/error。 + +同一 API 镜像另以 `python -m app.jobs.worker`、read-only root 和 `/tmp` tmpfs 启动。实际等待 +超过 31 秒后,knowledge fixed-delay job 执行两次且相隔 30 秒,snapshot redaction 多次执行; +这验证 Python 3.10 timeout 修复与真实调度循环。SIGTERM 后 jobs 也干净退出。API 多 worker +本身不加载 jobs,独立 worker 再由 Redis lock/watchdog 保证单实例。 + +上述 ownership 初始化是 Apple Container 空 named volume 的实测处理;本机没有 Docker +Compose runtime,因此 Docker Compose 的 named-volume copy-up 行为没有实际验证,不能用 +Apple Container 的结果代替。 + +### 11.4 Nginx 切流与 Java 回滚 + +Nginx 镜像以 read-only root 和 `/var/cache/nginx`、`/var/run`、`/tmp` 三个 tmpfs 运行;其 +entrypoint 将 `MANAGER_API_UPSTREAM` 注入模板后 `exec nginx`。Apple Container 自定义网络在 +本次环境没有容器名 DNS,因此实测使用 runtime 分配的 API/Java 容器 IP,语义与生产 hostname +upstream 相同。FastAPI upstream 下实际验证: + +- `/xiaozhi/health/ready` 为 HTTP 200; +- `/xiaozhi` 精确返回 308 到 `/xiaozhi/`; +- `Accept-Language: en-US` 的未认证 envelope 由 Nginx 转发后与直连 FastAPI 一致,整个 JSON + body 缺失也保持 HTTP 200、`code=500`; +- Nginx、API 均在 SIGTERM 下以 exit 0 干净退出。 + +随后仅替换 `MANAGER_API_UPSTREAM` 指向保留的 Java 容器并重建 Nginx 运行实例;`/xiaozhi/ota/` +回滚探针的 response body 与直连 Java 按字节完全一致。此步骤证明回滚不需要删除 Java 服务、 +改数据库或双写,只需切换 upstream。Nginx 基础镜像未声明非 root USER,因此这里不虚构其 +non-root 属性;实际硬化证据是 read-only root、最小 tmpfs 和无持久写入。应用与迁移镜像则 +均以 UID 10001 运行。 + +## 12. 证据文件 + +- 逐接口矩阵:`docs/manager-api-fastapi-compatibility.md` +- 迁移、切流与回滚说明:`docs/manager-api-fastapi-migration.md` +- Java 路由清单:`main/manager-api-fastapi/compatibility/java-routes.json` +- 三端调用清单:`main/manager-api-fastapi/compatibility/consumer-routes.json` +- 154 路由未认证/非法请求面机器报告: + `main/manager-api-fastapi/compatibility/route-surface-results.json` +- 154 路由已认证安全业务/校验机器报告: + `main/manager-api-fastapi/compatibility/authenticated-route-results.json` +- 深度差分机器报告:`main/manager-api-fastapi/compatibility/contract-results.json` +- 性能机器报告:`main/manager-api-fastapi/compatibility/performance-results.json` +- 一键隔离脚本:`main/manager-api-fastapi/scripts/run-isolated-contract-tests.sh` +- 未认证/非法请求面 runner: + `main/manager-api-fastapi/tests/compatibility/route_surface_runner.py` +- 已认证安全业务/校验 runner: + `main/manager-api-fastapi/tests/compatibility/authenticated_route_runner.py` +- 深度差分 runner:`main/manager-api-fastapi/tests/compatibility/differential_runner.py` +- 外部 mock:`main/manager-api-fastapi/tests/compatibility/external_mock.py` +- 集成测试:`main/manager-api-fastapi/tests/integration/test_isolated_runtime.py` +- 容器静态断言:`main/manager-api-fastapi/tests/test_deployment_artifacts.py` +- 容器网络 bridge:`main/manager-api-fastapi/tests/compatibility/tcp_proxy.py` +- API/migration/Nginx 构建定义:`main/manager-api-fastapi/Dockerfile`、 + `main/manager-api-fastapi/Dockerfile.migrations`、`main/manager-api-fastapi/Dockerfile.nginx` +- Nginx runtime 配置:`main/manager-api-fastapi/deploy/nginx.conf`、 + `main/manager-api-fastapi/deploy/nginx-entrypoint.sh` +- Java Surefire:`main/manager-api/target/surefire-reports/` + +## 13. 当前结论 + +Java 98、FastAPI 全量 139、隔离集成 7、未认证/非法请求面 154/154、已认证安全业务/校验 +154/154、深度差分 49/49、性能 480/0,以及 Web/Mobile 构建、xiaozhi-server compileall 和 +实际容器/Nginx 验证均按上述命令完成;各测试集合均为 0 failed、0 errors、0 skipped。原 Java +服务和 Liquibase 历史均未删除。 + +本地可安全执行的兼容、集成、构建、消费者和容器验证已经通过。每条 Java 路由虽已有两次 +全覆盖差分,但已认证 runner 有意不执行成功写入,所以不能将其表述为 154 条全部成功、错误 +和副作用生命周期均已深度验证;真实 RAGFlow、短信、语音克隆、声纹、模型、MQTT/MCP/WS +及 ESP32 硬件因没有真实凭证或设备而未联调,也没有在本报告中描述为已通过。 diff --git a/main/manager-api-fastapi/.env.example b/main/manager-api-fastapi/.env.example new file mode 100644 index 00000000..ab5335c3 --- /dev/null +++ b/main/manager-api-fastapi/.env.example @@ -0,0 +1,20 @@ +APP_ENVIRONMENT=development +APP_HOST=0.0.0.0 +APP_PORT=8002 +APP_CONTEXT_PATH=/xiaozhi +APP_TIMEZONE=Asia/Shanghai +APP_DATABASE_URL=mysql+asyncmy://xiaozhi:replace-me@mysql:3306/xiaozhi_esp32_server?charset=utf8mb4 +APP_REDIS_URL=redis://redis:6379/0 +# Local default; the container image overrides this with /data/uploads. +APP_UPLOAD_DIR=./uploadfile +# Docker Compose source: use a named volume by default, or set an existing +# Java uploadfile host path while the implementations coexist. +MANAGER_API_UPLOAD_SOURCE=manager-api-uploads +APP_JAVA_RESOURCES_DIR=/opt/xiaozhi/java-resources +APP_EXTERNAL_REQUEST_TIMEOUT_SECONDS=10 +APP_TRUSTED_PROXY_COUNT=1 +APP_LOG_LEVEL=INFO +APP_GRACEFUL_SHUTDOWN_SECONDS=30 +# Test-only escape hatches. Leave both unset in deployments. +# APP_SERVER_SECRET_OVERRIDE= +# APP_ALLOW_START_WITHOUT_DEPENDENCIES=false diff --git a/main/manager-api-fastapi/.gitignore b/main/manager-api-fastapi/.gitignore new file mode 100644 index 00000000..4807b1f2 --- /dev/null +++ b/main/manager-api-fastapi/.gitignore @@ -0,0 +1,16 @@ +.env +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.test-runtime/ +.venv/ +.coverage +coverage.xml +htmlcov/ +__pycache__/ +*.py[cod] +data/uploads/ +target/ +dist/ +!compatibility/*.json +!tests/fixtures/*.json diff --git a/main/manager-api-fastapi/.python-version b/main/manager-api-fastapi/.python-version new file mode 100644 index 00000000..c8cfe395 --- /dev/null +++ b/main/manager-api-fastapi/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/main/manager-api-fastapi/Dockerfile b/main/manager-api-fastapi/Dockerfile new file mode 100644 index 00000000..405c7ffc --- /dev/null +++ b/main/manager-api-fastapi/Dockerfile @@ -0,0 +1,54 @@ +FROM python:3.10.20-bookworm AS build + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_HTTP_TIMEOUT=120 \ + UV_HTTP_RETRIES=10 \ + PATH=/app/.venv/bin:$PATH + +WORKDIR /app + +COPY --from=ghcr.io/astral-sh/uv:0.11.28 /uv /uvx /bin/ + +COPY main/manager-api-fastapi/pyproject.toml main/manager-api-fastapi/uv.lock main/manager-api-fastapi/README.md ./ +COPY main/manager-api-fastapi/app ./app +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --no-editable + +FROM python:3.10.20-slim-bookworm + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH=/app/.venv/bin:$PATH \ + APP_JAVA_RESOURCES_DIR=/opt/xiaozhi/java-resources \ + APP_UPLOAD_DIR=/data/uploads \ + APP_HOST=0.0.0.0 \ + APP_PORT=8002 \ + APP_TIMEZONE=Asia/Shanghai + +WORKDIR /app + +RUN groupadd --gid 10001 xiaozhi \ + && useradd --uid 10001 --gid xiaozhi --create-home --shell /usr/sbin/nologin xiaozhi + +COPY --from=build --chown=10001:10001 /app/.venv ./.venv +COPY --from=build --chown=10001:10001 /app/app ./app + +COPY main/manager-api-fastapi/scripts/container-entrypoint.sh /usr/local/bin/manager-api-entrypoint +COPY main/manager-api/src/main/resources/i18n /opt/xiaozhi/java-resources/i18n +COPY main/manager-api/src/main/resources/db /opt/xiaozhi/java-resources/db + +RUN mkdir -p /data/uploads \ + && ln -s /data/uploads /app/uploadfile \ + && chown -R xiaozhi:xiaozhi /data/uploads /opt/xiaozhi \ + && chmod 0555 /usr/local/bin/manager-api-entrypoint + +USER 10001:10001 +EXPOSE 8002 +VOLUME ["/data/uploads"] +STOPSIGNAL SIGTERM + +HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=4 \ + CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8002/xiaozhi/health/live', timeout=2).read()"] + +ENTRYPOINT ["/usr/local/bin/manager-api-entrypoint"] diff --git a/main/manager-api-fastapi/Dockerfile.migrations b/main/manager-api-fastapi/Dockerfile.migrations new file mode 100644 index 00000000..8f91931c --- /dev/null +++ b/main/manager-api-fastapi/Dockerfile.migrations @@ -0,0 +1,35 @@ +FROM maven:3.9.9-eclipse-temurin-21 AS build + +WORKDIR /migration +COPY main/manager-api-fastapi/migration-pom.xml ./pom.xml +COPY main/manager-api-fastapi/migration-src ./migration-src +COPY main/manager-api/src/main/resources ./java-resources +# Keep the Maven repository outside the committed layer so an interrupted +# registry transfer can resume on the next build. Resolver downloads are +# deliberately serial: Apple Container's BuildKit NAT has proved unreliable +# when several Maven Central responses are multiplexed over one connection. +RUN --mount=type=cache,target=/root/.m2/repository \ + mvn -B \ + -Dmaven.repo.local=/root/.m2/repository \ + -Djava.resources.dir=/migration/java-resources \ + -Daether.connector.basic.threads=1 \ + -Daether.connector.connectTimeout=15000 \ + -Daether.connector.requestTimeout=60000 \ + -Daether.connector.http.retryHandler.count=5 \ + -Daether.connector.http.retryHandler.interval=1000 \ + -Daether.connector.http.retryHandler.intervalMax=5000 \ + package + +FROM eclipse-temurin:21-jre +WORKDIR /migration +COPY --from=build /migration/target/manager-api-liquibase-runner-1.0.0-all.jar ./runner.jar +COPY main/manager-api-fastapi/scripts/run-migrations.sh /usr/local/bin/run-manager-api-migrations +RUN groupadd --gid 10001 xiaozhi \ + && useradd --uid 10001 --gid xiaozhi --create-home --shell /usr/sbin/nologin xiaozhi \ + && chown -R xiaozhi:xiaozhi /migration \ + && chmod 0555 /usr/local/bin/run-manager-api-migrations +ENV MIGRATION_RUNNER_JAR=/migration/runner.jar \ + TZ=Asia/Shanghai +USER 10001:10001 +STOPSIGNAL SIGTERM +ENTRYPOINT ["/usr/local/bin/run-manager-api-migrations"] diff --git a/main/manager-api-fastapi/Dockerfile.nginx b/main/manager-api-fastapi/Dockerfile.nginx new file mode 100644 index 00000000..d348713a --- /dev/null +++ b/main/manager-api-fastapi/Dockerfile.nginx @@ -0,0 +1,14 @@ +FROM nginx:1.28.0-alpine + +COPY main/manager-api-fastapi/deploy/nginx.conf /etc/nginx/nginx.conf.template +COPY main/manager-api-fastapi/deploy/nginx-entrypoint.sh /usr/local/bin/manager-api-nginx-entrypoint +RUN MANAGER_API_UPSTREAM=127.0.0.1:8002 \ + envsubst '${MANAGER_API_UPSTREAM}' \ + < /etc/nginx/nginx.conf.template \ + > /tmp/nginx-build-check.conf \ + && nginx -t -c /tmp/nginx-build-check.conf \ + && rm /tmp/nginx-build-check.conf \ + && chmod 0555 /usr/local/bin/manager-api-nginx-entrypoint + +ENV MANAGER_API_UPSTREAM=manager-api-fastapi:8002 +ENTRYPOINT ["/usr/local/bin/manager-api-nginx-entrypoint"] diff --git a/main/manager-api-fastapi/README.md b/main/manager-api-fastapi/README.md new file mode 100644 index 00000000..8e60e536 --- /dev/null +++ b/main/manager-api-fastapi/README.md @@ -0,0 +1,38 @@ +# manager-api-fastapi + +`manager-api-fastapi` is the Python/FastAPI implementation of the existing Spring Boot +`main/manager-api`. The Java service remains in the repository as the contract baseline, +Liquibase migration owner, and rollback implementation. + +## Local development + +The service requires Python 3.10, MySQL 8, and Redis 5 or newer. Never point tests at a +development database: the integration harness creates a dedicated database and Redis +namespace/instance. + +```bash +cd main/manager-api-fastapi +cp .env.example .env +uv sync --locked +uv run python -m app +``` + +The compatible base URL is `http://127.0.0.1:8002/xiaozhi`. OpenAPI is exposed at +`/xiaozhi/v3/api-docs` and the Swagger UI at `/xiaozhi/doc.html`. + +Production must set `APP_DATABASE_URL`, `APP_REDIS_URL`, `APP_UPLOAD_DIR`, and +`APP_JAVA_RESOURCES_DIR`. The last path must contain the original Java i18n resources and +Liquibase changelog. `APP_SERVER_SECRET_OVERRIDE` is reserved for isolated tests; leaving it +set in a deployment bypasses the database-backed `server.secret` lookup and is unsupported. + +## Commands + +```bash +uv run pytest +uv run ruff check app tests scripts +uv run mypy app +uv run python scripts/extract_java_routes.py --output compatibility/java-routes.json +``` + +Migration, container, differential-contract, and cutover instructions are maintained in the +repository-level migration documents under `docs/manager-api-fastapi-*.md`. diff --git a/main/manager-api-fastapi/app/__init__.py b/main/manager-api-fastapi/app/__init__.py new file mode 100644 index 00000000..554f6736 --- /dev/null +++ b/main/manager-api-fastapi/app/__init__.py @@ -0,0 +1 @@ +"""Xiaozhi manager API FastAPI implementation.""" diff --git a/main/manager-api-fastapi/app/__main__.py b/main/manager-api-fastapi/app/__main__.py new file mode 100644 index 00000000..37f6b1b7 --- /dev/null +++ b/main/manager-api-fastapi/app/__main__.py @@ -0,0 +1,7 @@ +import uvicorn + +from app.core.config import get_settings + +if __name__ == "__main__": + settings = get_settings() + uvicorn.run("app.main:app", host=settings.host, port=settings.port, log_level=settings.log_level.lower()) diff --git a/main/manager-api-fastapi/app/core/__init__.py b/main/manager-api-fastapi/app/core/__init__.py new file mode 100644 index 00000000..9f53208c --- /dev/null +++ b/main/manager-api-fastapi/app/core/__init__.py @@ -0,0 +1 @@ +"""Shared compatibility infrastructure.""" diff --git a/main/manager-api-fastapi/app/core/config.py b/main/manager-api-fastapi/app/core/config.py new file mode 100644 index 00000000..fb15c7ba --- /dev/null +++ b/main/manager-api-fastapi/app/core/config.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path +from typing import Literal + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +def _default_java_resources() -> Path: + return Path(__file__).resolve().parents[3] / "manager-api" / "src" / "main" / "resources" + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_prefix="APP_", + case_sensitive=False, + extra="ignore", + ) + + environment: Literal["development", "test", "production"] = "development" + host: str = "0.0.0.0" # noqa: S104 - container bind is intentional + port: int = 8002 + context_path: str = "/xiaozhi" + timezone: str = "Asia/Shanghai" + database_url: str = "mysql+asyncmy://root:change-me@127.0.0.1:3306/xiaozhi_esp32_server?charset=utf8mb4" + redis_url: str = "redis://127.0.0.1:6379/0" + upload_dir: Path = Path("uploadfile") + java_resources_dir: Path = Field(default_factory=_default_java_resources) + external_request_timeout_seconds: float = 10.0 + database_pool_size: int = 20 + database_max_overflow: int = 20 + trusted_proxy_count: int = 1 + log_level: str = "INFO" + server_secret_override: str | None = None + allow_start_without_dependencies: bool = False + job_lock_ttl_seconds: int = 120 + graceful_shutdown_seconds: float = 30.0 + + @field_validator("context_path") + @classmethod + def normalize_context_path(cls, value: str) -> str: + normalized = "/" + value.strip("/") + return "" if normalized == "/" else normalized + + @field_validator("database_url") + @classmethod + def require_async_driver(cls, value: str) -> str: + if value.startswith("mysql://"): + return value.replace("mysql://", "mysql+asyncmy://", 1) + if value.startswith("sqlite:///"): + return value.replace("sqlite:///", "sqlite+aiosqlite:///", 1) + return value + + @property + def i18n_dir(self) -> Path: + return self.java_resources_dir / "i18n" + + @property + def changelog_path(self) -> Path: + return self.java_resources_dir / "db" / "changelog" / "db.changelog-master.yaml" + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + return Settings() + + +def clear_settings_cache() -> None: + get_settings.cache_clear() diff --git a/main/manager-api-fastapi/app/core/crypto.py b/main/manager-api-fastapi/app/core/crypto.py new file mode 100644 index 00000000..e0792c93 --- /dev/null +++ b/main/manager-api-fastapi/app/core/crypto.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import hashlib +import secrets +import uuid + +import bcrypt +from gmssl import func, sm2 # type: ignore[import-untyped] + + +def generate_database_token(value: str | None = None) -> str: + source = value if value is not None else str(uuid.uuid4()) + return hashlib.md5(source.encode("utf-8"), usedforsecurity=False).hexdigest() + + +def bcrypt_hash(password: str, rounds: int = 10) -> str: + encoded = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=rounds)) + # The bundled Java BCryptPasswordEncoder only accepts $2a$ hashes. + return encoded.decode("ascii").replace("$2b$", "$2a$", 1) + + +def bcrypt_matches(password: str, encoded: str | None) -> bool: + if not encoded or not encoded.startswith(("$2a$", "$2$")): + return False + normalized = encoded.replace("$2$", "$2a$", 1) + try: + return bcrypt.checkpw(password.encode("utf-8"), normalized.encode("ascii")) + except (ValueError, UnicodeEncodeError): + return False + + +def sm2_generate_keypair() -> tuple[str, str]: + private_key = func.random_hex(64) + helper = sm2.CryptSM2(private_key=private_key, public_key="", mode=1) + public_point = str(helper._kg(int(private_key, 16), sm2.default_ecc_table["g"])) # noqa: SLF001 + public_key = "04" + public_point + return public_key, private_key + + +def sm2_encrypt_c1c3c2(public_key: str, plaintext: str) -> str: + helper = sm2.CryptSM2(private_key="", public_key=public_key, mode=1) + encrypted = helper.encrypt(plaintext.encode("utf-8")) + if encrypted is None: + raise ValueError("SM2 KDF returned an all-zero key") + # BouncyCastle's SM2Engine emits the uncompressed-point marker. + return "04" + bytes(encrypted).hex() + + +def sm2_decrypt_c1c3c2(private_key: str, ciphertext: str) -> str: + normalized = ciphertext.strip().lower() + if normalized.startswith("04"): + normalized = normalized[2:] + if len(normalized) < 128 + 64 or len(normalized) % 2: + raise ValueError("invalid SM2 C1C3C2 ciphertext") + helper = sm2.CryptSM2(private_key=private_key, public_key="", mode=1) + decrypted = helper.decrypt(bytes.fromhex(normalized)) + if decrypted is None: + raise ValueError("SM2 decryption failed") + return bytes(decrypted).decode("utf-8") + + +def random_hex(length: int) -> str: + return secrets.token_hex((length + 1) // 2)[:length] diff --git a/main/manager-api-fastapi/app/core/database.py b/main/manager-api-fastapi/app/core/database.py new file mode 100644 index 00000000..c0e40744 --- /dev/null +++ b/main/manager-api-fastapi/app/core/database.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator, Mapping, Sequence +from contextlib import asynccontextmanager +from typing import Any + +from sqlalchemy import Result, TextClause, text +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine + +from app.core.config import Settings, get_settings + +_engine: AsyncEngine | None = None +_session_factory: async_sessionmaker[AsyncSession] | None = None + + +def configure_database(settings: Settings | None = None) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: + global _engine, _session_factory + selected = settings or get_settings() + engine_options: dict[str, Any] = {"pool_pre_ping": True} + if not selected.database_url.startswith("sqlite"): + engine_options.update(pool_size=selected.database_pool_size, max_overflow=selected.database_max_overflow) + _engine = create_async_engine(selected.database_url, **engine_options) + _session_factory = async_sessionmaker(_engine, expire_on_commit=False, autoflush=False) + return _engine, _session_factory + + +def get_engine() -> AsyncEngine: + if _engine is None: + return configure_database()[0] + return _engine + + +def get_session_factory() -> async_sessionmaker[AsyncSession]: + if _session_factory is None: + return configure_database()[1] + return _session_factory + + +async def get_db() -> AsyncIterator[AsyncSession]: + async with get_session_factory()() as session: + yield session + + +@asynccontextmanager +async def transaction() -> AsyncIterator[AsyncSession]: + async with get_session_factory()() as session, session.begin(): + yield session + + +async def dispose_database() -> None: + global _engine, _session_factory + if _engine is not None: + await _engine.dispose() + _engine = None + _session_factory = None + + +class Repository: + def __init__(self, session: AsyncSession): + self.session = session + + @staticmethod + def statement(sql: str | TextClause) -> TextClause: + return text(sql) if isinstance(sql, str) else sql + + async def fetch_one(self, sql: str | TextClause, params: Mapping[str, Any] | None = None) -> dict[str, Any] | None: + result = await self.session.execute(self.statement(sql), dict(params or {})) + row = result.mappings().first() + return dict(row) if row is not None else None + + async def fetch_all(self, sql: str | TextClause, params: Mapping[str, Any] | None = None) -> list[dict[str, Any]]: + result = await self.session.execute(self.statement(sql), dict(params or {})) + return [dict(row) for row in result.mappings().all()] + + async def scalar(self, sql: str | TextClause, params: Mapping[str, Any] | None = None) -> Any: + result = await self.session.execute(self.statement(sql), dict(params or {})) + return result.scalar_one_or_none() + + async def execute(self, sql: str | TextClause, params: Mapping[str, Any] | None = None) -> int: + result: Result[Any] = await self.session.execute(self.statement(sql), dict(params or {})) + return int(getattr(result, "rowcount", 0) or 0) + + async def execute_many(self, sql: str | TextClause, params: Sequence[Mapping[str, Any]]) -> int: + if not params: + return 0 + result: Result[Any] = await self.session.execute(self.statement(sql), [dict(item) for item in params]) + return int(getattr(result, "rowcount", 0) or 0) + + +async def database_ping() -> bool: + try: + async with get_session_factory()() as session: + await session.execute(text("SELECT 1")) + return True + except Exception: + return False diff --git a/main/manager-api-fastapi/app/core/errors.py b/main/manager-api-fastapi/app/core/errors.py new file mode 100644 index 00000000..add8cf4a --- /dev/null +++ b/main/manager-api-fastapi/app/core/errors.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +class ErrorCode: + INTERNAL_SERVER_ERROR = 500 + UNAUTHORIZED = 401 + FORBIDDEN = 403 + DB_RECORD_EXISTS = 10002 + PARAMS_GET_ERROR = 10003 + ACCOUNT_PASSWORD_ERROR = 10004 + ACCOUNT_DISABLE = 10005 + CAPTCHA_ERROR = 10007 + PASSWORD_ERROR = 10009 + UPLOAD_FILE_EMPTY = 10019 + TOKEN_INVALID = 10021 + ACCOUNT_LOCK = 10022 + INVALID_SYMBOL = 10029 + PASSWORD_LENGTH_ERROR = 10030 + PASSWORD_WEAK_ERROR = 10031 + DEL_MYSELF_ERROR = 10032 + DEVICE_CAPTCHA_ERROR = 10033 + PARAM_VALUE_NULL = 10034 + PARAM_TYPE_NULL = 10035 + PARAM_TYPE_INVALID = 10036 + PARAM_NUMBER_INVALID = 10037 + PARAM_BOOLEAN_INVALID = 10038 + PARAM_ARRAY_INVALID = 10039 + PARAM_JSON_INVALID = 10040 + RESOURCE_NOT_FOUND = 10051 + ADD_DATA_FAILED = 10065 + UPDATE_DATA_FAILED = 10066 + MODEL_TYPE_PROVIDE_CODE_NOT_NULL = 10131 + + +@dataclass(slots=True) +class AppError(Exception): + code: int + message: str | None = None + params: tuple[object, ...] = () + + def __str__(self) -> str: + return self.message or str(self.code) diff --git a/main/manager-api-fastapi/app/core/i18n.py b/main/manager-api-fastapi/app/core/i18n.py new file mode 100644 index 00000000..c96da245 --- /dev/null +++ b/main/manager-api-fastapi/app/core/i18n.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import re +from functools import lru_cache +from pathlib import Path + +from app.core.config import get_settings + +LANGUAGE_FILES: dict[str, str] = { + "zh-CN": "messages_zh_CN.properties", + "zh-TW": "messages_zh_TW.properties", + "en-US": "messages_en_US.properties", + "de-DE": "messages_de_DE.properties", + "vi-VN": "messages_vi_VN.properties", + "pt-BR": "messages_pt_BR.properties", +} +_UNICODE_ESCAPE = re.compile(r"\\u([0-9a-fA-F]{4})") + + +def resolve_language(accept_language: str | None) -> str: + if not accept_language: + return "zh-CN" + primary = accept_language.split(",", 1)[0].split(";", 1)[0].strip().replace("_", "-") + exact = {key.lower(): key for key in LANGUAGE_FILES} + if primary.lower() in exact: + return exact[primary.lower()] + prefix = primary.lower().split("-", 1)[0] + return { + "zh": "zh-CN", + "en": "en-US", + "de": "de-DE", + "vi": "vi-VN", + "pt": "pt-BR", + }.get(prefix, "zh-CN") + + +def _unescape(value: str) -> str: + decoded = _UNICODE_ESCAPE.sub(lambda match: chr(int(match.group(1), 16)), value) + return ( + decoded.replace("\\t", "\t") + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\f", "\f") + .replace("\\=", "=") + .replace("\\:", ":") + .replace("\\ ", " ") + .replace("\\\\", "\\") + ) + + +def _load_properties(path: Path) -> dict[str, str]: + messages: dict[str, str] = {} + if not path.exists(): + return messages + continuation = "" + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = continuation + raw_line + if line.endswith("\\") and not line.endswith("\\\\"): + continuation = line[:-1] + continue + continuation = "" + stripped = line.strip() + if not stripped or stripped.startswith(("#", "!")): + continue + delimiter = "=" if "=" in line else ":" + if delimiter not in line: + continue + key, value = line.split(delimiter, 1) + messages[key.strip()] = _unescape(value.strip()) + return messages + + +@lru_cache(maxsize=16) +def messages_for(language: str, i18n_dir: str | None = None) -> dict[str, str]: + directory = Path(i18n_dir) if i18n_dir else get_settings().i18n_dir + default_messages = _load_properties(directory / "messages.properties") + default_messages.update(_load_properties(directory / LANGUAGE_FILES.get(language, LANGUAGE_FILES["zh-CN"]))) + return default_messages + + +def message_for(code: int, accept_language: str | None, *params: object) -> str: + language = resolve_language(accept_language) + template = messages_for(language).get(str(code), str(code)) + for index, param in enumerate(params): + template = template.replace("{" + str(index) + "}", str(param)) + return template + + +def clear_i18n_cache() -> None: + messages_for.cache_clear() diff --git a/main/manager-api-fastapi/app/core/ids.py b/main/manager-api-fastapi/app/core/ids.py new file mode 100644 index 00000000..864359d6 --- /dev/null +++ b/main/manager-api-fastapi/app/core/ids.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import os +import socket +import threading +import time + + +class SnowflakeIdGenerator: + """MyBatis-Plus compatible 41/5/5/12-bit Snowflake identifier generator.""" + + EPOCH = 1288834974657 + SEQUENCE_BITS = 12 + WORKER_BITS = 5 + DATACENTER_BITS = 5 + MAX_SEQUENCE = (1 << SEQUENCE_BITS) - 1 + WORKER_SHIFT = SEQUENCE_BITS + DATACENTER_SHIFT = SEQUENCE_BITS + WORKER_BITS + TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_BITS + DATACENTER_BITS + + def __init__(self, worker_id: int | None = None, datacenter_id: int | None = None): + host_hash = sum(socket.gethostname().encode("utf-8")) + self.worker_id = worker_id if worker_id is not None else (host_hash ^ os.getpid()) & 31 + self.datacenter_id = datacenter_id if datacenter_id is not None else host_hash & 31 + if not 0 <= self.worker_id <= 31 or not 0 <= self.datacenter_id <= 31: + raise ValueError("worker_id and datacenter_id must be in [0, 31]") + self._sequence = 0 + self._last_timestamp = -1 + self._lock = threading.Lock() + + @staticmethod + def _milliseconds() -> int: + return time.time_ns() // 1_000_000 + + def next_id(self) -> int: + with self._lock: + timestamp = self._milliseconds() + if timestamp < self._last_timestamp: + raise RuntimeError("clock moved backwards; refusing to generate a duplicate Snowflake ID") + if timestamp == self._last_timestamp: + self._sequence = (self._sequence + 1) & self.MAX_SEQUENCE + if self._sequence == 0: + while timestamp <= self._last_timestamp: + timestamp = self._milliseconds() + else: + self._sequence = 0 + self._last_timestamp = timestamp + return ( + ((timestamp - self.EPOCH) << self.TIMESTAMP_SHIFT) + | (self.datacenter_id << self.DATACENTER_SHIFT) + | (self.worker_id << self.WORKER_SHIFT) + | self._sequence + ) + + +snowflake = SnowflakeIdGenerator() diff --git a/main/manager-api-fastapi/app/core/redis.py b/main/manager-api-fastapi/app/core/redis.py new file mode 100644 index 00000000..3a081da1 --- /dev/null +++ b/main/manager-api-fastapi/app/core/redis.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import asyncio +import json +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager, suppress +from datetime import datetime +from typing import Any, cast +from zoneinfo import ZoneInfo + +from redis.asyncio import Redis + +from app.core.config import get_settings + +_client: Redis | None = None +logger = logging.getLogger(__name__) + + +class JavaRedisCodec: + """Wire-compatible subset of Spring Data's ``RedisSerializer.json()``. + + Spring enables Jackson default typing for non-final values. Consequently a + plain JSON map/list cannot be read by the retained Java rollback service. A + map carries ``@class`` and a collection uses Jackson's wrapper-array form. + ``java_type`` and ``item_java_type`` cover the few caches whose Java readers + cast values to concrete DTO/entity classes. + """ + + @staticmethod + def encode( + value: Any, + *, + java_type: str | None = None, + item_java_type: str | None = None, + field_java_types: dict[str, str] | None = None, + ) -> bytes: + wire = JavaRedisCodec._encode_value( + value, + java_type=java_type, + item_java_type=item_java_type, + field_java_types=field_java_types, + nested=False, + ) + return json.dumps(wire, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + + @staticmethod + def decode(value: bytes | str | None) -> Any: + if value is None: + return None + raw = value.decode("utf-8") if isinstance(value, bytes) else value + try: + return JavaRedisCodec._decode_value(json.loads(raw)) + except json.JSONDecodeError: + return raw + + @staticmethod + def _encode_value( + value: Any, + *, + java_type: str | None = None, + item_java_type: str | None = None, + field_java_types: dict[str, str] | None = None, + nested: bool = True, + ) -> Any: + if value is None or isinstance(value, str | bool | float): + return value + if isinstance(value, int): + # Jackson's default typing only adds the Long wrapper when the + # runtime value sits behind an Object-typed container slot. A + # top-level Long, or a field with a declared Long type, is emitted + # as an ordinary JSON number. + if not nested or java_type == "java.lang.Long" or -(2**31) <= value < 2**31: + return value + return ["java.lang.Long", value] + if isinstance(value, datetime): + timezone = ZoneInfo(get_settings().timezone) + localized = value.replace(tzinfo=timezone) if value.tzinfo is None else value.astimezone(timezone) + return ["java.util.Date", int(localized.timestamp() * 1000)] + if isinstance(value, dict): + selected_type = java_type or "java.util.HashMap" + result: dict[str, Any] = {"@class": selected_type} + pojo = selected_type not in { + "java.util.HashMap", + "java.util.LinkedHashMap", + "java.util.TreeMap", + "cn.hutool.json.JSONObject", + } + for raw_key, item in value.items(): + if raw_key == "@class": + continue + key = _snake_to_camel(str(raw_key)) if pojo else str(raw_key) + child_type = (field_java_types or {}).get(key) or (field_java_types or {}).get(str(raw_key)) + result[key] = JavaRedisCodec._encode_value(item, java_type=child_type, nested=True) + return result + if isinstance(value, set | frozenset): + return [ + "java.util.HashSet", + [ + JavaRedisCodec._encode_value(item, java_type=item_java_type, nested=True) + for item in value + ], + ] + if isinstance(value, list | tuple): + return [ + "java.util.ArrayList", + [ + JavaRedisCodec._encode_value(item, java_type=item_java_type, nested=True) + for item in value + ], + ] + return value + + @staticmethod + def _decode_value(value: Any) -> Any: + if isinstance(value, dict): + return { + str(key): JavaRedisCodec._decode_value(item) + for key, item in value.items() + if key != "@class" + } + if isinstance(value, list): + if len(value) == 2 and isinstance(value[0], str) and value[0].startswith("java."): + type_name, payload = value + if type_name == "java.util.Date": + timezone = ZoneInfo(get_settings().timezone) + return datetime.fromtimestamp(float(payload) / 1000, timezone).replace(tzinfo=None) + if type_name in { + "java.util.ArrayList", + "java.util.LinkedList", + "java.util.HashSet", + "java.util.LinkedHashSet", + } and isinstance(payload, list): + return [JavaRedisCodec._decode_value(item) for item in payload] + return JavaRedisCodec._decode_value(payload) + return [JavaRedisCodec._decode_value(item) for item in value] + return value + + +def _snake_to_camel(value: str) -> str: + head, *tail = value.split("_") + return head + "".join(part[:1].upper() + part[1:] for part in tail) + + +def get_redis() -> Redis: + global _client + if _client is None: + _client = Redis.from_url(get_settings().redis_url, decode_responses=False) + return _client + + +async def close_redis() -> None: + global _client + if _client is not None: + await _client.aclose() + _client = None + + +async def redis_ping() -> bool: + try: + return bool(await get_redis().ping()) + except Exception: + return False + + +async def java_get(key: str) -> Any: + return JavaRedisCodec.decode(await cast(Any, get_redis().get(key))) + + +async def java_set( + key: str, + value: Any, + ttl_seconds: int | None = None, + *, + java_type: str | None = None, + item_java_type: str | None = None, +) -> None: + await cast(Any, get_redis().set)( + key, + JavaRedisCodec.encode(value, java_type=java_type, item_java_type=item_java_type), + ex=ttl_seconds, + ) + + +async def java_hget(key: str, field: str) -> Any: + return JavaRedisCodec.decode(await cast(Any, get_redis().hget(key, field))) + + +async def java_hset(key: str, field: str, value: Any, ttl_seconds: int = 86400) -> None: + redis = get_redis() + await cast(Any, redis.hset)(key, field, JavaRedisCodec.encode(value)) + await cast(Any, redis.expire)(key, ttl_seconds) + + +@asynccontextmanager +async def distributed_lock(name: str, ttl_seconds: int) -> AsyncIterator[bool]: + lock = get_redis().lock(name, timeout=ttl_seconds, blocking_timeout=0) + acquired = bool(await lock.acquire(blocking=False)) + renewal: asyncio.Task[None] | None = None + if acquired: + renewal = asyncio.create_task(_renew_lock(lock, ttl_seconds)) + try: + yield acquired + finally: + if renewal is not None: + renewal.cancel() + with suppress(asyncio.CancelledError): + await renewal + if acquired: + try: + await lock.release() + except Exception: + logger.warning("Lost ownership of distributed lock %s before release", name, exc_info=True) + + +async def _renew_lock(lock: Any, ttl_seconds: int) -> None: + """Keep a held job lock alive until its owner leaves the context.""" + + interval = max(float(ttl_seconds) / 3, 0.25) + while True: + await asyncio.sleep(interval) + try: + await lock.extend(ttl_seconds, replace_ttl=True) + except Exception: + logger.exception("Unable to renew distributed lock; duplicate execution protection is at risk") + return diff --git a/main/manager-api-fastapi/app/core/responses.py b/main/manager-api-fastapi/app/core/responses.py new file mode 100644 index 00000000..5abe8f15 --- /dev/null +++ b/main/manager-api-fastapi/app/core/responses.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import json +from typing import Any + +from fastapi import Request +from starlette.responses import JSONResponse, Response + +from app.core.i18n import message_for +from app.core.serialization import java_compatible + + +class JavaJSONResponse(JSONResponse): + def render(self, content: Any) -> bytes: + return json.dumps( + java_compatible(content), + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + + +def envelope(data: Any = None, *, code: int = 0, msg: str = "success") -> dict[str, Any]: + return {"code": code, "msg": msg, "data": data} + + +def ok(data: Any = None) -> JavaJSONResponse: + return JavaJSONResponse(envelope(data)) + + +def error_response( + request: Request, + code: int, + message: str | None = None, + *, + status_code: int = 200, + params: tuple[object, ...] = (), + media_type: str = "application/json", +) -> JavaJSONResponse: + translated = message or message_for(code, request.headers.get("Accept-Language"), *params) + return JavaJSONResponse( + envelope(None, code=code, msg=translated), + status_code=status_code, + media_type=media_type, + ) + + +def raw_json(content: Any, *, exclude_none: bool = False, status_code: int = 200) -> Response: + normalized = java_compatible(content) + if exclude_none: + normalized = _drop_none(normalized) + body = json.dumps(normalized, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8") + return Response( + body, + status_code=status_code, + media_type="application/json", + headers={"Content-Length": str(len(body))}, + ) + + +def _drop_none(value: Any) -> Any: + if isinstance(value, dict): + return {key: _drop_none(item) for key, item in value.items() if item is not None} + if isinstance(value, list): + return [_drop_none(item) for item in value] + return value diff --git a/main/manager-api-fastapi/app/core/security.py b/main/manager-api-fastapi/app/core/security.py new file mode 100644 index 00000000..75206986 --- /dev/null +++ b/main/manager-api-fastapi/app/core/security.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import fnmatch +import hmac +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from fastapi import Request +from sqlalchemy import text +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.responses import Response + +from app.core.config import get_settings +from app.core.database import get_session_factory +from app.core.errors import AppError, ErrorCode +from app.core.responses import error_response +from app.services.system_params import SystemParamService + +PUBLIC_PATTERNS = ( + "/ota/*", + "/ota", + "/otaMag/download/*", + "/webjars/*", + "/druid/*", + "/v3/api-docs*", + "/doc.html*", + "/favicon.ico", + "/user/captcha", + "/user/smsVerification", + "/user/login", + "/user/pub-config", + "/user/register", + "/user/retrieve-password", + "/api/ping", + "/agent/chat-history/download/*", + "/agent/play/*", + "/voiceClone/play/*", + "/health", + "/health/live", + "/health/ready", +) +SERVER_PATTERNS = ( + "/config/*", + "/device/address-book/call", + "/device/address-book/lookup", + "/agent/chat-history/report", + "/agent/chat-summary/*", + "/agent/chat-title/*", +) + + +@dataclass(slots=True, frozen=True) +class AuthUser: + id: int + username: str + super_admin: int + status: int + token: str + row: dict[str, Any] + + @property + def is_super_admin(self) -> bool: + return self.super_admin == 1 + + +def _matches(path: str, patterns: tuple[str, ...]) -> bool: + return any(fnmatch.fnmatchcase(path, pattern) for pattern in patterns) + + +def _bearer_token(request: Request) -> str | None: + authorization = request.headers.get("Authorization") + if not authorization or not authorization.startswith("Bearer "): + return None + value = authorization[len("Bearer ") :] + return value if value.strip() else None + + +class AuthenticationMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + if request.method == "OPTIONS": + return await call_next(request) + settings = get_settings() + path = request.url.path + if settings.context_path and path.startswith(settings.context_path): + path = path[len(settings.context_path) :] or "/" + if _matches(path, PUBLIC_PATTERNS): + request.state.auth_mode = "anonymous" + return await call_next(request) + if _matches(path, SERVER_PATTERNS): + return await self._server_auth(request, call_next) + return await self._user_auth(request, call_next) + + async def _server_auth(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + provided = _bearer_token(request) + if provided is None: + return error_response( + request, + ErrorCode.UNAUTHORIZED, + "服务器密钥不能为空", + media_type="application/json;charset=utf-8", + ) + expected = get_settings().server_secret_override + if expected is None: + try: + async with get_session_factory()() as session: + expected = await SystemParamService(session).get_value("server.secret", from_cache=True) + except Exception: + expected = None + if not expected or not hmac.compare_digest(provided, expected): + return error_response( + request, + ErrorCode.UNAUTHORIZED, + "无效的服务器密钥", + media_type="application/json;charset=utf-8", + ) + request.state.auth_mode = "server" + return await call_next(request) + + async def _user_auth(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + token = _bearer_token(request) + if token is None: + return error_response( + request, + ErrorCode.UNAUTHORIZED, + media_type="application/json;charset=utf-8", + ) + try: + async with get_session_factory()() as session: + result = await session.execute( + text( + "SELECT u.* FROM sys_user_token t " + "JOIN sys_user u ON u.id = t.user_id " + "WHERE t.token = :token AND t.expire_date >= CURRENT_TIMESTAMP LIMIT 1" + ), + {"token": token}, + ) + mapping = result.mappings().first() + except Exception: + mapping = None + if mapping is None or mapping.get("status") is None or int(mapping["status"]) != 1: + return error_response( + request, + ErrorCode.UNAUTHORIZED, + media_type="application/json;charset=utf-8", + ) + row = dict(mapping) + request.state.user = AuthUser( + id=int(row["id"]), + username=str(row.get("username") or ""), + super_admin=int(row.get("super_admin") or 0), + status=int(row["status"]), + token=token, + row=row, + ) + request.state.auth_mode = "user" + return await call_next(request) + + +def current_user(request: Request) -> AuthUser: + user = getattr(request.state, "user", None) + if not isinstance(user, AuthUser): + raise AppError(ErrorCode.UNAUTHORIZED) + return user + + +def require_normal(request: Request) -> AuthUser: + return current_user(request) + + +def require_super_admin(request: Request) -> AuthUser: + user = current_user(request) + if not user.is_super_admin: + raise AppError(ErrorCode.FORBIDDEN) + return user + + +def shanghai_now_naive() -> datetime: + from zoneinfo import ZoneInfo + + return datetime.now(tz=ZoneInfo(get_settings().timezone)).replace(tzinfo=None) diff --git a/main/manager-api-fastapi/app/core/serialization.py b/main/manager-api-fastapi/app/core/serialization.py new file mode 100644 index 00000000..2215059a --- /dev/null +++ b/main/manager-api-fastapi/app/core/serialization.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import dataclasses +import re +from collections.abc import Mapping, Sequence +from datetime import date, datetime, time +from decimal import Decimal +from enum import Enum +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +from pydantic import BaseModel + +from app.core.config import get_settings + +_SNAKE_PART = re.compile(r"_([a-zA-Z0-9])") +_LONG_FIELD_NAMES = { + "id", + "userId", + "creator", + "updater", + "createUserId", + "updateUserId", + "createDateTimestamp", + "createTime", + "createTimeFrom", + "createTimeTo", + "fileSize", + "lastConnectedAtTimestamp", + "pid", + "reportTime", + "size", + "timestamp", + "tokenCount", + "tokenNum", + "totalDocCount", + "totalTokenCount", + "updateTime", +} + + +class JavaMap(dict[str, Any]): + """Marker for Java ``Map`` payloads whose keys Jackson leaves untouched.""" + + +def preserve_java_map_keys(value: Any) -> Any: + """Recursively mark a dynamic Java Map/List graph as key-preserving.""" + + if isinstance(value, Mapping): + return JavaMap({str(key): preserve_java_map_keys(item) for key, item in value.items()}) + if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): + return [preserve_java_map_keys(item) for item in value] + return value + + +def snake_to_camel(value: str) -> str: + return _SNAKE_PART.sub(lambda match: match.group(1).upper(), value) + + +def _is_long_field(name: str | None) -> bool: + if not name: + return False + return name in _LONG_FIELD_NAMES or name.endswith("Id") or name.endswith("Ids") + + +def java_compatible(value: Any, *, field_name: str | None = None) -> Any: + if value is None or isinstance(value, str | bool | float): + return value + if isinstance(value, BaseModel): + return java_compatible(value.model_dump(by_alias=True, exclude_unset=False), field_name=field_name) + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return java_compatible(dataclasses.asdict(value), field_name=field_name) + if isinstance(value, Enum): + return java_compatible(value.value, field_name=field_name) + if isinstance(value, datetime): + timezone = ZoneInfo(get_settings().timezone) + localized = value.astimezone(timezone) if value.tzinfo else value + return localized.strftime("%Y-%m-%d %H:%M:%S") + if isinstance(value, date): + return value.strftime("%Y-%m-%d") + if isinstance(value, time): + return value.strftime("%H:%M:%S") + if isinstance(value, Decimal): + return float(value) + if isinstance(value, int): + return str(value) if _is_long_field(field_name) or not -(2**31) <= value < 2**31 else value + if isinstance(value, bytes): + return value + if isinstance(value, Path): + return str(value) + if isinstance(value, JavaMap): + return { + str(raw_key): java_compatible(item, field_name=snake_to_camel(str(raw_key))) + for raw_key, item in value.items() + } + if isinstance(value, Mapping): + result: dict[str, Any] = {} + for raw_key, item in value.items(): + key = snake_to_camel(str(raw_key)) + result[key] = java_compatible(item, field_name=key) + return result + if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): + return [java_compatible(item, field_name=field_name) for item in value] + return value diff --git a/main/manager-api-fastapi/app/integrations/__init__.py b/main/manager-api-fastapi/app/integrations/__init__.py new file mode 100644 index 00000000..6a25e29a --- /dev/null +++ b/main/manager-api-fastapi/app/integrations/__init__.py @@ -0,0 +1 @@ +"""Outbound integrations used by the FastAPI manager service.""" diff --git a/main/manager-api-fastapi/app/integrations/llm.py b/main/manager-api-fastapi/app/integrations/llm.py new file mode 100644 index 00000000..4f40d204 --- /dev/null +++ b/main/manager-api-fastapi/app/integrations/llm.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from typing import Any + +import httpx + +SUMMARY_PROMPT = """你是一个经验丰富的记忆总结者,擅长将对话内容进行总结摘要,遵循以下规则: +1、总结用户的重要信息,以便在未来的对话中提供更个性化的服务 +2、不要重复总结,不要遗忘之前记忆,除非原来的记忆超过了1800字,否则不要遗忘、不要压缩用户的历史记忆 +3、用户操控的设备音量、播放音乐、天气、退出、不想对话等和用户本身无关的内容,这些信息不需要加入到总结中 +4、聊天内容中的今天的日期时间、今天的天气情况与用户事件无关的数据,这些信息如果当成记忆存储会影响后续对话,这些信息不需要加入到总结中 +5、不要把设备操控的成果结果和失败结果加入到总结中,也不要把用户的一些废话加入到总结中 +6、不要为了总结而总结,如果用户的聊天没有意义,请返回原来的历史记录也是可以的 +7、只需要返回总结摘要,严格控制在1800字内 +8、不要包含代码、xml,不需要解释、注释和说明,保存记忆时仅从对话提取信息,不要混入示例内容 +9、如果提供了历史记忆,请将新对话内容与历史记忆进行智能合并,保留有价值的历史信息,同时添加新的重要信息 + +历史记忆: +{history_memory} + +新对话内容: +{conversation}""" +TITLE_PROMPT = ( + "请根据以下对话内容,生成一个简洁的会话标题(约15字以内),只返回标题,不要包含任何解释或标点符号:\n{conversation}" +) + + +def _apply_thinking_policy(base_url: str, request: dict[str, Any]) -> None: + if "aliyuncs.com" in base_url: + request["enable_thinking"] = False + elif any(domain in base_url for domain in ("bigmodel.cn", "moonshot.cn", "volces.com")): + request["thinking"] = {"type": "disabled"} + + +async def openai_completion( + config: dict[str, Any], + prompt: str, + *, + temperature: float, + max_tokens: int, + timeout: float, +) -> str | None: + base_url = str(config.get("base_url") or "") + api_key = str(config.get("api_key") or "") + if not base_url.strip() or not api_key.strip(): + return None + api_url = base_url if base_url.endswith("/chat/completions") else f"{base_url.rstrip('/')}/chat/completions" + request: dict[str, Any] = { + "model": config.get("model_name") or "gpt-3.5-turbo", + "messages": [{"role": "user", "content": prompt}], + "temperature": temperature, + "max_tokens": max_tokens, + } + _apply_thinking_policy(base_url, request) + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post( + api_url, + json=request, + headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}, + ) + response.raise_for_status() + payload = response.json() + choices = payload.get("choices") if isinstance(payload, dict) else None + if not isinstance(choices, list) or not choices: + return None + message = choices[0].get("message") if isinstance(choices[0], dict) else None + content = message.get("content") if isinstance(message, dict) else None + return str(content) if content is not None else None diff --git a/main/manager-api-fastapi/app/integrations/mcp.py b/main/manager-api-fastapi/app/integrations/mcp.py new file mode 100644 index 00000000..8b62c6eb --- /dev/null +++ b/main/manager-api-fastapi/app/integrations/mcp.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import json +from typing import Any +from urllib.parse import quote_plus, urlsplit, urlunsplit + +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from websockets.asyncio.client import connect + + +def _java_aes_key(value: str) -> bytes: + raw = value.encode("utf-8") + if len(raw) in {16, 24, 32}: + return raw + return raw[:32].ljust(32, b"\x00") + + +def encrypt_agent_token(agent_id: str, key: str) -> str: + digest = hashlib.md5(agent_id.encode("utf-8"), usedforsecurity=False).hexdigest() + plain_text = f'{{"agentId": "{digest}"}}'.encode() + padder = padding.PKCS7(128).padder() + padded = padder.update(plain_text) + padder.finalize() + # ECB is required for byte-for-byte compatibility with Java AES/ECB/PKCS5Padding. + encryptor = Cipher(algorithms.AES(_java_aes_key(key)), modes.ECB()).encryptor() # noqa: S305 + encrypted = encryptor.update(padded) + encryptor.finalize() + return base64.b64encode(encrypted).decode("ascii") + + +def build_agent_mcp_address(endpoint: str | None, agent_id: str) -> str | None: + if endpoint is None or not endpoint.strip() or endpoint == "null": + return None + parsed = urlsplit(endpoint) + if not parsed.scheme or not parsed.netloc: + raise ValueError("mcp的地址存在错误,请进入参数管理修改mcp接入点地址") + marker = "key=" + marker_index = parsed.query.find(marker) + # Java takes everything following the first key= marker, including subsequent query text. + key = parsed.query[marker_index + len(marker) :] if marker_index >= 0 else parsed.query[3:] + ws_scheme = "wss" if parsed.scheme == "https" else "ws" + path = parsed.path + parent = path[: path.rfind("/")] if "/" in path else "" + base = urlunsplit((ws_scheme, parsed.netloc, parent, "", "")).rstrip("/") + token = quote_plus(encrypt_agent_token(agent_id, key), safe="") + return f"{base}/mcp/?token={token}" + + +INITIALIZE_REQUEST = { + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {"roots": {"listChanged": False}, "sampling": {}}, + "clientInfo": {"name": "xz-mcp-broker", "version": "0.0.1"}, + }, + "id": 1, +} +INITIALIZED_NOTIFICATION = {"jsonrpc": "2.0", "method": "notifications/initialized"} +TOOLS_REQUEST = {"jsonrpc": "2.0", "method": "tools/list", "params": None, "id": 2} + + +async def _receive_matching(websocket: Any, request_id: int, timeout: float) -> dict[str, Any] | None: + async def receive() -> dict[str, Any] | None: + async for message in websocket: + try: + value = json.loads(message) + except (TypeError, json.JSONDecodeError): + continue + if isinstance(value, dict) and value.get("id") == request_id: + return value + return None + + return await asyncio.wait_for(receive(), timeout=timeout) + + +async def list_mcp_tools(address: str, *, connect_timeout: float = 8.0, session_timeout: float = 10.0) -> list[str]: + call_address = address.replace("/mcp/", "/call/") + try: + async with connect( + call_address, + open_timeout=connect_timeout, + max_size=1024 * 1024, + close_timeout=1, + ) as websocket: + await websocket.send(json.dumps(INITIALIZE_REQUEST, ensure_ascii=False, separators=(",", ":"))) + initialized = await _receive_matching(websocket, 1, session_timeout) + if not initialized or "result" not in initialized or "error" in initialized: + return [] + await websocket.send(json.dumps(INITIALIZED_NOTIFICATION, separators=(",", ":"))) + await websocket.send(json.dumps(TOOLS_REQUEST, separators=(",", ":"))) + response = await _receive_matching(websocket, 2, session_timeout) + if not response or "error" in response: + return [] + result = response.get("result") + tools = result.get("tools") if isinstance(result, dict) else None + if not isinstance(tools, list): + return [] + return sorted( + item["name"] for item in tools if isinstance(item, dict) and isinstance(item.get("name"), str) + ) + # Java treats every connect/protocol/parse failure as an empty tool list. + except Exception: + return [] diff --git a/main/manager-api-fastapi/app/integrations/mqtt_gateway.py b/main/manager-api-fastapi/app/integrations/mqtt_gateway.py new file mode 100644 index 00000000..1e3a06b4 --- /dev/null +++ b/main/manager-api-fastapi/app/integrations/mqtt_gateway.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import date, datetime, timedelta, timezone +from typing import Any + +import httpx + + +class MqttGatewayError(RuntimeError): + def __init__(self, message: str, status_code: int | None = None): + super().__init__(message) + self.status_code = status_code + + +def daily_authorization_tokens(signature_key: str, now: datetime | None = None) -> list[str]: + if not signature_key.strip() or signature_key.strip().lower() == "null": + raise MqttGatewayError("MQTT Gateway signature key is empty") + instant = now or datetime.now(tz=timezone.utc) + utc_date = instant.astimezone(timezone.utc).date() + dates: tuple[date, date, date] = (utc_date, utc_date - timedelta(days=1), utc_date + timedelta(days=1)) + return [hashlib.sha256(f"{value.isoformat()}{signature_key}".encode()).hexdigest() for value in dates] + + +async def post_json( + url: str, + body: Any, + signature_key: str, + *, + timeout_seconds: float, + now: datetime | None = None, + client: httpx.AsyncClient | None = None, +) -> str: + encoded = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + owns_client = client is None + selected = client or httpx.AsyncClient() + last_unauthorized: int | None = None + try: + for token in daily_authorization_tokens(signature_key, now): + response = await selected.post( + url, + content=encoded, + headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"}, + timeout=timeout_seconds, + ) + if response.status_code == 401: + last_unauthorized = response.status_code + continue + if not 200 <= response.status_code < 300: + raise MqttGatewayError( + f"MQTT Gateway request failed with HTTP status {response.status_code}", + response.status_code, + ) + return response.text + finally: + if owns_client: + await selected.aclose() + raise MqttGatewayError( + "MQTT Gateway rejected all daily authorization tokens" + + ("" if last_unauthorized is None else f" (HTTP {last_unauthorized})"), + last_unauthorized, + ) diff --git a/main/manager-api-fastapi/app/integrations/ragflow.py b/main/manager-api-fastapi/app/integrations/ragflow.py new file mode 100644 index 00000000..c18a5b68 --- /dev/null +++ b/main/manager-api-fastapi/app/integrations/ragflow.py @@ -0,0 +1,490 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import httpx +from fastapi import UploadFile + +from app.core.errors import AppError + +_DOCUMENT_CHUNK_METHODS = { + "naive", + "manual", + "qa", + "table", + "paper", + "book", + "laws", + "presentation", + "picture", + "one", + "knowledge_graph", + "email", +} +_RUN_STATUSES = {"UNSTART", "RUNNING", "CANCEL", "DONE", "FAIL"} +_DOCUMENT_PARSER_FIELDS = ( + "chunk_token_num", + "delimiter", + "layout_recognize", + "html4excel", + "auto_keywords", + "auto_questions", + "topn_tags", + "raptor", + "graphrag", +) +_DATASET_PARSER_FIELDS = ( + "chunk_token_num", + "delimiter", + "layout_recognize", + "html4excel", + "auto_keywords", + "auto_questions", +) + + +class RAGFlowClient: + """Async equivalent of the Java RAGFlow adapter and its wire contract.""" + + def __init__(self, config: Mapping[str, Any]): + self.config = dict(config) + self.base_url = str(config.get("base_url") or config.get("baseUrl") or "").rstrip("/") + self.api_key = str(config.get("api_key") or config.get("apiKey") or "") + raw_timeout = config.get("timeout") + if raw_timeout is None: + self.timeout = 30.0 + else: + try: + self.timeout = float(int(str(raw_timeout))) + except (TypeError, ValueError): + self.timeout = 30.0 + self._validate(config) + + def _validate(self, config: Mapping[str, Any]) -> None: + if not config: + raise AppError(10164) + if not self.base_url.strip(): + raise AppError(10171) + if not self.api_key.strip(): + raise AppError(10172) + if "你" in self.api_key: + raise AppError(10173) + if not self.base_url.startswith(("http://", "https://")): + raise AppError(10174) + adapter_type = "ragflow" if "type" not in config else str(config.get("type")) + if adapter_type != "ragflow": + raise AppError(10184, params=(f"适配器类型未注册: {adapter_type}",)) + + async def request( + self, + method: str, + endpoint: str, + *, + params: Mapping[str, Any] | None = None, + json_body: Any = None, + files: Mapping[str, Any] | None = None, + data: Mapping[str, Any] | None = None, + ) -> dict[str, Any]: + headers = {"Authorization": f"Bearer {self.api_key}"} + if files is None: + headers["Content-Type"] = "application/json" + headers["Accept-Charset"] = "utf-8" + normalized_params = { + key: self._query_value(value) for key, value in (params or {}).items() if value is not None + } + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.request( + method, + self.base_url + endpoint, + params=normalized_params, + json=json_body, + files=files, + data=data, + headers=headers, + ) + response.raise_for_status() + payload = response.json() + except (httpx.HTTPError, ValueError) as exc: + raise AppError(10167, params=(f"Request Failed: {exc}",)) from exc + if not isinstance(payload, dict): + raise AppError(10167, params=("Invalid Response",)) + code = payload.get("code") + if code is not None: + if isinstance(code, bool) or not isinstance(code, int): + raise AppError(10167, params=("Request Failed: invalid response code type",)) + if code != 0: + message = payload.get("message") + if message is not None and not isinstance(message, str): + raise AppError(10167, params=("Request Failed: invalid response message type",)) + raise AppError(10167, params=(message or "Unknown RAGFlow Error",)) + return dict(payload) + + @staticmethod + def _query_value(value: Any) -> Any: + if isinstance(value, bool): + return str(value).lower() + if isinstance(value, list): + # Java List.toString() is what the baseline URL builder sends. + return "[" + ", ".join(str(item) for item in value) + "]" + return value + + async def dataset_info(self, dataset_id: str) -> dict[str, Any] | None: + payload = await self.request( + "GET", "/api/v1/datasets", params={"id": dataset_id, "page": 1, "page_size": 1} + ) + data = payload.get("data") + if isinstance(data, list) and data and isinstance(data[0], dict): + return _normalize_dataset_info(data[0]) + return None + + async def create_dataset(self, body: dict[str, Any]) -> dict[str, Any]: + body = dict(body) + body["permission"] = "me" if _blank(body.get("permission")) else body.get("permission") + body["chunk_method"] = "naive" if _blank(body.get("chunk_method")) else body.get("chunk_method") + if _blank(body.get("embedding_model")): + configured_model = self.config.get("embedding_model", self.config.get("embeddingModel")) + body["embedding_model"] = None if _blank(configured_model) else configured_model + body["avatar"] = body.get("avatar") if not _blank(body.get("avatar")) else ( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + ) + body["parser_config"] = _normalize_parser_config( + body.get("parser_config"), fields=_DATASET_PARSER_FIELDS + ) + payload = await self.request("POST", "/api/v1/datasets", json_body=body) + data = payload.get("data") + if not isinstance(data, dict) or not data.get("id"): + raise AppError(10167, params=("Invalid response from createDataset: missing data object",)) + return _normalize_dataset_info(data) + + async def update_dataset(self, dataset_id: str, body: dict[str, Any]) -> dict[str, Any] | None: + body = dict(body) + body["parser_config"] = _normalize_parser_config( + body.get("parser_config"), fields=_DATASET_PARSER_FIELDS + ) + payload = await self.request("PUT", f"/api/v1/datasets/{dataset_id}", json_body=body) + return _normalize_dataset_info(payload["data"]) if isinstance(payload.get("data"), dict) else None + + async def delete_datasets(self, ids: list[str]) -> Any: + return (await self.request("DELETE", "/api/v1/datasets", json_body={"ids": ids})).get("data") + + async def documents( + self, + dataset_id: str, + *, + page: int = 1, + page_size: int = 10, + name: str | None = None, + status: str | None = None, + document_id: str | None = None, + ) -> tuple[list[dict[str, Any]], int]: + params: dict[str, Any] = {"page": page, "page_size": page_size} + if name: + params["name"] = name + if status: + status_number = int(status) if status.lstrip("-").isdigit() else None + names = {0: "UNSTART", 1: "RUNNING", 2: "CANCEL", 3: "DONE", 4: "FAIL"} + params["run"] = [names[status_number]] if status_number in names else [] + if document_id: + params["id"] = document_id + payload = await self.request("GET", f"/api/v1/datasets/{dataset_id}/documents", params=params) + data = payload.get("data") + if not isinstance(data, dict): + return [], 0 + docs = data.get("docs") + rows: list[dict[str, Any]] = [] + if isinstance(docs, list): + for item in docs: + if not isinstance(item, dict): + continue + try: + rows.append(_normalize_upload_document(item)) + except AppError: + # The Java adapter skips an individual document whose + # strong DTO conversion fails and keeps the rest of page. + continue + return rows, int(data.get("total") or 0) + + async def upload_document( + self, + dataset_id: str, + file: UploadFile, + content: bytes, + *, + name: str, + meta_fields: dict[str, Any] | None, + chunk_method: str | None, + parser_config: dict[str, Any] | None, + ) -> dict[str, Any]: + import json + + form: dict[str, Any] = {"name": name} + if meta_fields: + form["meta"] = json.dumps(meta_fields, ensure_ascii=False, separators=(",", ":")) + if not _blank(chunk_method): + normalized_method = str(chunk_method).lower() + if normalized_method in _DOCUMENT_CHUNK_METHODS: + form["chunk_method"] = normalized_method + normalized_parser = ( + _normalize_parser_config( + parser_config, + fields=_DOCUMENT_PARSER_FIELDS, + validate_layout=True, + ) + if parser_config + else None + ) + if normalized_parser is not None: + form["parser_config"] = json.dumps(normalized_parser, ensure_ascii=False, separators=(",", ":")) + payload = await self.request( + "POST", + f"/api/v1/datasets/{dataset_id}/documents", + files={"file": (file.filename or name, content, file.content_type or "application/octet-stream")}, + data=form, + ) + data = payload.get("data") + if isinstance(data, list) and data and isinstance(data[0], dict): + return _normalize_upload_document(data[0]) + if isinstance(data, dict): + return _normalize_upload_document(data) + raise AppError(10167, params=("远程上传成功但未返回有效 DocumentID",)) + + async def delete_documents(self, dataset_id: str, ids: list[str]) -> None: + await self.request( + "DELETE", f"/api/v1/datasets/{dataset_id}/documents", json_body={"ids": ids} + ) + + async def parse_documents(self, dataset_id: str, document_ids: list[str]) -> None: + await self.request( + "POST", + f"/api/v1/datasets/{dataset_id}/chunks", + json_body={"document_ids": document_ids}, + ) + + async def chunks( + self, dataset_id: str, document_id: str, params: Mapping[str, Any] + ) -> dict[str, Any]: + payload = await self.request( + "GET", f"/api/v1/datasets/{dataset_id}/documents/{document_id}/chunks", params=params + ) + data = payload.get("data") + if not isinstance(data, dict): + return {"chunks": [], "doc": None, "total": 0} + try: + return _normalize_chunk_list(data) + except (TypeError, ValueError) as exc: + raise AppError(10167, params=(str(exc),)) from exc + + async def retrieval(self, body: dict[str, Any]) -> dict[str, Any]: + payload = await self.request("POST", "/api/v1/retrieval", json_body=body) + data = payload.get("data") + if not isinstance(data, dict): + return {"chunks": [], "doc_aggs": [], "total": 0} + try: + return _normalize_retrieval_result(data) + except (TypeError, ValueError) as exc: + raise AppError(10167, params=(str(exc),)) from exc + + +def _blank(value: Any) -> bool: + return value is None or (isinstance(value, str) and not value.strip()) + + +def _normalize_parser_config( + value: Any, + *, + fields: tuple[str, ...], + validate_layout: bool = False, +) -> dict[str, Any] | None: + if value is None: + return None + if not isinstance(value, Mapping): + raise ValueError("parser_config must be an object") + result = {key: value.get(key) for key in fields} + if validate_layout and result.get("layout_recognize") not in {None, "DeepDOC", "Simple"}: + raise ValueError("invalid layout_recognize") + if "raptor" in result and result["raptor"] is not None: + nested = result["raptor"] + if not isinstance(nested, Mapping): + raise ValueError("raptor must be an object") + result["raptor"] = {"use_raptor": nested.get("use_raptor")} + if "graphrag" in result and result["graphrag"] is not None: + nested = result["graphrag"] + if not isinstance(nested, Mapping): + raise ValueError("graphrag must be an object") + result["graphrag"] = {"use_graphrag": nested.get("use_graphrag")} + return result + + +def _normalize_upload_document(value: Mapping[str, Any]) -> dict[str, Any]: + result = dict(value) + try: + if result.get("parser_config") is not None: + result["parser_config"] = _normalize_parser_config( + result["parser_config"], fields=_DOCUMENT_PARSER_FIELDS, validate_layout=True + ) + except (TypeError, ValueError) as exc: + raise AppError(10167, params=("远程上传成功但未返回有效 DocumentID",)) from exc + chunk_method = result.get("chunk_method") + if chunk_method is not None: + normalized_method = str(chunk_method).lower() + if normalized_method not in _DOCUMENT_CHUNK_METHODS: + raise AppError(10167, params=("远程上传成功但未返回有效 DocumentID",)) + result["chunk_method"] = normalized_method + run = result.get("run") + if run is not None and str(run) not in _RUN_STATUSES: + raise AppError(10167, params=("远程上传成功但未返回有效 DocumentID",)) + return result + + +def _normalize_dataset_info(value: Mapping[str, Any]) -> dict[str, Any]: + """Apply Jackson's DatasetDTO.InfoVO unknown-field and type boundary.""" + fields = ( + "id", + "name", + "avatar", + "tenant_id", + "description", + "embedding_model", + "permission", + "chunk_method", + "parser_config", + "chunk_count", + "document_count", + "create_time", + "update_time", + "token_num", + "create_date", + "update_date", + ) + try: + result = {field: value.get(field) for field in fields} + result["parser_config"] = _normalize_parser_config( + result.get("parser_config"), fields=_DATASET_PARSER_FIELDS + ) + for field in ("chunk_count", "document_count", "create_time", "update_time", "token_num"): + raw_value = result[field] + if raw_value is not None: + result[field] = int(raw_value) + return result + except (TypeError, ValueError) as exc: + raise AppError(10167, params=(str(exc),)) from exc + + +def _nullable_object(value: Any, fields: tuple[str, ...]) -> dict[str, Any] | None: + if value is None: + return None + if not isinstance(value, Mapping): + raise TypeError("response object has an invalid shape") + return {field: value.get(field) for field in fields} + + +def _normalize_chunk_list(data: Mapping[str, Any]) -> dict[str, Any]: + chunk_fields = ( + "id", + "content", + "document_id", + "docnm_kwd", + "important_keywords", + "questions", + "image_id", + "dataset_id", + "available", + "positions", + "token", + ) + raw_chunks = data.get("chunks") + if raw_chunks is None: + chunks: list[dict[str, Any]] = [] + elif isinstance(raw_chunks, list): + chunks = [] + for item in raw_chunks: + normalized = _nullable_object(item, chunk_fields) + if normalized is not None: + chunks.append(normalized) + else: + raise TypeError("chunks must be an array") + + doc_fields = ( + "id", + "thumbnail", + "dataset_id", + "chunk_method", + "pipeline_id", + "parser_config", + "source_type", + "type", + "created_by", + "name", + "location", + "size", + "token_count", + "chunk_count", + "progress", + "progress_msg", + "process_begin_at", + "process_duration", + "meta_fields", + "suffix", + "run", + "status", + "create_time", + "create_date", + "update_time", + "update_date", + ) + doc = _nullable_object(data.get("doc"), doc_fields) + if doc is not None: + doc["parser_config"] = _normalize_parser_config( + doc.get("parser_config"), fields=_DOCUMENT_PARSER_FIELDS, validate_layout=True + ) + if doc.get("chunk_count") is not None: + # DocumentDTO.InfoVO.chunkCount is Long, unlike the Integer field + # on KnowledgeFilesDTO used by the document-list endpoint. + doc["chunk_count"] = str(doc["chunk_count"]) + if doc.get("run") is not None and str(doc["run"]) not in _RUN_STATUSES: + raise ValueError("invalid document run status") + # ChunkDTO.ListVO.total is Long and therefore uses the Java global Long + # serializer even for small values (including the adapter's default 0L). + return {"chunks": chunks, "doc": doc, "total": str(int(data.get("total") or 0))} + + +def _normalize_retrieval_result(data: Mapping[str, Any]) -> dict[str, Any]: + hit_fields = ( + "id", + "content", + "document_id", + "dataset_id", + "document_name", + "document_keyword", + "similarity", + "vector_similarity", + "term_similarity", + "index", + "highlight", + "important_keywords", + "questions", + "image_id", + "positions", + ) + agg_fields = ("doc_name", "doc_id", "count") + + def normalize_list(raw: Any, fields: tuple[str, ...], name: str) -> list[dict[str, Any]]: + if raw is None: + return [] + if not isinstance(raw, list): + raise TypeError(f"{name} must be an array") + values: list[dict[str, Any]] = [] + for item in raw: + normalized = _nullable_object(item, fields) + if normalized is not None: + values.append(normalized) + return values + + return { + "chunks": normalize_list(data.get("chunks"), hit_fields, "chunks"), + "doc_aggs": normalize_list(data.get("doc_aggs"), agg_fields, "doc_aggs"), + # RetrievalDTO.ResultVO.total is also Long. + "total": str(int(data.get("total") or 0)), + } diff --git a/main/manager-api-fastapi/app/integrations/voice_clone.py b/main/manager-api-fastapi/app/integrations/voice_clone.py new file mode 100644 index 00000000..aecfd5ad --- /dev/null +++ b/main/manager-api-fastapi/app/integrations/voice_clone.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import base64 +import json +from dataclasses import dataclass +from typing import Any + +import httpx + + +@dataclass(slots=True) +class VoiceCloneProviderError(Exception): + code: int + message: str + + def __str__(self) -> str: + return self.message + + +class VoiceCloneIntegration: + ENDPOINT = "https://openspeech.bytedance.com/api/v1/mega_tts/audio/upload" + + def __init__( + self, + *, + timeout_seconds: float, + client: httpx.AsyncClient | None = None, + endpoint: str | None = None, + ): + self.timeout_seconds = timeout_seconds + self.client = client + self.endpoint = endpoint or self.ENDPOINT + + async def train_huoshan( + self, + *, + appid: str, + access_token: str, + voice: bytes, + speaker_id: str, + ) -> str: + request_body: dict[str, Any] = { + "appid": appid, + "audios": [ + { + "audio_bytes": base64.b64encode(voice).decode("ascii"), + "audio_format": "wav", + } + ], + "source": 2, + "language": 0, + "model_type": 1, + "speaker_id": speaker_id, + } + owns_client = self.client is None + client = self.client or httpx.AsyncClient() + try: + response = await client.post( + self.endpoint, + content=json.dumps(request_body, ensure_ascii=False, separators=(",", ":")).encode("utf-8"), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer;{access_token}", + "Resource-Id": "seed-icl-1.0", + }, + timeout=self.timeout_seconds, + ) + try: + payload = response.json() + except (json.JSONDecodeError, ValueError) as exc: + raise VoiceCloneProviderError(10157, str(exc)) from exc + except httpx.HTTPError as exc: + raise VoiceCloneProviderError(10157, str(exc)) from exc + finally: + if owns_client: + await client.aclose() + + if not isinstance(payload, dict): + raise VoiceCloneProviderError(10156, "响应格式错误,缺少BaseResp字段") + base_response = payload.get("BaseResp") + if isinstance(base_response, dict): + raw_status = base_response.get("StatusCode") + try: + status_code = int(raw_status) if raw_status is not None else None + except (TypeError, ValueError): + status_code = None + returned_speaker = payload.get("speaker_id") + if status_code == 0 and isinstance(returned_speaker, str) and returned_speaker.strip(): + return returned_speaker + status_message = base_response.get("StatusMessage") + message = str(status_message) if status_message not in (None, "") else "训练失败" + raise VoiceCloneProviderError(500, message) + payload_message = payload.get("message") + if payload_message not in (None, ""): + raise VoiceCloneProviderError(500, str(payload_message)) + raise VoiceCloneProviderError(10156, "响应格式错误,缺少BaseResp字段") diff --git a/main/manager-api-fastapi/app/integrations/voiceprint.py b/main/manager-api-fastapi/app/integrations/voiceprint.py new file mode 100644 index 00000000..45de2529 --- /dev/null +++ b/main/manager-api-fastapi/app/integrations/voiceprint.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from urllib.parse import urlsplit + +import httpx + + +class VoicePrintIntegrationError(RuntimeError): + def __init__(self, code: int, message: str | None = None, params: tuple[object, ...] = ()): + super().__init__(message or str(code)) + self.code = code + self.message = message + self.params = params + + +@dataclass(slots=True, frozen=True) +class VoicePrintEndpoint: + base_url: str + authorization: str + + @classmethod + def parse(cls, configured_url: str | None) -> VoicePrintEndpoint: + if configured_url is None: + raise VoicePrintIntegrationError(10084) + parsed = urlsplit(configured_url) + if not parsed.scheme or not parsed.hostname: + raise VoicePrintIntegrationError(10084) + marker = "key=" + marker_index = parsed.query.find(marker) + key = parsed.query[marker_index + len(marker) :] if marker_index >= 0 else parsed.query[3:] + port = f":{parsed.port}" if parsed.port is not None else "" + return cls(f"{parsed.scheme}://{parsed.hostname}{port}", f"Bearer {key}") + + +class VoicePrintClient: + def __init__(self, configured_url: str, *, timeout: float = 10.0, client: httpx.AsyncClient | None = None): + self.endpoint = VoicePrintEndpoint.parse(configured_url) + self.timeout = timeout + self._client = client + + async def _request( + self, + method: str, + path: str, + *, + data: Mapping[str, str] | None = None, + files: Mapping[str, tuple[str, bytes, str]] | None = None, + ) -> httpx.Response: + headers = {"Authorization": self.endpoint.authorization} + if self._client is not None: + return await self._client.request( + method, f"{self.endpoint.base_url}{path}", headers=headers, data=data, files=files + ) + async with httpx.AsyncClient(timeout=self.timeout) as client: + return await client.request( + method, f"{self.endpoint.base_url}{path}", headers=headers, data=data, files=files + ) + + async def identify(self, speaker_ids: list[str], audio: bytes) -> tuple[str | None, float | None] | None: + if not speaker_ids: + return None + response = await self._request( + "POST", + "/voiceprint/identify", + data={"speaker_ids": ",".join(speaker_ids)}, + files={"file": ("VoicePrint.WAV", audio, "application/octet-stream")}, + ) + if response.status_code != 200: + raise VoicePrintIntegrationError(10091) + try: + payload = response.json() + except ValueError as exc: + raise VoicePrintIntegrationError(10091) from exc + if not isinstance(payload, dict): + return None + speaker_id = payload.get("speaker_id") + score = payload.get("score") + return ( + str(speaker_id) if speaker_id is not None else None, + float(score) if isinstance(score, int | float) else None, + ) + + async def register(self, speaker_id: str, audio: bytes) -> None: + response = await self._request( + "POST", + "/voiceprint/register", + data={"speaker_id": speaker_id}, + files={"file": ("VoicePrint.WAV", audio, "application/octet-stream")}, + ) + if response.status_code != 200: + raise VoicePrintIntegrationError(10087) + if "true" not in response.text: + raise VoicePrintIntegrationError(10088) + + async def cancel(self, speaker_id: str) -> None: + response = await self._request("DELETE", f"/voiceprint/{speaker_id}") + if response.status_code != 200: + raise VoicePrintIntegrationError(10089) + if "true" not in response.text: + raise VoicePrintIntegrationError(10090) diff --git a/main/manager-api-fastapi/app/jobs/__init__.py b/main/manager-api-fastapi/app/jobs/__init__.py new file mode 100644 index 00000000..1740faf2 --- /dev/null +++ b/main/manager-api-fastapi/app/jobs/__init__.py @@ -0,0 +1 @@ +"""Single-instance background jobs for the manager API.""" diff --git a/main/manager-api-fastapi/app/jobs/tasks.py b/main/manager-api-fastapi/app/jobs/tasks.py new file mode 100644 index 00000000..a93fbf8f --- /dev/null +++ b/main/manager-api-fastapi/app/jobs/tasks.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from app.core.config import get_settings +from app.core.database import get_session_factory +from app.core.redis import distributed_lock +from app.repositories.knowledge import KnowledgeRepository +from app.services.knowledge import KnowledgeDocumentService + + +async def sync_running_knowledge_documents() -> int: + """Run one document-status pass under a cross-process Redis lock.""" + + settings = get_settings() + async with distributed_lock("jobs:knowledge-document-status", settings.job_lock_ttl_seconds) as acquired: + if not acquired: + return 0 + async with get_session_factory()() as session: + return await KnowledgeDocumentService(KnowledgeRepository(session)).sync_running() diff --git a/main/manager-api-fastapi/app/jobs/worker.py b/main/manager-api-fastapi/app/jobs/worker.py new file mode 100644 index 00000000..17132823 --- /dev/null +++ b/main/manager-api-fastapi/app/jobs/worker.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import asyncio +import logging +import os +import signal +import time +from collections.abc import Awaitable, Callable +from contextlib import suppress + +from app.core.config import get_settings +from app.core.database import configure_database, database_ping, dispose_database +from app.core.redis import close_redis, redis_ping +from app.jobs.tasks import sync_running_knowledge_documents +from app.services.agent import redact_legacy_agent_snapshots + +logger = logging.getLogger(__name__) + + +async def _wait_or_stop(stop: asyncio.Event, seconds: float) -> bool: + try: + await asyncio.wait_for(stop.wait(), timeout=seconds) + except asyncio.TimeoutError: + return False + return True + + +async def _fixed_delay_loop( + stop: asyncio.Event, + operation: Callable[[], Awaitable[int]], + *, + name: str, + initial_delay: float, + delay: float, +) -> None: + if initial_delay and await _wait_or_stop(stop, initial_delay): + return + while not stop.is_set(): + started = time.monotonic() + try: + changed = await operation() + logger.info( + "Background job %s completed changed=%s duration_ms=%d", + name, + changed, + (time.monotonic() - started) * 1000, + ) + except Exception: + logger.exception("Background job %s failed; the next fixed-delay pass will retry", name) + if await _wait_or_stop(stop, delay): + return + + +async def _finish_tasks(tasks: list[asyncio.Task[None]], timeout: float) -> bool: + """Let active jobs finish, then cancel only those exceeding the shutdown budget.""" + + _, pending = await asyncio.wait(tasks, timeout=max(timeout, 0.0)) + if not pending: + return True + logger.warning( + "Graceful job shutdown timed out after %.1f seconds; cancelling %d task(s)", + timeout, + len(pending), + ) + for task in pending: + task.cancel() + for task in pending: + with suppress(asyncio.CancelledError): + await task + return False + + +async def run_worker(stop: asyncio.Event | None = None) -> None: + settings = get_settings() + os.environ["TZ"] = settings.timezone + if hasattr(time, "tzset"): + time.tzset() + logging.basicConfig(level=settings.log_level, format="%(asctime)s %(levelname)s %(name)s %(message)s") + configure_database(settings) + selected_stop = stop or asyncio.Event() + + if not settings.allow_start_without_dependencies: + if not await database_ping(): + raise RuntimeError("database readiness check failed") + if not await redis_ping(): + raise RuntimeError("Redis readiness check failed") + + # The retained Java service performs a blocking startup redaction pass, + # then compensates rolling-deployment writes after 5 seconds and every + # 15 seconds. The standalone worker preserves those timings while the + # Redis lock makes multiple worker replicas safe. + await redact_legacy_agent_snapshots() + tasks = [ + asyncio.create_task( + _fixed_delay_loop( + selected_stop, + redact_legacy_agent_snapshots, + name="agent-snapshot-redaction", + initial_delay=5, + delay=15, + ) + ), + asyncio.create_task( + _fixed_delay_loop( + selected_stop, + sync_running_knowledge_documents, + name="knowledge-document-status", + initial_delay=0, + delay=30, + ) + ), + ] + try: + await selected_stop.wait() + finally: + await _finish_tasks(tasks, settings.graceful_shutdown_seconds) + await close_redis() + await dispose_database() + + +def main() -> None: + stop = asyncio.Event() + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + for signal_name in (signal.SIGINT, signal.SIGTERM): + with suppress(NotImplementedError): + loop.add_signal_handler(signal_name, stop.set) + try: + loop.run_until_complete(run_worker(stop)) + finally: + loop.close() + + +if __name__ == "__main__": + main() diff --git a/main/manager-api-fastapi/app/main.py b/main/manager-api-fastapi/app/main.py new file mode 100644 index 00000000..38399630 --- /dev/null +++ b/main/manager-api-fastapi/app/main.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import logging +import os +import time +from collections.abc import AsyncIterator, Mapping, Sequence +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from sqlalchemy.exc import IntegrityError +from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.middleware.cors import CORSMiddleware + +from app.core.config import get_settings +from app.core.database import configure_database, database_ping, dispose_database +from app.core.errors import AppError, ErrorCode +from app.core.i18n import message_for +from app.core.redis import close_redis, redis_ping +from app.core.responses import JavaJSONResponse, error_response, ok +from app.core.security import AuthenticationMiddleware +from app.routers import application_routers + +logger = logging.getLogger(__name__) +settings = get_settings() + +_MULTIPART_VALIDATION_PATHS = { + "/datasets/{dataset_id}/documents", + "/otaMag/upload", + "/otaMag/uploadAssetsBin", + "/voiceClone/upload", +} + + +def _matches_path_template(path: str, template: str) -> bool: + path_parts = path.removeprefix(settings.context_path).strip("/").split("/") + template_parts = template.strip("/").split("/") + return len(path_parts) == len(template_parts) and all( + expected.startswith("{") and expected.endswith("}") or actual == expected + for actual, expected in zip(path_parts, template_parts, strict=True) + ) + + +def _java_required_message(request: Request, errors: Sequence[Mapping[str, Any]]) -> str | None: + path = request.url.path.removeprefix(settings.context_path) + mappings = ( + ( + "/admin/server/emit-action", + (("action", "操作不能为空"), ("targetWs", "目标ws地址不能为空")), + ), + ("/agent", (("agentName", "智能体名称不能为空"),)), + ( + "/agent/chat-history/report", + tuple((field, "不能为空") for field in ("macAddress", "sessionId", "chatType", "content")), + ), + ( + "/agent/{agentId}/snapshots/{snapshotId}/restore", + (("currentStateToken", "不能为空"),), + ), + ( + "/config/agent-models", + ( + ("macAddress", "设备MAC地址不能为空"), + ("clientId", "客户端ID不能为空"), + ("selectedModule", "客户端已实例化的模型不能为空"), + ), + ), + ("/config/correct-words", (("macAddress", "设备MAC地址不能为空"),)), + ( + "/device/address-book/alias", + (("targetMac", "目标MAC地址不能为空"), ("macAddress", "MAC地址不能为空")), + ), + ) + missing_fields: set[str] = set() + for error in errors: + location = tuple(error.get("loc", ())) + if error.get("type") == "missing" and location[:1] == ("body",): + missing_fields.add(str(location[-1])) + if not missing_fields: + return None + for template, fields in mappings: + if _matches_path_template(path, template): + return next((message for field, message in fields if field in missing_fields), None) + return None + + +@asynccontextmanager +async def lifespan(_: FastAPI) -> AsyncIterator[None]: + os.environ["TZ"] = settings.timezone + if hasattr(time, "tzset"): + time.tzset() + settings.upload_dir.mkdir(parents=True, exist_ok=True) + configure_database(settings) + if not settings.i18n_dir.exists(): + raise RuntimeError(f"Java i18n resources are missing: {settings.i18n_dir}") + if not settings.changelog_path.exists(): + raise RuntimeError(f"Liquibase source of truth is missing: {settings.changelog_path}") + if not settings.allow_start_without_dependencies: + if not await database_ping(): + raise RuntimeError("database readiness check failed") + if not await redis_ping(): + raise RuntimeError("Redis readiness check failed") + yield + await close_redis() + await dispose_database() + + +app = FastAPI( + title="xiaozhi-manager-api", + version="0.1.0", + docs_url=f"{settings.context_path}/doc.html", + openapi_url=f"{settings.context_path}/v3/api-docs", + redoc_url=None, + default_response_class=JavaJSONResponse, + lifespan=lifespan, +) + +app.add_middleware(AuthenticationMiddleware) +app.add_middleware( + CORSMiddleware, + allow_origins=[], + allow_origin_regex=".*", + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["*"], + max_age=3600, +) + +for router in application_routers(): + app.include_router(router, prefix=settings.context_path) + + +@app.get(f"{settings.context_path}/health", include_in_schema=False) +async def health() -> JavaJSONResponse: + return ok({"status": "UP"}) + + +@app.get(f"{settings.context_path}/health/live", include_in_schema=False) +async def liveness() -> JavaJSONResponse: + return ok({"status": "UP"}) + + +def upload_storage_ready() -> bool: + """Report whether the non-root API process can traverse and write its upload mount.""" + + try: + return settings.upload_dir.is_dir() and os.access( + settings.upload_dir, + os.W_OK | os.X_OK, + ) + except OSError: + return False + + +@app.get(f"{settings.context_path}/health/ready", include_in_schema=False) +async def readiness() -> JavaJSONResponse: + database, redis, uploads = await database_ping(), await redis_ping(), upload_storage_ready() + code = 0 if database and redis and uploads else 503 + msg = "success" if code == 0 else "dependencies unavailable" + return JavaJSONResponse( + { + "code": code, + "msg": msg, + "data": {"database": database, "redis": redis, "uploads": uploads}, + }, + status_code=200 if code == 0 else 503, + ) + + +@app.exception_handler(AppError) +async def app_error_handler(request: Request, exc: AppError) -> JavaJSONResponse: + return error_response(request, exc.code, exc.message, params=exc.params) + + +@app.exception_handler(RequestValidationError) +async def validation_error_handler(request: Request, exc: RequestValidationError) -> JavaJSONResponse: + errors = exc.errors() + # Spring only maps MethodArgumentNotValidException (a deserialized JSON + # object's @Valid field constraints) to code 10034. Root-body conversion, + # missing query parameters and multipart binding failures reach its generic + # exception handler and therefore keep the HTTP-200/code-500 envelope. + root_body_error = any(tuple(error.get("loc", ())) == ("body",) for error in errors) + missing_query = any( + error.get("type") == "missing" and tuple(error.get("loc", ()))[:1] == ("query",) + for error in errors + ) + multipart_binding_error = any( + error.get("type") == "missing" + and tuple(error.get("loc", ()))[:1] == ("body",) + and any(_matches_path_template(request.url.path, path) for path in _MULTIPART_VALIDATION_PATHS) + for error in errors + ) + if root_body_error or missing_query or multipart_binding_error: + return error_response(request, ErrorCode.INTERNAL_SERVER_ERROR) + first = errors[0] if errors else None + detail = _java_required_message(request, errors) or (str(first.get("msg")) if first else None) + return error_response(request, ErrorCode.PARAM_VALUE_NULL, detail) + + +@app.exception_handler(IntegrityError) +async def integrity_error_handler(request: Request, _: IntegrityError) -> JavaJSONResponse: + return error_response(request, ErrorCode.DB_RECORD_EXISTS) + + +@app.exception_handler(StarletteHTTPException) +async def http_error_handler(request: Request, exc: StarletteHTTPException) -> JavaJSONResponse: + if exc.status_code == 404: + not_found = message_for(ErrorCode.RESOURCE_NOT_FOUND, request.headers.get("Accept-Language")) + return error_response(request, 404, not_found) + return error_response(request, exc.status_code, str(exc.detail)) + + +@app.exception_handler(Exception) +async def unhandled_error_handler(request: Request, exc: Exception) -> JavaJSONResponse: + logger.exception("Unhandled manager-api error", exc_info=exc) + return error_response(request, ErrorCode.INTERNAL_SERVER_ERROR) diff --git a/main/manager-api-fastapi/app/repositories/agent.py b/main/manager-api-fastapi/app/repositories/agent.py new file mode 100644 index 00000000..ea42b24c --- /dev/null +++ b/main/manager-api-fastapi/app/repositories/agent.py @@ -0,0 +1,665 @@ +from __future__ import annotations + +# All interpolated SQL fragments are selected from closed column/table allowlists. +# ruff: noqa: S608 +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import Any + +from sqlalchemy import bindparam, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import Repository + +AGENT_COLUMNS = ( + "id", + "user_id", + "agent_code", + "agent_name", + "asr_model_id", + "vad_model_id", + "llm_model_id", + "slm_model_id", + "vllm_model_id", + "tts_model_id", + "tts_voice_id", + "tts_language", + "tts_volume", + "tts_rate", + "tts_pitch", + "mem_model_id", + "intent_model_id", + "chat_history_conf", + "system_prompt", + "summary_memory", + "lang_code", + "language", + "sort", + "creator", + "created_at", + "updater", + "updated_at", +) +AGENT_MUTABLE_COLUMNS = frozenset(AGENT_COLUMNS) - {"id", "user_id", "creator", "created_at"} +TEMPLATE_COLUMNS = ( + "id", + "agent_code", + "agent_name", + "asr_model_id", + "vad_model_id", + "llm_model_id", + "vllm_model_id", + "tts_model_id", + "tts_voice_id", + "tts_language", + "tts_volume", + "tts_rate", + "tts_pitch", + "mem_model_id", + "intent_model_id", + "chat_history_conf", + "system_prompt", + "summary_memory", + "lang_code", + "language", + "sort", + "creator", + "created_at", + "updater", + "updated_at", +) + + +class AgentRepository(Repository): + def __init__(self, session: AsyncSession): + super().__init__(session) + + @property + def is_sqlite(self) -> bool: + bind = self.session.get_bind() + return bool(bind is not None and bind.dialect.name == "sqlite") + + async def get_agent(self, agent_id: str, *, for_update: bool = False) -> dict[str, Any] | None: + suffix = "" if self.is_sqlite or not for_update else " FOR UPDATE" + return await self.fetch_one( + f"SELECT {', '.join(AGENT_COLUMNS)} FROM ai_agent WHERE id=:id{suffix}", {"id": agent_id} + ) + + async def check_agent_owner(self, agent_id: str, user_id: int, *, super_admin: bool) -> bool: + if super_admin: + return bool(await self.scalar("SELECT 1 FROM ai_agent WHERE id=:id LIMIT 1", {"id": agent_id})) + return bool( + await self.scalar( + "SELECT 1 FROM ai_agent WHERE id=:id AND user_id=:user_id LIMIT 1", + {"id": agent_id, "user_id": user_id}, + ) + ) + + async def list_user_agents(self, user_id: int, keyword: str | None) -> list[dict[str, Any]]: + params: dict[str, Any] = {"user_id": user_id} + where = "a.user_id=:user_id" + if keyword is not None and keyword.strip(): + params["keyword"] = f"%{keyword}%" + where += ( + " AND (a.agent_name LIKE :keyword" + " OR EXISTS (SELECT 1 FROM ai_device d0 WHERE d0.agent_id=a.id" + " AND d0.user_id=:user_id AND d0.mac_address LIKE :keyword)" + " OR EXISTS (SELECT 1 FROM ai_agent_tag_relation tr0" + " JOIN ai_agent_tag t0 ON t0.id=tr0.tag_id" + " WHERE tr0.agent_id=a.id AND t0.deleted=0 AND t0.tag_name LIKE :keyword))" + ) + return await self.fetch_all( + "SELECT a.*, mt.model_name AS tts_model_name, ml.model_name AS llm_model_name," + " mv.model_name AS vllm_model_name, COALESCE(tv.name, vc.name) AS tts_voice_name," + " (SELECT MAX(d.last_connected_at) FROM ai_device d WHERE d.agent_id=a.id) AS last_connected_at," + " (SELECT COUNT(*) FROM ai_device d WHERE d.agent_id=a.id) AS device_count" + " FROM ai_agent a" + " LEFT JOIN ai_model_config mt ON mt.id=a.tts_model_id" + " LEFT JOIN ai_model_config ml ON ml.id=a.llm_model_id" + " LEFT JOIN ai_model_config mv ON mv.id=a.vllm_model_id" + " LEFT JOIN ai_tts_voice tv ON tv.id=a.tts_voice_id" + " LEFT JOIN ai_voice_clone vc ON vc.id=a.tts_voice_id" + f" WHERE {where} ORDER BY a.created_at DESC", + params, + ) + + async def list_admin_agents( + self, page: int, limit: int, order_field: str, ascending: bool + ) -> tuple[list[dict[str, Any]], int]: + allowed = {"agent_name", "created_at", "updated_at", "sort", "id"} + selected = order_field if order_field in allowed else "agent_name" + direction = "ASC" if ascending else "DESC" + total = int(await self.scalar("SELECT COUNT(*) FROM ai_agent") or 0) + query = ( + f"SELECT {', '.join(AGENT_COLUMNS)} FROM ai_agent " + f"ORDER BY {selected} {direction} LIMIT :limit OFFSET :offset" + ) + rows = await self.fetch_all( + query, + {"limit": limit, "offset": (page - 1) * limit}, + ) + return rows, total + + async def insert_agent(self, values: Mapping[str, Any]) -> int: + columns = [column for column in AGENT_COLUMNS if column in values] + placeholders = ", ".join(f":{column}" for column in columns) + return await self.execute( + f"INSERT INTO ai_agent ({', '.join(columns)}) VALUES ({placeholders})", + {column: values[column] for column in columns}, + ) + + async def update_agent(self, agent_id: str, values: Mapping[str, Any], *, include_null: bool = False) -> int: + selected = { + key: value + for key, value in values.items() + if key in AGENT_MUTABLE_COLUMNS and (include_null or value is not None) + } + if not selected: + return 0 + assignments = ", ".join(f"{column}=:{column}" for column in selected) + return await self.execute( + f"UPDATE ai_agent SET {assignments} WHERE id=:agent_id", + {**selected, "agent_id": agent_id}, + ) + + async def get_agent_plugins(self, agent_id: str) -> list[dict[str, Any]]: + return await self.fetch_all( + "SELECT m.id,m.agent_id,m.plugin_id,m.param_info,p.provider_code" + " FROM ai_agent_plugin_mapping m LEFT JOIN ai_model_provider p ON p.id=m.plugin_id" + " WHERE m.agent_id=:agent_id ORDER BY m.id ASC", + {"agent_id": agent_id}, + ) + + async def replace_plugins(self, agent_id: str, plugins: Sequence[Mapping[str, Any]]) -> None: + existing = await self.fetch_all( + "SELECT id,plugin_id FROM ai_agent_plugin_mapping WHERE agent_id=:agent_id", + {"agent_id": agent_id}, + ) + by_plugin = {str(row["plugin_id"]): int(row["id"]) for row in existing} + incoming = {str(item.get("plugin_id") or "") for item in plugins} + remove_ids = [int(row["id"]) for row in existing if str(row["plugin_id"]) not in incoming] + if remove_ids: + statement = text("DELETE FROM ai_agent_plugin_mapping WHERE id IN :ids").bindparams( + bindparam("ids", expanding=True) + ) + await self.execute(statement, {"ids": remove_ids}) + for item in plugins: + plugin_id = str(item.get("plugin_id") or "") + params = {"agent_id": agent_id, "plugin_id": plugin_id, "param_info": item.get("param_info") or "{}"} + if plugin_id in by_plugin: + await self.execute( + "UPDATE ai_agent_plugin_mapping SET param_info=:param_info WHERE id=:id", + {"id": by_plugin[plugin_id], **params}, + ) + else: + await self.execute( + "INSERT INTO ai_agent_plugin_mapping (id,agent_id,plugin_id,param_info)" + " VALUES (:id,:agent_id,:plugin_id,:param_info)", + {"id": int(item["id"]), **params}, + ) + + async def delete_plugins(self, agent_id: str) -> int: + return await self.execute("DELETE FROM ai_agent_plugin_mapping WHERE agent_id=:id", {"id": agent_id}) + + async def get_context_provider(self, agent_id: str) -> dict[str, Any] | None: + return await self.fetch_one( + "SELECT id,agent_id,context_providers,creator,created_at,updater,updated_at" + " FROM ai_agent_context_provider WHERE agent_id=:id LIMIT 1", + {"id": agent_id}, + ) + + async def upsert_context_provider(self, agent_id: str, encoded: str, new_id: str) -> None: + existing = await self.get_context_provider(agent_id) + if existing: + await self.execute( + "UPDATE ai_agent_context_provider SET context_providers=:value WHERE id=:id", + {"value": encoded, "id": existing["id"]}, + ) + else: + await self.execute( + "INSERT INTO ai_agent_context_provider (id,agent_id,context_providers)" + " VALUES (:id,:agent_id,:value)", + {"id": new_id, "agent_id": agent_id, "value": encoded}, + ) + + async def get_correct_word_ids(self, agent_id: str) -> list[str]: + rows = await self.fetch_all( + "SELECT file_id FROM ai_agent_correct_word_mapping WHERE agent_id=:id", {"id": agent_id} + ) + return [str(row["file_id"]) for row in rows] + + async def replace_correct_words( + self, agent_id: str, file_ids: Sequence[str], user_id: int, now: datetime, ids: Sequence[str] + ) -> None: + await self.execute("DELETE FROM ai_agent_correct_word_mapping WHERE agent_id=:id", {"id": agent_id}) + await self.execute_many( + "INSERT INTO ai_agent_correct_word_mapping" + " (id,agent_id,file_id,creator,created_at,updater,updated_at)" + " VALUES (:id,:agent_id,:file_id,:user_id,:now,:user_id,:now)", + [ + {"id": mapping_id, "agent_id": agent_id, "file_id": file_id, "user_id": user_id, "now": now} + for mapping_id, file_id in zip(ids, file_ids, strict=True) + ], + ) + + async def get_agent_tags(self, agent_id: str) -> list[dict[str, Any]]: + return await self.fetch_all( + "SELECT t.id,t.tag_name,t.sort,r.sort AS relation_sort" + " FROM ai_agent_tag t JOIN ai_agent_tag_relation r ON t.id=r.tag_id" + " WHERE r.agent_id=:id AND t.deleted=0 ORDER BY r.sort ASC,r.created_at ASC", + {"id": agent_id}, + ) + + async def get_tags_for_agents(self, agent_ids: Sequence[str]) -> list[dict[str, Any]]: + if not agent_ids: + return [] + statement = text( + "SELECT t.id,t.tag_name,r.agent_id,r.sort AS relation_sort" + " FROM ai_agent_tag t JOIN ai_agent_tag_relation r ON t.id=r.tag_id" + " WHERE r.agent_id IN :ids AND t.deleted=0 ORDER BY r.sort ASC,r.created_at ASC" + ).bindparams(bindparam("ids", expanding=True)) + return await self.fetch_all(statement, {"ids": list(agent_ids)}) + + async def list_tags(self) -> list[dict[str, Any]]: + return await self.fetch_all("SELECT id,tag_name,sort FROM ai_agent_tag WHERE deleted=0 ORDER BY sort ASC") + + async def get_tag(self, tag_id: str) -> dict[str, Any] | None: + return await self.fetch_one("SELECT * FROM ai_agent_tag WHERE id=:id", {"id": tag_id}) + + async def find_active_tag_by_name(self, tag_name: str) -> dict[str, Any] | None: + return await self.fetch_one( + "SELECT * FROM ai_agent_tag WHERE tag_name=:name AND deleted=0 LIMIT 1", {"name": tag_name} + ) + + async def find_any_tag_by_name(self, tag_name: str) -> dict[str, Any] | None: + return await self.fetch_one("SELECT * FROM ai_agent_tag WHERE tag_name=:name LIMIT 1", {"name": tag_name}) + + async def insert_tag(self, values: Mapping[str, Any]) -> int: + return await self.execute( + "INSERT INTO ai_agent_tag" + " (id,tag_name,sort,deleted,creator,created_at,updater,updated_at)" + " VALUES (:id,:tag_name,:sort,:deleted,:creator,:created_at,:updater,:updated_at)", + values, + ) + + async def soft_delete_tag(self, tag_id: str, now: datetime) -> int: + return await self.execute( + "UPDATE ai_agent_tag SET deleted=1,updated_at=:now WHERE id=:id", + {"id": tag_id, "now": now}, + ) + + async def replace_tag_relations(self, agent_id: str, relations: Sequence[Mapping[str, Any]]) -> None: + await self.execute("DELETE FROM ai_agent_tag_relation WHERE agent_id=:id", {"id": agent_id}) + await self.execute_many( + "INSERT INTO ai_agent_tag_relation" + " (id,agent_id,tag_id,sort,creator,created_at,updater,updated_at)" + " VALUES (:id,:agent_id,:tag_id,:sort,:creator,:created_at,:updater,:updated_at)", + relations, + ) + + async def get_model_config(self, model_id: str | None) -> dict[str, Any] | None: + if not model_id: + return None + return await self.fetch_one("SELECT * FROM ai_model_config WHERE id=:id", {"id": model_id}) + + async def get_default_llm_config(self) -> dict[str, Any] | None: + return await self.fetch_one( + "SELECT * FROM ai_model_config WHERE model_type='LLM' AND is_enabled=1" + " ORDER BY is_default DESC,sort ASC LIMIT 1" + ) + + async def get_model_provider(self, provider_id: str) -> dict[str, Any] | None: + return await self.fetch_one("SELECT * FROM ai_model_provider WHERE id=:id", {"id": provider_id}) + + async def get_timbre(self, timbre_id: str | None) -> dict[str, Any] | None: + if not timbre_id: + return None + row = await self.fetch_one("SELECT * FROM ai_tts_voice WHERE id=:id", {"id": timbre_id}) + if row is None: + row = await self.fetch_one("SELECT * FROM ai_voice_clone WHERE id=:id", {"id": timbre_id}) + return row + + async def find_timbre_by_voice_code(self, model_id: str, voice_code: str) -> dict[str, Any] | None: + return await self.fetch_one( + "SELECT * FROM ai_tts_voice WHERE tts_model_id=:model_id AND tts_voice=:voice LIMIT 1", + {"model_id": model_id, "voice": voice_code}, + ) + + async def get_device_by_mac(self, mac_address: str) -> dict[str, Any] | None: + return await self.fetch_one( + "SELECT * FROM ai_device WHERE mac_address=:mac ORDER BY id DESC LIMIT 1", {"mac": mac_address} + ) + + async def get_agent_by_device_mac(self, mac_address: str) -> dict[str, Any] | None: + return await self.fetch_one( + f"SELECT {', '.join('a.' + column for column in AGENT_COLUMNS)}" + " FROM ai_device d LEFT JOIN ai_agent a ON d.agent_id=a.id" + " WHERE d.mac_address=:mac ORDER BY d.id DESC LIMIT 1", + {"mac": mac_address}, + ) + + async def update_device_connection(self, device_id: str, now: datetime) -> int: + return await self.execute( + "UPDATE ai_device SET last_connected_at=:now WHERE id=:id", {"id": device_id, "now": now} + ) + + async def insert_chat_audio(self, audio_id: str, audio: bytes) -> int: + return await self.execute( + "INSERT INTO ai_agent_chat_audio (id,audio) VALUES (:id,:audio)", {"id": audio_id, "audio": audio} + ) + + async def get_chat_audio(self, audio_id: str) -> bytes | None: + value = await self.scalar("SELECT audio FROM ai_agent_chat_audio WHERE id=:id", {"id": audio_id}) + return bytes(value) if value is not None else None + + async def insert_chat_history(self, values: Mapping[str, Any]) -> int: + return await self.execute( + "INSERT INTO ai_agent_chat_history" + " (mac_address,agent_id,session_id,chat_type,content,audio_id,created_at)" + " VALUES (:mac_address,:agent_id,:session_id,:chat_type,:content,:audio_id,:created_at)", + values, + ) + + async def get_session_agent_id(self, session_id: str) -> str | None: + value = await self.scalar( + "SELECT agent_id FROM ai_agent_chat_history WHERE session_id=:id LIMIT 1", {"id": session_id} + ) + return str(value) if value is not None else None + + async def get_audio_agent_id(self, audio_id: str) -> str | None: + value = await self.scalar( + "SELECT agent_id FROM ai_agent_chat_history WHERE audio_id=:id LIMIT 1", {"id": audio_id} + ) + return str(value) if value is not None else None + + async def is_audio_owned(self, audio_id: str, agent_id: str) -> bool: + count = await self.scalar( + "SELECT COUNT(*) FROM ai_agent_chat_history WHERE audio_id=:audio_id AND agent_id=:agent_id", + {"audio_id": audio_id, "agent_id": agent_id}, + ) + return int(count or 0) == 1 + + async def get_audio_content(self, audio_id: str) -> str | None: + value = await self.scalar( + "SELECT content FROM ai_agent_chat_history WHERE audio_id=:id LIMIT 1", {"id": audio_id} + ) + return str(value) if value is not None else None + + async def get_chat_history(self, agent_id: str, session_id: str) -> list[dict[str, Any]]: + return await self.fetch_all( + "SELECT created_at,chat_type,content,audio_id,mac_address" + " FROM ai_agent_chat_history WHERE agent_id=:agent_id AND session_id=:session_id" + " ORDER BY created_at ASC", + {"agent_id": agent_id, "session_id": session_id}, + ) + + async def get_recent_user_history(self, agent_id: str) -> list[dict[str, Any]]: + return await self.fetch_all( + "SELECT content,audio_id FROM ai_agent_chat_history" + " WHERE agent_id=:id AND chat_type=1 AND audio_id IS NOT NULL ORDER BY id DESC LIMIT 50", + {"id": agent_id}, + ) + + async def list_sessions(self, agent_id: str, page: int, limit: int) -> tuple[list[dict[str, Any]], int]: + total = int( + await self.scalar( + "SELECT COUNT(*) FROM (SELECT session_id FROM ai_agent_chat_history" + " WHERE agent_id=:id GROUP BY session_id) sessions", + {"id": agent_id}, + ) + or 0 + ) + rows = await self.fetch_all( + "SELECT h.session_id,MAX(h.created_at) AS created_at,COUNT(*) AS chat_count," + " (SELECT t.title FROM ai_agent_chat_title t WHERE t.session_id=h.session_id LIMIT 1) AS title" + " FROM ai_agent_chat_history h WHERE h.agent_id=:id GROUP BY h.session_id" + " ORDER BY created_at DESC LIMIT :limit OFFSET :offset", + {"id": agent_id, "limit": limit, "offset": (page - 1) * limit}, + ) + return rows, total + + async def upsert_chat_title(self, session_id: str, title: str, now: datetime, title_id: str) -> None: + existing = await self.fetch_one( + "SELECT id FROM ai_agent_chat_title WHERE session_id=:session_id LIMIT 1", {"session_id": session_id} + ) + if existing: + await self.execute( + "UPDATE ai_agent_chat_title SET title=:title,updated_at=:now WHERE id=:id", + {"id": existing["id"], "title": title, "now": now}, + ) + else: + await self.execute( + "INSERT INTO ai_agent_chat_title (id,session_id,title,created_at,updated_at)" + " VALUES (:id,:session_id,:title,:now,:now)", + {"id": title_id, "session_id": session_id, "title": title, "now": now}, + ) + + async def delete_chat_history(self, agent_id: str, *, delete_audio: bool, delete_text: bool) -> None: + if delete_audio: + ids = await self.fetch_all( + "SELECT DISTINCT audio_id FROM ai_agent_chat_history WHERE agent_id=:id AND audio_id IS NOT NULL", + {"id": agent_id}, + ) + audio_ids = [str(row["audio_id"]) for row in ids] + for offset in range(0, len(audio_ids), 1000): + batch = audio_ids[offset : offset + 1000] + statement = text("DELETE FROM ai_agent_chat_audio WHERE id IN :ids").bindparams( + bindparam("ids", expanding=True) + ) + await self.execute(statement, {"ids": batch}) + if delete_audio and not delete_text: + await self.execute("UPDATE ai_agent_chat_history SET audio_id=NULL WHERE agent_id=:id", {"id": agent_id}) + if delete_text: + await self.execute("DELETE FROM ai_agent_chat_history WHERE agent_id=:id", {"id": agent_id}) + + async def delete_agent_cascade(self, agent_id: str) -> None: + devices = await self.fetch_all("SELECT mac_address FROM ai_device WHERE agent_id=:id", {"id": agent_id}) + macs = [str(row["mac_address"]) for row in devices if row.get("mac_address") is not None] + await self.execute("DELETE FROM ai_device WHERE agent_id=:id", {"id": agent_id}) + if macs: + statement = text( + "DELETE FROM ai_device_address_book WHERE mac_address IN :macs OR target_mac IN :targets" + ).bindparams(bindparam("macs", expanding=True), bindparam("targets", expanding=True)) + await self.execute(statement, {"macs": macs, "targets": macs}) + await self.delete_chat_history(agent_id, delete_audio=True, delete_text=True) + for table in ( + "ai_agent_plugin_mapping", + "ai_agent_context_provider", + "ai_agent_correct_word_mapping", + "ai_agent_tag_relation", + "ai_agent_snapshot", + ): + await self.execute(f"DELETE FROM {table} WHERE agent_id=:id", {"id": agent_id}) + await self.execute("DELETE FROM ai_agent WHERE id=:id", {"id": agent_id}) + + async def list_templates( + self, *, name: str | None = None, page: int | None = None, limit: int | None = None + ) -> tuple[list[dict[str, Any]], int]: + params: dict[str, Any] = {} + where = "" + if name: + where = " WHERE agent_name LIKE :name" + params["name"] = f"%{name}%" + total = int(await self.scalar(f"SELECT COUNT(*) FROM ai_agent_template{where}", params) or 0) + paging = "" + if page is not None and limit is not None: + params.update(limit=limit, offset=(page - 1) * limit) + paging = " LIMIT :limit OFFSET :offset" + rows = await self.fetch_all(f"SELECT * FROM ai_agent_template{where} ORDER BY sort ASC{paging}", params) + return rows, total + + async def get_template(self, template_id: str) -> dict[str, Any] | None: + return await self.fetch_one("SELECT * FROM ai_agent_template WHERE id=:id", {"id": template_id}) + + async def get_default_template(self) -> dict[str, Any] | None: + return await self.fetch_one("SELECT * FROM ai_agent_template ORDER BY sort ASC LIMIT 1") + + async def next_template_sort(self) -> int: + rows = await self.fetch_all("SELECT sort FROM ai_agent_template WHERE sort IS NOT NULL ORDER BY sort ASC") + expected = 1 + for row in rows: + value = int(row["sort"]) + if value > expected: + return expected + expected = value + 1 + return expected + + async def insert_template(self, values: Mapping[str, Any]) -> int: + columns = [column for column in TEMPLATE_COLUMNS if column in values] + return await self.execute( + f"INSERT INTO ai_agent_template ({', '.join(columns)})" + f" VALUES ({', '.join(':' + column for column in columns)})", + {column: values[column] for column in columns}, + ) + + async def update_template(self, template_id: str, values: Mapping[str, Any]) -> int: + selected = { + key: value for key, value in values.items() if key in TEMPLATE_COLUMNS and key != "id" and value is not None + } + if not selected: + return 0 + return await self.execute( + f"UPDATE ai_agent_template SET {', '.join(key + '=:' + key for key in selected)} WHERE id=:id", + {**selected, "id": template_id}, + ) + + async def delete_template(self, template_id: str) -> int: + return await self.execute("DELETE FROM ai_agent_template WHERE id=:id", {"id": template_id}) + + async def reorder_templates(self, deleted_sort: int) -> int: + return await self.execute("UPDATE ai_agent_template SET sort=sort-1 WHERE sort>:sort", {"sort": deleted_sort}) + + async def delete_templates(self, ids: Sequence[str]) -> int: + if not ids: + return 0 + statement = text("DELETE FROM ai_agent_template WHERE id IN :ids").bindparams(bindparam("ids", expanding=True)) + return await self.execute(statement, {"ids": list(ids)}) + + async def list_voiceprints(self, agent_id: str, user_id: int) -> list[dict[str, Any]]: + return await self.fetch_all( + "SELECT id,audio_id,source_name,introduce,create_date" + " FROM ai_agent_voice_print WHERE agent_id=:agent_id AND creator=:user_id", + {"agent_id": agent_id, "user_id": user_id}, + ) + + async def list_voiceprint_ids(self, agent_id: str) -> list[str]: + rows = await self.fetch_all("SELECT id FROM ai_agent_voice_print WHERE agent_id=:id", {"id": agent_id}) + return [str(row["id"]) for row in rows] + + async def get_voiceprint(self, voiceprint_id: str, user_id: int | None = None) -> dict[str, Any] | None: + where = "id=:id" + params: dict[str, Any] = {"id": voiceprint_id} + if user_id is not None: + where += " AND creator=:user_id" + params["user_id"] = user_id + return await self.fetch_one(f"SELECT * FROM ai_agent_voice_print WHERE {where} LIMIT 1", params) + + async def insert_voiceprint(self, values: Mapping[str, Any]) -> int: + return await self.execute( + "INSERT INTO ai_agent_voice_print" + " (id,agent_id,audio_id,source_name,introduce,creator,create_date,updater,update_date)" + " VALUES (:id,:agent_id,:audio_id,:source_name,:introduce,:creator,:create_date,:updater,:update_date)", + values, + ) + + async def update_voiceprint(self, voiceprint_id: str, user_id: int, values: Mapping[str, Any]) -> int: + allowed = {"audio_id", "source_name", "introduce", "updater", "update_date"} + selected = {key: value for key, value in values.items() if key in allowed and value is not None} + if not selected: + return 0 + return await self.execute( + f"UPDATE ai_agent_voice_print SET {', '.join(key + '=:' + key for key in selected)}" + " WHERE id=:id AND creator=:user_id", + {**selected, "id": voiceprint_id, "user_id": user_id}, + ) + + async def delete_voiceprint(self, voiceprint_id: str, user_id: int) -> int: + return await self.execute( + "DELETE FROM ai_agent_voice_print WHERE id=:id AND creator=:user_id", + {"id": voiceprint_id, "user_id": user_id}, + ) + + async def snapshot_max_version(self, agent_id: str) -> int: + return int( + await self.scalar( + "SELECT COALESCE(MAX(version_no),0) FROM ai_agent_snapshot WHERE agent_id=:id", {"id": agent_id} + ) + or 0 + ) + + async def latest_snapshot(self, agent_id: str) -> dict[str, Any] | None: + return await self.fetch_one( + "SELECT * FROM ai_agent_snapshot WHERE agent_id=:id ORDER BY version_no DESC LIMIT 1", {"id": agent_id} + ) + + async def next_snapshot(self, agent_id: str, version_no: int) -> dict[str, Any] | None: + return await self.fetch_one( + "SELECT * FROM ai_agent_snapshot WHERE agent_id=:id AND version_no>:version" + " ORDER BY version_no ASC LIMIT 1", + {"id": agent_id, "version": version_no}, + ) + + async def get_snapshot(self, snapshot_id: str) -> dict[str, Any] | None: + return await self.fetch_one("SELECT * FROM ai_agent_snapshot WHERE id=:id", {"id": snapshot_id}) + + async def list_snapshots( + self, agent_id: str, page: int, limit: int, max_version_no: int | None + ) -> tuple[list[dict[str, Any]], int]: + params: dict[str, Any] = {"id": agent_id, "limit": limit, "offset": (page - 1) * limit} + extra = "" + if max_version_no is not None: + extra = " AND version_no<=:max_version" + params["max_version"] = max_version_no + total = int(await self.scalar(f"SELECT COUNT(*) FROM ai_agent_snapshot WHERE agent_id=:id{extra}", params) or 0) + rows = await self.fetch_all( + f"SELECT * FROM ai_agent_snapshot WHERE agent_id=:id{extra}" + " ORDER BY version_no DESC LIMIT :limit OFFSET :offset", + params, + ) + return rows, total + + async def insert_snapshot_next_version(self, values: Mapping[str, Any]) -> int: + return await self.execute( + "INSERT INTO ai_agent_snapshot" + " (id,agent_id,user_id,version_no,snapshot_data,changed_fields,source," + " restore_from_snapshot_id,restore_from_version_no,creator,created_at,redaction_version)" + " SELECT :id,:agent_id,:user_id,COALESCE(MAX(version_no),0)+1,:snapshot_data,:changed_fields,:source," + " :restore_from_snapshot_id,:restore_from_version_no,:creator,:created_at,:redaction_version" + " FROM ai_agent_snapshot WHERE agent_id=:agent_id", + values, + ) + + async def prune_snapshots(self, agent_id: str, keep: int) -> int: + rows = await self.fetch_all( + "SELECT id FROM ai_agent_snapshot WHERE agent_id=:id ORDER BY version_no DESC LIMIT :keep", + {"id": agent_id, "keep": keep}, + ) + retained = [str(row["id"]) for row in rows] + if not retained: + return 0 + statement = text("DELETE FROM ai_agent_snapshot WHERE agent_id=:agent_id AND id NOT IN :retained").bindparams( + bindparam("retained", expanding=True) + ) + return await self.execute(statement, {"agent_id": agent_id, "retained": retained}) + + async def delete_snapshot(self, snapshot_id: str) -> int: + return await self.execute("DELETE FROM ai_agent_snapshot WHERE id=:id", {"id": snapshot_id}) + + async def list_legacy_snapshots(self, after_id: str | None, limit: int, version: int) -> list[dict[str, Any]]: + params: dict[str, Any] = {"version": version, "limit": limit} + extra = "" + if after_id is not None: + extra = " AND id>:after_id" + params["after_id"] = after_id + return await self.fetch_all( + f"SELECT id,snapshot_data,redaction_version FROM ai_agent_snapshot" + f" WHERE redaction_version<:version{extra} ORDER BY id ASC LIMIT :limit", + params, + ) + + async def update_redacted_snapshot(self, snapshot_id: str, snapshot_data: str, version: int) -> int: + return await self.execute( + "UPDATE ai_agent_snapshot SET snapshot_data=:data,redaction_version=:version" + " WHERE id=:id AND redaction_version<:version", + {"id": snapshot_id, "data": snapshot_data, "version": version}, + ) diff --git a/main/manager-api-fastapi/app/repositories/config.py b/main/manager-api-fastapi/app/repositories/config.py new file mode 100644 index 00000000..89ebcaa8 --- /dev/null +++ b/main/manager-api-fastapi/app/repositories/config.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import Repository + + +class ConfigRepository(Repository): + def __init__(self, session: AsyncSession): + super().__init__(session) + + async def list_params(self) -> list[dict[str, Any]]: + return await self.fetch_all( + "SELECT param_code, param_value, value_type FROM sys_params WHERE param_type = 1" + ) + + async def get_param_value(self, code: str) -> str | None: + value = await self.scalar( + "SELECT param_value FROM sys_params WHERE param_code = :code LIMIT 1", + {"code": code}, + ) + return None if value is None else str(value) + + async def get_default_template(self) -> dict[str, Any] | None: + return await self.fetch_one( + "SELECT id, agent_code, agent_name, asr_model_id, vad_model_id, llm_model_id, " + "vllm_model_id, tts_model_id, tts_voice_id, tts_language, tts_volume, tts_rate, tts_pitch, " + "mem_model_id, intent_model_id, chat_history_conf, system_prompt, summary_memory, lang_code, " + "language, sort FROM ai_agent_template ORDER BY sort ASC LIMIT 1" + ) + + async def get_device_by_mac(self, mac_address: str) -> dict[str, Any] | None: + return await self.fetch_one( + "SELECT id, user_id, mac_address, board, agent_id, app_version, auto_update " + "FROM ai_device WHERE mac_address = :mac_address LIMIT 1", + {"mac_address": mac_address}, + ) + + async def get_agent(self, agent_id: str) -> dict[str, Any] | None: + return await self.fetch_one( + "SELECT id, user_id, agent_code, agent_name, asr_model_id, vad_model_id, llm_model_id, slm_model_id, " + "vllm_model_id, tts_model_id, tts_voice_id, tts_language, tts_volume, tts_rate, tts_pitch, " + "mem_model_id, intent_model_id, chat_history_conf, system_prompt, summary_memory, lang_code, language " + "FROM ai_agent WHERE id = :id LIMIT 1", + {"id": agent_id}, + ) + + async def get_model(self, model_id: str) -> dict[str, Any] | None: + return await self.fetch_one( + "SELECT id, model_type, model_code, model_name, is_default, is_enabled, config_json, doc_link, " + "remark, sort, creator, create_date, updater, update_date " + "FROM ai_model_config WHERE id = :id LIMIT 1", + {"id": model_id}, + ) + + async def get_timbre(self, timbre_id: str) -> dict[str, Any] | None: + return await self.fetch_one( + "SELECT id, languages, name, remark, reference_audio, reference_text, sort, tts_model_id, " + "tts_voice, voice_demo FROM ai_tts_voice WHERE id = :id LIMIT 1", + {"id": timbre_id}, + ) + + async def get_voice_clone(self, clone_id: str) -> dict[str, Any] | None: + return await self.fetch_one( + "SELECT id, name, model_id, voice_id, languages, user_id, train_status, train_error " + "FROM ai_voice_clone WHERE id = :id LIMIT 1", + {"id": clone_id}, + ) + + async def get_plugin_mappings(self, agent_id: str) -> list[dict[str, Any]]: + return await self.fetch_all( + "SELECT m.id, m.agent_id, m.plugin_id, m.param_info, " + "(SELECT p.provider_code FROM ai_model_provider p WHERE p.id = m.plugin_id LIMIT 1) AS provider_code " + "FROM ai_agent_plugin_mapping m WHERE m.agent_id = :agent_id", + {"agent_id": agent_id}, + ) + + async def get_dataset(self, dataset_id: str) -> dict[str, Any] | None: + return await self.fetch_one( + "SELECT id, dataset_id, rag_model_id, name, description, status " + "FROM ai_rag_dataset WHERE id = :id LIMIT 1", + {"id": dataset_id}, + ) + + async def get_context_providers(self, agent_id: str) -> Any: + return await self.scalar( + "SELECT context_providers FROM ai_agent_context_provider WHERE agent_id = :agent_id LIMIT 1", + {"agent_id": agent_id}, + ) + + async def get_voiceprints(self, agent_id: str) -> list[dict[str, Any]]: + return await self.fetch_all( + "SELECT id, agent_id, source_name, introduce, create_date " + "FROM ai_agent_voice_print WHERE agent_id = :agent_id ORDER BY create_date ASC", + {"agent_id": agent_id}, + ) + + async def get_correct_word_items(self, agent_id: str) -> list[dict[str, Any]]: + return await self.fetch_all( + "SELECT i.source_word, i.target_word FROM ai_agent_correct_word_mapping m " + "JOIN ai_agent_correct_word_item i ON i.file_id = m.file_id WHERE m.agent_id = :agent_id", + {"agent_id": agent_id}, + ) diff --git a/main/manager-api-fastapi/app/repositories/correctword.py b/main/manager-api-fastapi/app/repositories/correctword.py new file mode 100644 index 00000000..5a11f16c --- /dev/null +++ b/main/manager-api-fastapi/app/repositories/correctword.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import Repository + + +class CorrectWordRepository(Repository): + def __init__(self, session: AsyncSession): + super().__init__(session) + + async def name_exists(self, user_id: int, file_name: str, exclude_id: str | None = None) -> bool: + return bool( + await self.scalar( + "SELECT COUNT(*) FROM ai_agent_correct_word_file WHERE creator=:creator AND file_name=:file_name " + "AND (:exclude_id IS NULL OR id<>:exclude_id)", + {"creator": user_id, "file_name": file_name, "exclude_id": exclude_id}, + ) + ) + + async def insert_file(self, values: dict[str, Any]) -> None: + await self.execute( + "INSERT INTO ai_agent_correct_word_file " + "(id, file_name, word_count, content, creator, created_at) " + "VALUES (:id, :file_name, :word_count, :content, :creator, :now)", + values, + ) + + async def insert_items(self, values: Sequence[dict[str, Any]]) -> None: + await self.execute_many( + "INSERT INTO ai_agent_correct_word_item (id, file_id, source_word, target_word) " + "VALUES (:id, :file_id, :source_word, :target_word)", + values, + ) + + async def get_file(self, file_id: str, *, for_update: bool = False) -> dict[str, Any] | None: + suffix = " FOR UPDATE" if for_update and self.session.get_bind().dialect.name != "sqlite" else "" + return await self.fetch_one( + f"SELECT * FROM ai_agent_correct_word_file WHERE id=:id{suffix}", # noqa: S608 + {"id": file_id}, + ) + + async def update_file(self, values: dict[str, Any]) -> int: + return await self.execute( + "UPDATE ai_agent_correct_word_file SET file_name=:file_name, word_count=:word_count, " + "content=:content, updater=:updater, updated_at=:now WHERE id=:id", + values, + ) + + async def list_files( + self, user_id: int, *, offset: int | None = None, limit: int | None = None + ) -> tuple[list[dict[str, Any]], int]: + total = int( + await self.scalar( + "SELECT COUNT(*) FROM ai_agent_correct_word_file WHERE creator=:creator", {"creator": user_id} + ) + or 0 + ) + if offset is None or limit is None: + rows = await self.fetch_all( + "SELECT * FROM ai_agent_correct_word_file WHERE creator=:creator ORDER BY created_at DESC", + {"creator": user_id}, + ) + else: + rows = await self.fetch_all( + "SELECT * FROM ai_agent_correct_word_file WHERE creator=:creator ORDER BY created_at DESC " + "LIMIT :offset, :limit", + {"creator": user_id, "offset": offset, "limit": limit}, + ) + return rows, total + + async def delete_file_graph(self, file_id: str) -> None: + await self.execute("DELETE FROM ai_agent_correct_word_mapping WHERE file_id=:id", {"id": file_id}) + await self.execute("DELETE FROM ai_agent_correct_word_item WHERE file_id=:id", {"id": file_id}) + await self.execute("DELETE FROM ai_agent_correct_word_file WHERE id=:id", {"id": file_id}) + + async def delete_items(self, file_id: str) -> None: + await self.execute("DELETE FROM ai_agent_correct_word_item WHERE file_id=:id", {"id": file_id}) + + async def items_for_agent(self, agent_id: str) -> list[dict[str, Any]]: + return await self.fetch_all( + "SELECT i.source_word, i.target_word FROM ai_agent_correct_word_item i " + "JOIN ai_agent_correct_word_mapping m ON m.file_id=i.file_id WHERE m.agent_id=:agent_id", + {"agent_id": agent_id}, + ) + + async def file_ids_for_agent(self, agent_id: str) -> list[str]: + rows = await self.fetch_all( + "SELECT file_id FROM ai_agent_correct_word_mapping WHERE agent_id=:agent_id", {"agent_id": agent_id} + ) + return [str(row["file_id"]) for row in rows] + + async def replace_agent_mappings( + self, agent_id: str, file_ids: Sequence[str], user_id: int, now: Any + ) -> None: + await self.execute("DELETE FROM ai_agent_correct_word_mapping WHERE agent_id=:agent_id", {"agent_id": agent_id}) + await self.execute_many( + "INSERT INTO ai_agent_correct_word_mapping " + "(id, agent_id, file_id, creator, created_at, updater, updated_at) " + "VALUES (:id, :agent_id, :file_id, :user_id, :now, :user_id, :now)", + [ + { + "id": uuid.uuid4().hex, + "agent_id": agent_id, + "file_id": file_id, + "user_id": user_id, + "now": now, + } + for file_id in file_ids + ], + ) diff --git a/main/manager-api-fastapi/app/repositories/device.py b/main/manager-api-fastapi/app/repositories/device.py new file mode 100644 index 00000000..0120a8f5 --- /dev/null +++ b/main/manager-api-fastapi/app/repositories/device.py @@ -0,0 +1,294 @@ +from __future__ import annotations + +# Every interpolated SQL fragment below is a module constant or a service-side allowlist. +# ruff: noqa: S608 +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import Repository + +DEVICE_COLUMNS = ( + "id, user_id, mac_address, last_connected_at, auto_update, board, alias, " + "agent_id, app_version, sort, updater, update_date, creator, create_date" +) +OTA_COLUMNS = ( + "id, firmware_name, type, version, size, remark, firmware_path, sort, " + "updater, update_date, creator, create_date" +) +ADDRESS_BOOK_COLUMNS = ( + "mac_address, target_mac, alias, has_permission, creator, create_date, updater, update_date" +) + + +class DeviceRepository(Repository): + def __init__(self, session: AsyncSession): + super().__init__(session) + + async def get_device(self, device_id: str) -> dict[str, Any] | None: + return await self.fetch_one( + f"SELECT {DEVICE_COLUMNS} FROM ai_device WHERE id = :id LIMIT 1", + {"id": device_id}, + ) + + async def get_device_by_mac(self, mac_address: str) -> dict[str, Any] | None: + return await self.fetch_one( + f"SELECT {DEVICE_COLUMNS} FROM ai_device WHERE mac_address = :mac_address LIMIT 1", + {"mac_address": mac_address}, + ) + + async def get_user_devices(self, user_id: int, agent_id: str) -> list[dict[str, Any]]: + return await self.fetch_all( + f"SELECT {DEVICE_COLUMNS} FROM ai_device WHERE user_id = :user_id AND agent_id = :agent_id", + {"user_id": user_id, "agent_id": agent_id}, + ) + + async def insert_device(self, values: Mapping[str, Any]) -> None: + await self.execute( + "INSERT INTO ai_device " + "(id, user_id, mac_address, last_connected_at, auto_update, board, alias, agent_id, app_version, " + "sort, updater, update_date, creator, create_date) " + "VALUES (:id, :user_id, :mac_address, :last_connected_at, :auto_update, :board, :alias, :agent_id, " + ":app_version, :sort, :updater, :update_date, :creator, :create_date)", + values, + ) + + async def update_device_info( + self, + device_id: str, + *, + auto_update: int | None, + alias: str | None, + updater: int, + now: datetime, + ) -> int: + assignments = ["updater = :updater", "update_date = :now"] + params: dict[str, Any] = {"id": device_id, "updater": updater, "now": now} + if auto_update is not None: + assignments.append("auto_update = :auto_update") + params["auto_update"] = auto_update + if alias is not None: + assignments.append("alias = :alias") + params["alias"] = alias + return await self.execute( + f"UPDATE ai_device SET {', '.join(assignments)} WHERE id = :id", + params, + ) + + async def update_connection( + self, + device_id: str, + *, + app_version: str | None, + now: datetime, + ) -> int: + if app_version is None or not app_version.strip(): + return await self.execute( + "UPDATE ai_device SET last_connected_at = :now WHERE id = :id", + {"id": device_id, "now": now}, + ) + return await self.execute( + "UPDATE ai_device SET last_connected_at = :now, app_version = :app_version WHERE id = :id", + {"id": device_id, "now": now, "app_version": app_version}, + ) + + async def delete_device_for_user(self, device_id: str, user_id: int) -> int: + return await self.execute( + "DELETE FROM ai_device WHERE id = :id AND user_id = :user_id", + {"id": device_id, "user_id": user_id}, + ) + + async def get_address_book(self, mac_address: str) -> list[dict[str, Any]]: + return await self.fetch_all( + f"SELECT {ADDRESS_BOOK_COLUMNS} FROM ai_device_address_book " + "WHERE mac_address = :mac_address ORDER BY update_date DESC", + {"mac_address": mac_address}, + ) + + async def get_all_address_book(self) -> list[dict[str, Any]]: + return await self.fetch_all(f"SELECT {ADDRESS_BOOK_COLUMNS} FROM ai_device_address_book") + + async def get_address_book_record(self, mac_address: str, target_mac: str) -> dict[str, Any] | None: + return await self.fetch_one( + f"SELECT {ADDRESS_BOOK_COLUMNS} FROM ai_device_address_book " + "WHERE mac_address = :mac_address AND target_mac = :target_mac LIMIT 1", + {"mac_address": mac_address, "target_mac": target_mac}, + ) + + async def get_aliases(self, mac_address: str) -> list[str]: + rows = await self.fetch_all( + "SELECT alias FROM ai_device_address_book WHERE mac_address = :mac_address", + {"mac_address": mac_address}, + ) + return [str(row["alias"]) for row in rows if row.get("alias") not in (None, "")] + + async def insert_address_book( + self, + *, + mac_address: str, + target_mac: str, + alias: str | None, + has_permission: bool | None, + actor: int, + now: datetime, + ) -> None: + await self.execute( + "INSERT INTO ai_device_address_book " + "(mac_address, target_mac, alias, has_permission, creator, create_date, updater, update_date) " + "VALUES (:mac_address, :target_mac, :alias, :has_permission, :actor, :now, :actor, :now)", + { + "mac_address": mac_address, + "target_mac": target_mac, + "alias": alias, + "has_permission": has_permission, + "actor": actor, + "now": now, + }, + ) + + async def update_address_alias( + self, + mac_address: str, + target_mac: str, + alias: str | None, + *, + now: datetime, + ) -> int: + return await self.execute( + "UPDATE ai_device_address_book SET alias = :alias, update_date = :now " + "WHERE mac_address = :mac_address AND target_mac = :target_mac", + { + "mac_address": mac_address, + "target_mac": target_mac, + "alias": alias, + "now": now, + }, + ) + + async def update_address_permission( + self, + mac_address: str, + target_mac: str, + has_permission: bool, + *, + now: datetime, + ) -> int: + return await self.execute( + "UPDATE ai_device_address_book " + "SET has_permission = :has_permission, update_date = :now " + "WHERE mac_address = :mac_address AND target_mac = :target_mac", + { + "mac_address": mac_address, + "target_mac": target_mac, + "has_permission": has_permission, + "now": now, + }, + ) + + async def delete_address_books_for_macs(self, mac_addresses: Sequence[str]) -> int: + if not mac_addresses: + return 0 + placeholders = ", ".join(f":mac_{index}" for index in range(len(mac_addresses))) + params = {f"mac_{index}": mac for index, mac in enumerate(mac_addresses)} + return await self.execute( + f"DELETE FROM ai_device_address_book WHERE mac_address IN ({placeholders}) " + f"OR target_mac IN ({placeholders})", + params, + ) + + async def count_ota(self, firmware_name: str | None = None) -> int: + where = "" + params: dict[str, Any] = {} + if firmware_name is not None and firmware_name.strip(): + where = " WHERE firmware_name LIKE :firmware_name" + params["firmware_name"] = f"%{firmware_name}%" + return int(await self.scalar(f"SELECT COUNT(*) FROM ai_ota{where}", params) or 0) + + async def list_ota( + self, + *, + page: int, + limit: int, + firmware_name: str | None, + order_fields: Sequence[str], + ascending: bool, + ) -> list[dict[str, Any]]: + where = "" + params: dict[str, Any] = {"limit": limit, "offset": max(page - 1, 0) * limit} + if firmware_name is not None and firmware_name.strip(): + where = " WHERE firmware_name LIKE :firmware_name" + params["firmware_name"] = f"%{firmware_name}%" + direction = "ASC" if ascending else "DESC" + order_by = ", ".join(f"{field} {direction}" for field in order_fields) + return await self.fetch_all( + f"SELECT {OTA_COLUMNS} FROM ai_ota{where} ORDER BY {order_by} LIMIT :limit OFFSET :offset", + params, + ) + + async def get_ota(self, ota_id: str) -> dict[str, Any] | None: + return await self.fetch_one( + f"SELECT {OTA_COLUMNS} FROM ai_ota WHERE id = :id LIMIT 1", + {"id": ota_id}, + ) + + async def get_first_ota_by_type(self, ota_type: str) -> dict[str, Any] | None: + return await self.fetch_one( + f"SELECT {OTA_COLUMNS} FROM ai_ota WHERE type = :type LIMIT 1", + {"type": ota_type}, + ) + + async def get_latest_ota(self, ota_type: str) -> dict[str, Any] | None: + return await self.fetch_one( + f"SELECT {OTA_COLUMNS} FROM ai_ota WHERE type = :type ORDER BY update_date DESC LIMIT 1", + {"type": ota_type}, + ) + + async def count_duplicate_ota(self, *, ota_id: str, ota_type: str | None, version: str | None) -> int: + return int( + await self.scalar( + "SELECT COUNT(*) FROM ai_ota WHERE type = :type AND version = :version AND id <> :id", + {"id": ota_id, "type": ota_type, "version": version}, + ) + or 0 + ) + + async def insert_ota(self, values: Mapping[str, Any]) -> None: + await self.execute( + "INSERT INTO ai_ota " + "(id, firmware_name, type, version, size, remark, firmware_path, sort, updater, update_date, creator, " + "create_date) VALUES (:id, :firmware_name, :type, :version, :size, :remark, :firmware_path, :sort, " + ":updater, :update_date, :creator, :create_date)", + values, + ) + + async def update_ota(self, ota_id: str, values: Mapping[str, Any]) -> int: + allowed = { + "firmware_name", + "type", + "version", + "size", + "remark", + "firmware_path", + "sort", + "updater", + "update_date", + "creator", + "create_date", + } + selected = {key: value for key, value in values.items() if key in allowed and value is not None} + if not selected: + return 0 + assignments = ", ".join(f"{key} = :{key}" for key in selected) + return await self.execute( + f"UPDATE ai_ota SET {assignments} WHERE id = :id", + {"id": ota_id, **selected}, + ) + + async def delete_ota(self, ids: Sequence[str]) -> int: + if not ids: + return 0 + placeholders = ", ".join(f":id_{index}" for index in range(len(ids))) + params = {f"id_{index}": value for index, value in enumerate(ids)} + return await self.execute(f"DELETE FROM ai_ota WHERE id IN ({placeholders})", params) diff --git a/main/manager-api-fastapi/app/repositories/knowledge.py b/main/manager-api-fastapi/app/repositories/knowledge.py new file mode 100644 index 00000000..b61eb93a --- /dev/null +++ b/main/manager-api-fastapi/app/repositories/knowledge.py @@ -0,0 +1,344 @@ +from __future__ import annotations + +import json +import uuid +from collections.abc import Sequence +from datetime import datetime +from typing import Any +from zoneinfo import ZoneInfo + +from sqlalchemy import bindparam, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import get_settings +from app.core.database import Repository + + +class KnowledgeRepository(Repository): + def __init__(self, session: AsyncSession): + super().__init__(session) + + async def dataset_page( + self, user_id: int, name: str | None, offset: int, limit: int + ) -> tuple[list[dict[str, Any]], int]: + where = ( + "WHERE creator=:creator AND (:name IS NULL OR :name='' OR name LIKE CONCAT('%', :name, '%'))" + ) + params = {"creator": user_id, "name": name, "offset": offset, "limit": limit} + total = int(await self.scalar(f"SELECT COUNT(*) FROM ai_rag_dataset {where}", params) or 0) # noqa: S608 + rows = await self.fetch_all( + f"SELECT * FROM ai_rag_dataset {where} ORDER BY created_at DESC LIMIT :offset, :limit", # noqa: S608 + params, + ) + return rows, total + + async def get_dataset(self, identifier: str, *, for_update: bool = False) -> dict[str, Any] | None: + suffix = " FOR UPDATE" if for_update and self.session.get_bind().dialect.name != "sqlite" else "" + return await self.fetch_one( + f"SELECT * FROM ai_rag_dataset WHERE dataset_id=:id OR id=:id LIMIT 1{suffix}", # noqa: S608 + {"id": identifier}, + ) + + async def datasets_by_ids(self, identifiers: Sequence[str]) -> list[dict[str, Any]]: + if not identifiers: + return [] + statement = text("SELECT * FROM ai_rag_dataset WHERE dataset_id IN :ids OR id IN :ids").bindparams( + bindparam("ids", expanding=True) + ) + result = await self.session.execute(statement, {"ids": list(identifiers)}) + return [dict(row) for row in result.mappings().all()] + + async def duplicate_dataset_name(self, user_id: int, name: str, exclude_id: str | None = None) -> bool: + return bool( + await self.scalar( + "SELECT COUNT(*) FROM ai_rag_dataset WHERE creator=:creator AND name=:name " + "AND (:exclude_id IS NULL OR id<>:exclude_id)", + {"creator": user_id, "name": name, "exclude_id": exclude_id}, + ) + ) + + async def dataset_id_conflict(self, dataset_id: str, exclude_id: str) -> bool: + return bool( + await self.scalar( + "SELECT COUNT(*) FROM ai_rag_dataset WHERE dataset_id=:dataset_id AND id<>:exclude_id", + {"dataset_id": dataset_id, "exclude_id": exclude_id}, + ) + ) + + async def insert_dataset(self, values: dict[str, Any]) -> None: + await self.execute( + "INSERT INTO ai_rag_dataset " + "(id,dataset_id,rag_model_id,tenant_id,name,avatar,description,embedding_model,permission,chunk_method," + "parser_config,chunk_count,document_count,token_num,status,creator,created_at,updater,updated_at) VALUES " + "(:id,:dataset_id,:rag_model_id,:tenant_id,:name,:avatar,:description,:embedding_model,:permission," + ":chunk_method,:parser_config,:chunk_count,:document_count,:token_num,:status,:creator,:created_at," + ":updater,:updated_at)", + values, + ) + + async def update_dataset(self, values: dict[str, Any]) -> int: + return await self.execute( + "UPDATE ai_rag_dataset SET dataset_id=COALESCE(:dataset_id,dataset_id)," + "rag_model_id=COALESCE(:rag_model_id,rag_model_id),name=COALESCE(:name,name)," + "avatar=COALESCE(:avatar,avatar),description=COALESCE(:description,description)," + "embedding_model=COALESCE(:embedding_model,embedding_model)," + "permission=COALESCE(:permission,permission),chunk_method=COALESCE(:chunk_method,chunk_method)," + "parser_config=COALESCE(:parser_config,parser_config),chunk_count=COALESCE(:chunk_count,chunk_count)," + "token_num=COALESCE(:token_num,token_num),status=COALESCE(:status,status)," + "creator=COALESCE(:creator,creator),created_at=COALESCE(:created_at,created_at),updater=:updater," + "updated_at=:updated_at WHERE id=:id", + values, + ) + + async def delete_dataset_local(self, row: dict[str, Any]) -> None: + await self.execute("DELETE FROM ai_agent_plugin_mapping WHERE plugin_id=:id", {"id": row["id"]}) + await self.execute("DELETE FROM ai_rag_dataset WHERE id=:id", {"id": row["id"]}) + + async def rag_models(self) -> list[dict[str, Any]]: + return await self.fetch_all( + "SELECT id, model_name, config_json FROM ai_model_config WHERE model_type='RAG' AND is_enabled=1 " + "ORDER BY is_default DESC, create_date DESC" + ) + + async def rag_config(self, model_id: str) -> dict[str, Any]: + row = await self.fetch_one("SELECT config_json FROM ai_model_config WHERE id=:id", {"id": model_id}) + if row is None or row.get("config_json") is None: + from app.core.errors import AppError + + raise AppError(10164) + raw = row["config_json"] + if isinstance(raw, bytes): + raw = raw.decode("utf-8") + config = dict(raw) if isinstance(raw, dict) else dict(json.loads(str(raw))) + config.setdefault("type", "ragflow") + return config + + async def documents_page( + self, + dataset_id: str, + *, + name: str | None, + status: str | None, + offset: int, + limit: int, + ) -> tuple[list[dict[str, Any]], int]: + where = ( + "WHERE dataset_id=:dataset_id " + "AND (:name IS NULL OR :name='' OR name LIKE CONCAT('%', :name, '%')) " + "AND (:status IS NULL OR :status='' OR status=:status)" + ) + params = {"dataset_id": dataset_id, "name": name, "status": status, "offset": offset, "limit": limit} + total = int(await self.scalar(f"SELECT COUNT(*) FROM ai_rag_knowledge_document {where}", params) or 0) # noqa: S608 + rows = await self.fetch_all( + f"SELECT * FROM ai_rag_knowledge_document {where} " # noqa: S608 + "ORDER BY created_at DESC LIMIT :offset, :limit", + params, + ) + return rows, total + + async def all_documents(self, dataset_id: str) -> list[dict[str, Any]]: + return await self.fetch_all( + "SELECT * FROM ai_rag_knowledge_document WHERE dataset_id=:dataset_id", {"dataset_id": dataset_id} + ) + + async def documents_by_remote_ids(self, dataset_id: str, ids: Sequence[str]) -> list[dict[str, Any]]: + if not ids: + return [] + statement = text( + "SELECT * FROM ai_rag_knowledge_document WHERE dataset_id=:dataset_id AND document_id IN :ids" + ).bindparams(bindparam("ids", expanding=True)) + result = await self.session.execute(statement, {"dataset_id": dataset_id, "ids": list(ids)}) + return [dict(row) for row in result.mappings().all()] + + async def upsert_document(self, dataset_id: str, remote: dict[str, Any], *, creator: int | None = None) -> bool: + document_id = str(remote.get("id") or remote.get("document_id") or "") + existing = await self.fetch_one( + "SELECT id,created_at FROM ai_rag_knowledge_document WHERE document_id=:id", {"id": document_id} + ) + name = remote.get("name") + size = remote.get("size") + if size is None: + size = remote.get("file_size") + meta_fields = remote.get("meta_fields") + if meta_fields is None: + meta_fields = remote.get("meta") + error = remote.get("progress_msg") + if error is None: + error = remote.get("error") + synced_at = _shanghai_now_naive() + created_at = remote.get("created_at") + if not isinstance(created_at, datetime): + created_at = _millis_date(remote.get("create_time")) + updated_at = remote.get("updated_at") + if not isinstance(updated_at, datetime): + updated_at = _millis_date(remote.get("update_time")) + values = { + "id": existing["id"] if existing else uuid.uuid4().hex, + "dataset_id": remote.get("dataset_id") or dataset_id, + "document_id": document_id, + "name": name, + "size": size, + "type": _file_type(str(name or "")), + "chunk_method": remote.get("chunk_method"), + "parser_config": _json_dump(remote.get("parser_config")), + "status": _remote_status(remote.get("status")), + "run": remote.get("run"), + "progress": remote.get("progress"), + "thumbnail": remote.get("thumbnail"), + "process_duration": remote.get("process_duration"), + "meta_fields": _json_dump(meta_fields), + "source_type": remote.get("source_type"), + "error": error, + "chunk_count": remote.get("chunk_count") or 0, + "token_count": remote.get("token_count") or 0, + "enabled": 1, + "creator": creator, + "created_at": existing.get("created_at") if existing else (created_at or synced_at), + "updated_at": updated_at or synced_at, + "synced_at": synced_at, + } + if existing: + await self.execute( + "UPDATE ai_rag_knowledge_document SET dataset_id=:dataset_id,document_id=:document_id,name=:name," + "size=:size,type=:type,chunk_method=:chunk_method,parser_config=:parser_config,status=:status,run=:run," + "progress=:progress,thumbnail=:thumbnail,process_duration=:process_duration,meta_fields=:meta_fields," + "source_type=:source_type,error=:error,chunk_count=:chunk_count,token_count=:token_count,enabled=:enabled," + "updated_at=:updated_at,last_sync_at=:synced_at WHERE id=:id", + values, + ) + return False + await self.execute( + "INSERT INTO ai_rag_knowledge_document " + "(id,dataset_id,document_id,name,size,type,chunk_method,parser_config,status,run,progress,thumbnail," + "process_duration,meta_fields,source_type,error,chunk_count,token_count,enabled,creator,created_at,updated_at," + "last_sync_at) VALUES (:id,:dataset_id,:document_id,:name,:size,:type,:chunk_method,:parser_config,:status," + ":run,:progress,:thumbnail,:process_duration,:meta_fields,:source_type,:error,:chunk_count,:token_count," + ":enabled,:creator,COALESCE(:created_at,:synced_at),COALESCE(:updated_at,:synced_at),:synced_at)", + values, + ) + return True + + async def update_stats(self, dataset_id: str, docs: int, chunks: int, tokens: int) -> None: + await self.execute( + "UPDATE ai_rag_dataset SET document_count=document_count+:docs,chunk_count=chunk_count+:chunks," + "token_num=token_num+:tokens,updated_at=:now WHERE dataset_id=:dataset_id", + { + "dataset_id": dataset_id, + "docs": docs, + "chunks": chunks, + "tokens": tokens, + "now": _shanghai_now_naive(), + }, + ) + + async def delete_document_shadows(self, dataset_id: str, ids: Sequence[str]) -> int: + if not ids: + return 0 + statement = text( + "DELETE FROM ai_rag_knowledge_document WHERE dataset_id=:dataset_id AND document_id IN :ids" + ).bindparams(bindparam("ids", expanding=True)) + result = await self.session.execute(statement, {"dataset_id": dataset_id, "ids": list(ids)}) + return int(getattr(result, "rowcount", 0) or 0) + + async def mark_documents_running(self, dataset_id: str, ids: Sequence[str], now: datetime) -> int: + if not ids: + return 0 + statement = text( + "UPDATE ai_rag_knowledge_document SET run='RUNNING',status='1',updated_at=:now " + "WHERE dataset_id=:dataset_id AND document_id IN :ids" + ).bindparams(bindparam("ids", expanding=True)) + result = await self.session.execute(statement, {"dataset_id": dataset_id, "ids": list(ids), "now": now}) + return int(getattr(result, "rowcount", 0) or 0) + + async def mark_document_remote_deleted(self, document_id: str, now: datetime) -> int: + return await self.execute( + "UPDATE ai_rag_knowledge_document SET run='CANCEL',error=:error,updated_at=:now,last_sync_at=:now " + "WHERE document_id=:document_id", + { + "document_id": document_id, + "error": "文档在远程服务中已被删除", + "now": now, + }, + ) + + async def sync_running_document( + self, + dataset_id: str, + document_id: str, + remote: dict[str, Any], + now: datetime, + ) -> int: + """Update exactly the columns touched by Java's status-sync helper.""" + updated_at = _millis_date(remote.get("update_time")) or now + meta_fields = remote.get("meta_fields") + assignments = ( + "status=:status,run=:run,progress=:progress,chunk_count=:chunk_count,token_count=:token_count," + "error=:error,process_duration=:process_duration,thumbnail=:thumbnail,updated_at=:updated_at," + "last_sync_at=:now" + ) + if meta_fields is not None: + assignments += ",meta_fields=:meta_fields" + return await self.execute( + f"UPDATE ai_rag_knowledge_document SET {assignments} " # noqa: S608 + "WHERE document_id=:document_id AND dataset_id=:dataset_id", + { + "dataset_id": dataset_id, + "document_id": document_id, + "status": remote.get("status"), + "run": remote.get("run"), + "progress": remote.get("progress"), + "chunk_count": remote.get("chunk_count"), + "token_count": remote.get("token_count"), + "error": remote.get("progress_msg") if remote.get("progress_msg") is not None else remote.get("error"), + "process_duration": remote.get("process_duration"), + "thumbnail": remote.get("thumbnail"), + "meta_fields": _json_dump(meta_fields), + "updated_at": updated_at, + "now": now, + }, + ) + + async def running_documents(self) -> list[dict[str, Any]]: + return await self.fetch_all("SELECT * FROM ai_rag_knowledge_document WHERE run='RUNNING' AND status='1'") + + +def _json_dump(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, str): + return value + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + + +def _millis_date(value: Any) -> datetime | None: + if value is None: + return None + try: + timezone = ZoneInfo(get_settings().timezone) + return datetime.fromtimestamp(float(value) / 1000, timezone).replace(tzinfo=None) + except (TypeError, ValueError, OSError): + return None + + +def _file_type(name: str) -> str: + last_dot = name.rfind(".") + if last_dot <= 0 or last_dot == len(name) - 1: + return "unknown" + extension = name.rsplit(".", 1)[1].lower() + if extension in {"pdf", "doc", "docx", "txt", "md", "mdx"}: + return "document" + if extension in {"csv", "xls", "xlsx"}: + return "spreadsheet" + if extension in {"ppt", "pptx"}: + return "presentation" + return extension + + +def _remote_status(value: Any) -> str: + if value is None or (isinstance(value, str) and not value.strip()): + return "1" + return str(value) + + +def _shanghai_now_naive() -> datetime: + return datetime.now(ZoneInfo(get_settings().timezone)).replace(tzinfo=None) diff --git a/main/manager-api-fastapi/app/repositories/model.py b/main/manager-api-fastapi/app/repositories/model.py new file mode 100644 index 00000000..59ca0868 --- /dev/null +++ b/main/manager-api-fastapi/app/repositories/model.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import json +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import bindparam, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import Repository + + +class ModelRepository(Repository): + def __init__(self, session: AsyncSession): + super().__init__(session) + + async def list_model_names(self, model_type: str, model_name: str | None) -> list[dict[str, Any]]: + return await self.fetch_all( + "SELECT id, model_name FROM ai_model_config " + "WHERE model_type = :model_type AND is_enabled = 1 " + "AND (:model_name IS NULL OR :model_name = '' OR model_name LIKE CONCAT('%', :model_name, '%')) " + "ORDER BY sort ASC", + {"model_type": model_type, "model_name": model_name}, + ) + + async def list_llm_names(self, model_name: str | None) -> list[dict[str, Any]]: + return await self.fetch_all( + "SELECT id, model_name, config_json FROM ai_model_config " + "WHERE model_type = 'llm' AND is_enabled = 1 " + "AND (:model_name IS NULL OR :model_name = '' OR model_name LIKE CONCAT('%', :model_name, '%'))", + {"model_name": model_name}, + ) + + async def list_providers_by_type(self, model_type: str) -> list[dict[str, Any]]: + return await self.fetch_all( + "SELECT * FROM ai_model_provider WHERE model_type = :model_type ORDER BY sort ASC", + {"model_type": model_type or ""}, + ) + + async def list_providers( + self, + *, + model_type: str | None, + name: str | None, + offset: int, + limit: int, + ) -> tuple[list[dict[str, Any]], int]: + where = ( + "WHERE (:model_type IS NULL OR :model_type = '' OR model_type = :model_type) " + "AND (:name IS NULL OR :name = '' OR name LIKE CONCAT('%', :name, '%') " + "OR provider_code LIKE CONCAT('%', :name, '%'))" + ) + params = {"model_type": model_type, "name": name, "offset": offset, "limit": limit} + total = int(await self.scalar(f"SELECT COUNT(*) FROM ai_model_provider {where}", params) or 0) # noqa: S608 + rows = await self.fetch_all( + f"SELECT * FROM ai_model_provider {where} " # noqa: S608 + "ORDER BY model_type ASC, sort ASC LIMIT :offset, :limit", + params, + ) + return rows, total + + async def list_model_configs( + self, + *, + model_type: str, + model_name: str | None, + offset: int, + limit: int, + ) -> tuple[list[dict[str, Any]], int]: + where = ( + "WHERE model_type = :model_type AND " + "(:model_name IS NULL OR :model_name = '' OR model_name LIKE CONCAT('%', :model_name, '%'))" + ) + params = {"model_type": model_type, "model_name": model_name, "offset": offset, "limit": limit} + total = int(await self.scalar(f"SELECT COUNT(*) FROM ai_model_config {where}", params) or 0) # noqa: S608 + rows = await self.fetch_all( + f"SELECT * FROM ai_model_config {where} " # noqa: S608 + "ORDER BY is_enabled DESC, sort ASC LIMIT :offset, :limit", + params, + ) + return rows, total + + async def get_provider(self, model_type: str, provider_code: str) -> dict[str, Any] | None: + return await self.fetch_one( + "SELECT * FROM ai_model_provider WHERE model_type = :model_type AND provider_code = :provider_code LIMIT 1", + {"model_type": model_type or "", "provider_code": provider_code or ""}, + ) + + async def get_model(self, model_id: str, *, for_update: bool = False) -> dict[str, Any] | None: + suffix = " FOR UPDATE" if for_update and self.session.get_bind().dialect.name != "sqlite" else "" + return await self.fetch_one( + f"SELECT * FROM ai_model_config WHERE id = :id LIMIT 1{suffix}", # noqa: S608 + {"id": model_id}, + ) + + async def insert_model(self, values: dict[str, Any]) -> None: + await self.execute( + "INSERT INTO ai_model_config " + "(id, model_type, model_code, model_name, is_default, is_enabled, config_json, doc_link, remark, sort) " + "VALUES (:id, :model_type, :model_code, :model_name, :is_default, COALESCE(:is_enabled, 0), " + ":config_json, :doc_link, :remark, COALESCE(:sort, 0))", + values, + ) + + async def update_model(self, values: dict[str, Any]) -> int: + return await self.execute( + "UPDATE ai_model_config SET model_type=:model_type, model_code=:model_code, " + "model_name=COALESCE(:model_name, model_name), is_default=:is_default, " + "is_enabled=COALESCE(:is_enabled, is_enabled), config_json=:config_json, doc_link=:doc_link, " + "remark=COALESCE(:remark, remark), sort=COALESCE(:sort, sort) WHERE id=:id", + values, + ) + + async def delete_model(self, model_id: str) -> int: + return await self.execute("DELETE FROM ai_model_config WHERE id = :id", {"id": model_id}) + + async def model_agent_references(self, model_id: str) -> list[str]: + rows = await self.fetch_all( + "SELECT agent_name FROM ai_agent WHERE vad_model_id=:id OR asr_model_id=:id OR llm_model_id=:id " + "OR tts_model_id=:id OR mem_model_id=:id OR vllm_model_id=:id OR intent_model_id=:id", + {"id": model_id}, + ) + return [str(row.get("agent_name") or "") for row in rows] + + async def intent_reference_count(self, model_id: str) -> int: + return int( + await self.scalar( + "SELECT COUNT(*) FROM ai_model_config WHERE model_type='Intent' AND CAST(config_json AS CHAR) LIKE " + "CONCAT('%', :id, '%')", + {"id": model_id}, + ) + or 0 + ) + + async def set_models_default(self, model_type: str, value: int) -> None: + await self.execute( + "UPDATE ai_model_config SET is_default=:value WHERE model_type=:model_type", + {"value": value, "model_type": model_type}, + ) + + async def set_model_enabled(self, model_id: str, status: int) -> int: + return await self.execute( + "UPDATE ai_model_config SET is_enabled=:status WHERE id=:id", + {"status": status, "id": model_id}, + ) + + async def update_default_template_models(self, model_type: str, model_id: str) -> None: + columns = { + "ASR": ("asr_model_id",), + "VAD": ("vad_model_id",), + "LLM": ("llm_model_id",), + "TTS": ("tts_model_id", "tts_voice_id"), + "VLLM": ("vllm_model_id",), + "MEMORY": ("mem_model_id",), + "INTENT": ("intent_model_id",), + }.get(model_type.upper()) + if not columns: + return + if columns == ("tts_model_id", "tts_voice_id"): + await self.execute( + "UPDATE ai_agent_template SET tts_model_id=:id, tts_voice_id=NULL WHERE sort >= 0", + {"id": model_id}, + ) + else: + column = columns[0] + await self.session.execute( + text(f"UPDATE ai_agent_template SET {column}=:id WHERE sort >= 0"), # noqa: S608 + {"id": model_id}, + ) + + async def insert_provider(self, values: dict[str, Any]) -> None: + if self.session.get_bind().dialect.name == "sqlite": + statement = ( + "INSERT INTO ai_model_provider " + "(id, model_type, provider_code, name, fields, sort, creator, create_date, updater, update_date) " + "VALUES (:id, :model_type, :provider_code, :name, :fields, :sort, :creator, :now, :updater, :now)" + ) + else: + statement = ( + "INSERT INTO ai_model_provider " + "(id, model_type, provider_code, name, fields, sort, creator, create_date, updater, update_date) " + "VALUES (:id, :model_type, :provider_code, :name, CAST(:fields AS JSON), :sort, :creator, :now, " + ":updater, :now)" + ) + await self.execute(statement, values) + + async def update_provider(self, values: dict[str, Any]) -> int: + if self.session.get_bind().dialect.name == "sqlite": + statement = ( + "UPDATE ai_model_provider SET model_type=:model_type, provider_code=:provider_code, name=:name, " + "fields=:fields, sort=:sort, updater=:updater, update_date=:now WHERE id=:id" + ) + else: + statement = ( + "UPDATE ai_model_provider SET model_type=:model_type, provider_code=:provider_code, name=:name, " + "fields=CAST(:fields AS JSON), sort=:sort, updater=:updater, update_date=:now WHERE id=:id" + ) + return await self.execute(statement, values) + + async def delete_providers(self, ids: Sequence[str]) -> int: + if not ids: + return 0 + statement = text("DELETE FROM ai_model_provider WHERE id IN :ids").bindparams( + bindparam("ids", expanding=True) + ) + result = await self.session.execute(statement, {"ids": list(ids)}) + return int(getattr(result, "rowcount", 0) or 0) + + async def list_plugins_for_user(self, user_id: int) -> list[dict[str, Any]]: + providers = await self.fetch_all("SELECT * FROM ai_model_provider WHERE model_type='Plugin'") + datasets = await self.fetch_all( + "SELECT id, name, created_at, updated_at FROM ai_rag_dataset WHERE creator=:creator AND status=1", + {"creator": user_id}, + ) + providers.extend( + { + "id": row["id"], + "model_type": "Rag", + "name": f"[知识库]{row['name']}", + "provider_code": "ragflow", + "fields": "[]", + "sort": 0, + "create_date": row.get("created_at"), + "update_date": row.get("updated_at"), + "creator": 0, + "updater": 0, + } + for row in datasets + ) + return providers + + +def parse_json_object(value: Any) -> dict[str, Any] | None: + if value is None: + return None + if isinstance(value, dict): + return dict(value) + if isinstance(value, bytes): + value = value.decode("utf-8") + if isinstance(value, str): + parsed = json.loads(value) + return dict(parsed) if isinstance(parsed, dict) else None + return None diff --git a/main/manager-api-fastapi/app/repositories/security.py b/main/manager-api-fastapi/app/repositories/security.py new file mode 100644 index 00000000..37779c61 --- /dev/null +++ b/main/manager-api-fastapi/app/repositories/security.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import Repository + + +class SecurityRepository(Repository): + def __init__(self, session: AsyncSession): + super().__init__(session) + + async def get_param_value(self, code: str) -> str | None: + value = await self.scalar( + "SELECT param_value FROM sys_params WHERE param_code = :code LIMIT 1", + {"code": code}, + ) + return None if value is None else str(value) + + async def get_mobile_area_items(self) -> list[dict[str, Any]]: + return await self.fetch_all( + "SELECT d.dict_label AS name, d.dict_value AS `key` " + "FROM sys_dict_data d " + "LEFT JOIN sys_dict_type t ON d.dict_type_id = t.id " + "WHERE t.dict_type = :dict_type ORDER BY d.sort ASC", + {"dict_type": "MOBILE_AREA"}, + ) + + async def get_user_by_username(self, username: str | None) -> dict[str, Any] | None: + return await self.fetch_one( + "SELECT id, username, password, super_admin, status, creator, create_date, updater, update_date " + "FROM sys_user WHERE username = :username LIMIT 1", + {"username": username}, + ) + + async def get_user_by_id(self, user_id: int) -> dict[str, Any] | None: + return await self.fetch_one( + "SELECT id, username, password, super_admin, status, creator, create_date, updater, update_date " + "FROM sys_user WHERE id = :id LIMIT 1", + {"id": user_id}, + ) + + async def count_users(self) -> int: + return int(await self.scalar("SELECT COUNT(*) FROM sys_user") or 0) + + async def insert_user( + self, + *, + user_id: int, + username: str | None, + password: str, + super_admin: int, + now: datetime, + ) -> None: + await self.execute( + "INSERT INTO sys_user " + "(id, username, password, super_admin, status, creator, create_date, updater, update_date) " + "VALUES (:id, :username, :password, :super_admin, 1, NULL, :now, NULL, :now)", + { + "id": user_id, + "username": username, + "password": password, + "super_admin": super_admin, + "now": now, + }, + ) + + async def get_token_by_user_id(self, user_id: int, *, for_update: bool = False) -> dict[str, Any] | None: + sql = ( + "SELECT id, user_id, token, expire_date, update_date, create_date " + "FROM sys_user_token WHERE user_id = :user_id LIMIT 1 FOR UPDATE" + if for_update and self._supports_for_update() + else "SELECT id, user_id, token, expire_date, update_date, create_date " + "FROM sys_user_token WHERE user_id = :user_id LIMIT 1" + ) + return await self.fetch_one( + sql, + {"user_id": user_id}, + ) + + async def insert_token( + self, + *, + token_id: int, + user_id: int, + token: str, + now: datetime, + expire_date: datetime, + ) -> None: + await self.execute( + "INSERT INTO sys_user_token (id, user_id, token, expire_date, update_date, create_date) " + "VALUES (:id, :user_id, :token, :expire_date, :now, :now)", + { + "id": token_id, + "user_id": user_id, + "token": token, + "expire_date": expire_date, + "now": now, + }, + ) + + async def update_token(self, *, token_id: int, token: str, now: datetime, expire_date: datetime) -> None: + await self.execute( + "UPDATE sys_user_token SET token = :token, expire_date = :expire_date, update_date = :now " + "WHERE id = :id", + {"id": token_id, "token": token, "expire_date": expire_date, "now": now}, + ) + + async def update_password( + self, + user_id: int, + password_hash: str, + now: datetime, + *, + preserve_audit_fields: bool = False, + ) -> int: + return await self.execute( + "UPDATE sys_user SET password = :password, " + "update_date = CASE WHEN :preserve_audit = 1 THEN update_date ELSE :now END WHERE id = :id", + { + "id": user_id, + "password": password_hash, + "now": now, + "preserve_audit": int(preserve_audit_fields), + }, + ) + + async def expire_user_token(self, user_id: int, expire_date: datetime) -> int: + return await self.execute( + "UPDATE sys_user_token SET expire_date = :expire_date WHERE user_id = :user_id", + {"user_id": user_id, "expire_date": expire_date}, + ) + + def _supports_for_update(self) -> bool: + bind = self.session.get_bind() + return bind.dialect.name != "sqlite" + + +async def raw_user_token(session: AsyncSession, token: str) -> dict[str, Any] | None: + result = await session.execute( + text( + "SELECT t.id AS token_id, t.user_id, t.token, t.expire_date, " + "u.username, u.super_admin, u.status " + "FROM sys_user_token t JOIN sys_user u ON u.id = t.user_id " + "WHERE t.token = :token LIMIT 1" + ), + {"token": token}, + ) + row = result.mappings().first() + return dict(row) if row is not None else None diff --git a/main/manager-api-fastapi/app/repositories/sys.py b/main/manager-api-fastapi/app/repositories/sys.py new file mode 100644 index 00000000..20006fc7 --- /dev/null +++ b/main/manager-api-fastapi/app/repositories/sys.py @@ -0,0 +1,494 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import bindparam, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import Repository + + +class SysRepository(Repository): + def __init__(self, session: AsyncSession): + super().__init__(session) + + async def page_users(self, *, mobile: str | None, page: int, limit: int) -> tuple[list[dict[str, Any]], int]: + pattern = f"%{mobile}%" if mobile else None + params = {"mobile": pattern, "offset": (page - 1) * limit, "limit": limit} + total = int( + await self.scalar( + "SELECT COUNT(*) FROM sys_user WHERE (:mobile IS NULL OR username LIKE :mobile)", + params, + ) + or 0 + ) + rows = await self.fetch_all( + "SELECT u.id, u.username, u.status, u.create_date, " + "(SELECT COUNT(*) FROM ai_device d WHERE d.user_id = u.id) AS device_count " + "FROM sys_user u WHERE (:mobile IS NULL OR u.username LIKE :mobile) " + "ORDER BY u.id ASC LIMIT :limit OFFSET :offset", + params, + ) + return rows, total + + async def reset_user_password( + self, + user_id: int, + password_hash: str, + updater: int, + now: datetime, + ) -> int: + return await self.execute( + "UPDATE sys_user SET password = :password, updater = :updater, update_date = :now WHERE id = :id", + {"id": user_id, "password": password_hash, "updater": updater, "now": now}, + ) + + async def change_user_status(self, status: int, user_ids: list[int], updater: int, now: datetime) -> int: + statement = text( + "UPDATE sys_user SET status = :status, updater = :updater, update_date = :now WHERE id IN :ids" + ).bindparams(bindparam("ids", expanding=True)) + return await self.execute( + statement, + {"status": status, "updater": updater, "now": now, "ids": user_ids}, + ) + + async def delete_user_cascade(self, user_id: int) -> None: + agent_rows = await self.fetch_all("SELECT id FROM ai_agent WHERE user_id = :user_id", {"user_id": user_id}) + agent_ids = [str(row["id"]) for row in agent_rows] + await self.execute("DELETE FROM sys_user WHERE id = :id", {"id": user_id}) + await self.execute("DELETE FROM ai_device WHERE user_id = :id", {"id": user_id}) + for agent_id in agent_ids: + audio_rows = await self.fetch_all( + "SELECT DISTINCT audio_id FROM ai_agent_chat_history " + "WHERE agent_id = :agent_id AND audio_id IS NOT NULL", + {"agent_id": agent_id}, + ) + audio_ids = [str(row["audio_id"]) for row in audio_rows] + if audio_ids: + statement = text("DELETE FROM ai_agent_chat_audio WHERE id IN :ids").bindparams( + bindparam("ids", expanding=True) + ) + await self.execute(statement, {"ids": audio_ids}) + for table in ( + "ai_agent_chat_history", + "ai_agent_plugin_mapping", + "ai_agent_context_provider", + "ai_agent_correct_word_mapping", + "ai_agent_tag_relation", + "ai_agent_snapshot", + ): + # Table names are a closed list mirroring AgentServiceImpl.deleteAgent. + await self.execute( + f"DELETE FROM {table} WHERE agent_id = :agent_id", # noqa: S608 - closed table list above + {"agent_id": agent_id}, + ) + await self.execute("DELETE FROM ai_device WHERE agent_id = :agent_id", {"agent_id": agent_id}) + await self.execute("DELETE FROM ai_agent WHERE id = :agent_id", {"agent_id": agent_id}) + + async def page_devices( + self, + *, + keywords: str | None, + page: int, + limit: int, + ) -> tuple[list[dict[str, Any]], int]: + pattern = f"%{keywords}%" if keywords else None + params = {"keywords": pattern, "offset": (page - 1) * limit, "limit": limit} + total = int( + await self.scalar( + "SELECT COUNT(*) FROM ai_device WHERE (:keywords IS NULL OR alias LIKE :keywords)", + params, + ) + or 0 + ) + rows = await self.fetch_all( + "SELECT d.id, d.user_id, d.mac_address, d.last_connected_at, d.auto_update, d.board, d.alias, " + "d.agent_id, d.app_version, d.sort, d.create_date, d.update_date, u.username AS bind_user_name " + "FROM ai_device d LEFT JOIN sys_user u ON u.id = d.user_id " + "WHERE (:keywords IS NULL OR d.alias LIKE :keywords) " + "ORDER BY d.mac_address ASC LIMIT :limit OFFSET :offset", + params, + ) + return rows, total + + async def page_params( + self, + *, + param_code: str | None, + page: int, + limit: int, + order_field: str | None, + order: str | None, + ) -> tuple[list[dict[str, Any]], int]: + pattern = f"%{param_code}%" if param_code else None + params = {"pattern": pattern, "offset": (page - 1) * limit, "limit": limit} + where = "param_type = 1 AND (:pattern IS NULL OR param_code LIKE :pattern OR remark LIKE :pattern)" + total = int(await self.scalar(f"SELECT COUNT(*) FROM sys_params WHERE {where}", params) or 0) # noqa: S608 + allowed = { + "id": "id", + "paramCode": "param_code", + "paramValue": "param_value", + "valueType": "value_type", + "createDate": "create_date", + "updateDate": "update_date", + } + order_column = allowed.get(order_field or "") + order_clause = "" + if order_column is not None: + direction = "ASC" if (order or "").lower() == "asc" else "DESC" + order_clause = f" ORDER BY {order_column} {direction}" + sql = ( + "SELECT id, param_code, param_value, value_type, remark, create_date, update_date " # noqa: S608 + f"FROM sys_params WHERE {where}{order_clause} LIMIT :limit OFFSET :offset" + ) + return await self.fetch_all(sql, params), total # noqa: S608 + + async def list_config_params(self) -> list[dict[str, Any]]: + return await self.fetch_all( + "SELECT id, param_code, param_value, value_type, remark, create_date, update_date " + "FROM sys_params WHERE param_type = 1" + ) + + async def get_param(self, param_id: int) -> dict[str, Any] | None: + return await self.fetch_one( + "SELECT id, param_code, param_value, value_type, remark, create_date, update_date " + "FROM sys_params WHERE id = :id", + {"id": param_id}, + ) + + async def get_param_value(self, code: str) -> str | None: + value = await self.scalar("SELECT param_value FROM sys_params WHERE param_code = :code", {"code": code}) + return None if value is None else str(value) + + async def insert_param( + self, + *, + param_id: int, + param_code: str, + param_value: str, + value_type: str, + remark: str | None, + user_id: int, + now: datetime, + ) -> None: + await self.execute( + "INSERT INTO sys_params " + "(id, param_code, param_value, value_type, param_type, remark, creator, create_date, updater, update_date) " + "VALUES (:id, :code, :value, :value_type, 1, :remark, :user_id, :now, :user_id, :now)", + { + "id": param_id, + "code": param_code, + "value": param_value, + "value_type": value_type, + "remark": remark, + "user_id": user_id, + "now": now, + }, + ) + + async def update_param( + self, + *, + param_id: int, + param_code: str, + param_value: str, + value_type: str, + remark: str | None, + user_id: int, + now: datetime, + ) -> int: + return await self.execute( + "UPDATE sys_params SET param_code = :code, param_value = :value, value_type = :value_type, " + "remark = CASE WHEN :has_remark = 1 THEN :remark ELSE remark END, updater = :user_id, update_date = :now " + "WHERE id = :id", + { + "id": param_id, + "code": param_code, + "value": param_value, + "value_type": value_type, + "has_remark": int(remark is not None), + "remark": remark, + "user_id": user_id, + "now": now, + }, + ) + + async def update_param_value_by_code(self, code: str, value: str, user_id: int, now: datetime) -> int: + return await self.execute( + "UPDATE sys_params SET param_value = :value, updater = :user_id, update_date = :now " + "WHERE param_code = :code", + {"code": code, "value": value, "user_id": user_id, "now": now}, + ) + + async def param_codes_for_ids(self, ids: list[int]) -> list[str]: + statement = text("SELECT param_code FROM sys_params WHERE id IN :ids").bindparams( + bindparam("ids", expanding=True) + ) + rows = await self.fetch_all(statement, {"ids": ids}) + return [str(row["param_code"]) for row in rows] + + async def delete_params(self, ids: list[int]) -> int: + statement = text("DELETE FROM sys_params WHERE id IN :ids").bindparams(bindparam("ids", expanding=True)) + return await self.execute(statement, {"ids": ids}) + + async def delete_plugin_mapping_by_plugin_id(self, plugin_id: str) -> int: + return await self.execute( + "DELETE FROM ai_agent_plugin_mapping WHERE plugin_id = :plugin_id", + {"plugin_id": plugin_id}, + ) + + async def page_dict_types( + self, + *, + dict_type: str | None, + dict_name: str | None, + page: int, + limit: int, + ) -> tuple[list[dict[str, Any]], int]: + params = { + "dict_type": f"%{dict_type}%" if dict_type else None, + "dict_name": f"%{dict_name}%" if dict_name else None, + "offset": (page - 1) * limit, + "limit": limit, + } + where = ( + "(:dict_type IS NULL OR t.dict_type LIKE :dict_type) " + "AND (:dict_name IS NULL OR t.dict_name LIKE :dict_name)" + ) + total = int(await self.scalar(f"SELECT COUNT(*) FROM sys_dict_type t WHERE {where}", params) or 0) # noqa: S608 + rows = await self.fetch_all( + "SELECT t.id, t.dict_type, t.dict_name, t.remark, t.sort, t.creator, t.create_date, t.updater, " # noqa: S608 + "t.update_date, creator.username AS creator_name, updater.username AS updater_name " + "FROM sys_dict_type t LEFT JOIN sys_user creator ON creator.id = t.creator " + "LEFT JOIN sys_user updater ON updater.id = t.updater " + f"WHERE {where} ORDER BY t.sort ASC LIMIT :limit OFFSET :offset", # noqa: S608 + params, + ) + return rows, total + + async def get_dict_type(self, type_id: int) -> dict[str, Any] | None: + return await self.fetch_one( + "SELECT id, dict_type, dict_name, remark, sort, creator, create_date, updater, update_date " + "FROM sys_dict_type WHERE id = :id", + {"id": type_id}, + ) + + async def dict_type_exists(self, dict_type: str | None, *, exclude_id: int | None = None) -> bool: + count = await self.scalar( + "SELECT COUNT(*) FROM sys_dict_type WHERE dict_type = :dict_type " + "AND (:exclude_id IS NULL OR id <> :exclude_id)", + {"dict_type": dict_type, "exclude_id": exclude_id}, + ) + return int(count or 0) > 0 + + async def insert_dict_type( + self, + *, + type_id: int, + dict_type: str | None, + dict_name: str | None, + remark: str | None, + sort: int | None, + user_id: int, + now: datetime, + ) -> None: + await self.execute( + "INSERT INTO sys_dict_type " + "(id, dict_type, dict_name, remark, sort, creator, create_date, updater, update_date) " + "VALUES (:id, :dict_type, :dict_name, :remark, :sort, :user_id, :now, :user_id, :now)", + { + "id": type_id, + "dict_type": dict_type, + "dict_name": dict_name, + "remark": remark, + "sort": sort, + "user_id": user_id, + "now": now, + }, + ) + + async def update_dict_type( + self, + *, + type_id: int | None, + dict_type: str | None, + dict_name: str | None, + remark: str | None, + sort: int | None, + user_id: int, + now: datetime, + ) -> int: + return await self.execute( + "UPDATE sys_dict_type SET " + "dict_type = CASE WHEN :has_dict_type = 1 THEN :dict_type ELSE dict_type END, " + "dict_name = CASE WHEN :has_dict_name = 1 THEN :dict_name ELSE dict_name END, " + "remark = CASE WHEN :has_remark = 1 THEN :remark ELSE remark END, " + "sort = CASE WHEN :has_sort = 1 THEN :sort ELSE sort END, updater = :user_id, update_date = :now " + "WHERE id = :id", + { + "id": type_id, + "has_dict_type": int(dict_type is not None), + "dict_type": dict_type, + "has_dict_name": int(dict_name is not None), + "dict_name": dict_name, + "has_remark": int(remark is not None), + "remark": remark, + "has_sort": int(sort is not None), + "sort": sort, + "user_id": user_id, + "now": now, + }, + ) + + async def delete_dict_types(self, ids: list[int]) -> None: + statement_data = text("DELETE FROM sys_dict_data WHERE dict_type_id IN :ids").bindparams( + bindparam("ids", expanding=True) + ) + statement_types = text("DELETE FROM sys_dict_type WHERE id IN :ids").bindparams( + bindparam("ids", expanding=True) + ) + await self.execute(statement_data, {"ids": ids}) + await self.execute(statement_types, {"ids": ids}) + + async def page_dict_data( + self, + *, + dict_type_id: int | None, + dict_label: str | None, + dict_value: str | None, + page: int, + limit: int, + ) -> tuple[list[dict[str, Any]], int]: + params = { + "type_id": dict_type_id, + "dict_label": f"%{dict_label}%" if dict_label else None, + "dict_value": f"%{dict_value}%" if dict_value else None, + "offset": (page - 1) * limit, + "limit": limit, + } + where = ( + "d.dict_type_id = :type_id AND (:dict_label IS NULL OR d.dict_label LIKE :dict_label) " + "AND (:dict_value IS NULL OR d.dict_value LIKE :dict_value)" + ) + total = int(await self.scalar(f"SELECT COUNT(*) FROM sys_dict_data d WHERE {where}", params) or 0) # noqa: S608 + rows = await self.fetch_all( + "SELECT d.id, d.dict_type_id, d.dict_label, d.dict_value, d.remark, d.sort, d.creator, " # noqa: S608 + "d.create_date, d.updater, d.update_date, creator.username AS creator_name, " + "updater.username AS updater_name FROM sys_dict_data d " + "LEFT JOIN sys_user creator ON creator.id = d.creator " + "LEFT JOIN sys_user updater ON updater.id = d.updater " + f"WHERE {where} ORDER BY d.sort ASC LIMIT :limit OFFSET :offset", # noqa: S608 + params, + ) + return rows, total + + async def get_dict_data(self, data_id: int) -> dict[str, Any] | None: + return await self.fetch_one( + "SELECT id, dict_type_id, dict_label, dict_value, remark, sort, creator, create_date, updater, update_date " + "FROM sys_dict_data WHERE id = :id", + {"id": data_id}, + ) + + async def dict_data_label_exists( + self, + dict_type_id: int | None, + compared_label: str | None, + *, + exclude_id: int | None = None, + ) -> bool: + count = await self.scalar( + "SELECT COUNT(*) FROM sys_dict_data WHERE dict_type_id = :type_id AND dict_label = :label " + "AND (:exclude_id IS NULL OR id <> :exclude_id)", + {"type_id": dict_type_id, "label": compared_label, "exclude_id": exclude_id}, + ) + return int(count or 0) > 0 + + async def dict_type_code(self, type_id: int | None) -> str | None: + value = await self.scalar("SELECT dict_type FROM sys_dict_type WHERE id = :id", {"id": type_id}) + return None if value is None else str(value) + + async def insert_dict_data( + self, + *, + data_id: int, + dict_type_id: int | None, + dict_label: str | None, + dict_value: str | None, + remark: str | None, + sort: int | None, + user_id: int, + now: datetime, + ) -> None: + await self.execute( + "INSERT INTO sys_dict_data " + "(id, dict_type_id, dict_label, dict_value, remark, sort, creator, create_date, updater, update_date) " + "VALUES (:id, :type_id, :label, :value, :remark, :sort, :user_id, :now, :user_id, :now)", + { + "id": data_id, + "type_id": dict_type_id, + "label": dict_label, + "value": dict_value, + "remark": remark, + "sort": sort, + "user_id": user_id, + "now": now, + }, + ) + + async def update_dict_data( + self, + *, + data_id: int | None, + dict_type_id: int | None, + dict_label: str | None, + dict_value: str | None, + remark: str | None, + sort: int | None, + user_id: int, + now: datetime, + ) -> int: + return await self.execute( + "UPDATE sys_dict_data SET " + "dict_type_id = CASE WHEN :has_type_id = 1 THEN :type_id ELSE dict_type_id END, " + "dict_label = CASE WHEN :has_label = 1 THEN :label ELSE dict_label END, " + "dict_value = CASE WHEN :has_value = 1 THEN :value ELSE dict_value END, " + "remark = CASE WHEN :has_remark = 1 THEN :remark ELSE remark END, " + "sort = CASE WHEN :has_sort = 1 THEN :sort ELSE sort END, updater = :user_id, update_date = :now " + "WHERE id = :id", + { + "id": data_id, + "has_type_id": int(dict_type_id is not None), + "type_id": dict_type_id, + "has_label": int(dict_label is not None), + "label": dict_label, + "has_value": int(dict_value is not None), + "value": dict_value, + "has_remark": int(remark is not None), + "remark": remark, + "has_sort": int(sort is not None), + "sort": sort, + "user_id": user_id, + "now": now, + }, + ) + + async def dict_type_codes_for_data_ids(self, ids: list[int]) -> list[str]: + statement = text( + "SELECT DISTINCT t.dict_type FROM sys_dict_type t JOIN sys_dict_data d ON d.dict_type_id = t.id " + "WHERE d.id IN :ids" + ).bindparams(bindparam("ids", expanding=True)) + rows = await self.fetch_all(statement, {"ids": ids}) + return [str(row["dict_type"]) for row in rows] + + async def delete_dict_data(self, ids: list[int]) -> int: + statement = text("DELETE FROM sys_dict_data WHERE id IN :ids").bindparams(bindparam("ids", expanding=True)) + return await self.execute(statement, {"ids": ids}) + + async def dict_items(self, dict_type: str) -> list[dict[str, Any]]: + return await self.fetch_all( + "SELECT d.dict_label AS name, d.dict_value AS `key` FROM sys_dict_data d " + "LEFT JOIN sys_dict_type t ON d.dict_type_id = t.id " + "WHERE t.dict_type = :dict_type ORDER BY d.sort ASC", + {"dict_type": dict_type}, + ) diff --git a/main/manager-api-fastapi/app/repositories/timbre.py b/main/manager-api-fastapi/app/repositories/timbre.py new file mode 100644 index 00000000..1ad73fa1 --- /dev/null +++ b/main/manager-api-fastapi/app/repositories/timbre.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import bindparam, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import Repository + + +class TimbreRepository(Repository): + def __init__(self, session: AsyncSession): + super().__init__(session) + + async def page( + self, *, tts_model_id: str, name: str | None, offset: int, limit: int + ) -> tuple[list[dict[str, Any]], int]: + where = ( + "WHERE tts_model_id=:tts_model_id AND " + "(:name IS NULL OR :name='' OR name LIKE CONCAT('%', :name, '%'))" + ) + params = {"tts_model_id": tts_model_id, "name": name, "offset": offset, "limit": limit} + total = int(await self.scalar(f"SELECT COUNT(*) FROM ai_tts_voice {where}", params) or 0) # noqa: S608 + rows = await self.fetch_all( + f"SELECT * FROM ai_tts_voice {where} LIMIT :offset, :limit", # noqa: S608 + params, + ) + return rows, total + + async def insert(self, values: dict[str, Any]) -> None: + await self.execute( + "INSERT INTO ai_tts_voice " + "(id, languages, name, remark, reference_audio, reference_text, sort, tts_model_id, tts_voice, " + "voice_demo, creator, create_date) VALUES (:id, :languages, :name, :remark, :reference_audio, " + ":reference_text, :sort, :tts_model_id, :tts_voice, :voice_demo, :creator, :now)", + values, + ) + + async def update(self, values: dict[str, Any]) -> int: + return await self.execute( + "UPDATE ai_tts_voice SET languages=:languages, name=:name, remark=COALESCE(:remark, remark), " + "reference_audio=COALESCE(:reference_audio, reference_audio), " + "reference_text=COALESCE(:reference_text, reference_text), sort=:sort, " + "tts_model_id=:tts_model_id, tts_voice=:tts_voice, " + "voice_demo=COALESCE(:voice_demo, voice_demo), updater=:updater, " + "update_date=:now WHERE id=:id", + values, + ) + + async def delete(self, ids: Sequence[str]) -> int: + if not ids: + return 0 + statement = text("DELETE FROM ai_tts_voice WHERE id IN :ids").bindparams(bindparam("ids", expanding=True)) + result = await self.session.execute(statement, {"ids": list(ids)}) + return int(getattr(result, "rowcount", 0) or 0) + + async def voices( + self, model_id: str, name: str | None, user_id: int + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + normal = await self.fetch_all( + "SELECT id, name, voice_demo, languages FROM ai_tts_voice WHERE tts_model_id=:model_id " + "AND (:name IS NULL OR :name='' OR name LIKE CONCAT('%', :name, '%'))", + {"model_id": model_id or "", "name": name}, + ) + clones = await self.fetch_all( + "SELECT id, name, voice_id AS voice_demo, languages FROM ai_voice_clone " + "WHERE model_id=:model_id AND user_id=:user_id AND train_status=2", + {"model_id": model_id, "user_id": user_id}, + ) + return normal, clones diff --git a/main/manager-api-fastapi/app/repositories/voiceclone.py b/main/manager-api-fastapi/app/repositories/voiceclone.py new file mode 100644 index 00000000..0e3e0d32 --- /dev/null +++ b/main/manager-api-fastapi/app/repositories/voiceclone.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +# Every interpolated SQL fragment below is a module constant or a service-side allowlist. +# ruff: noqa: S608 +from collections.abc import Mapping, Sequence +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import Repository + +VOICE_COLUMNS = ( + "id, name, model_id, voice_id, languages, user_id, voice, train_status, train_error, creator, create_date" +) + + +class VoiceCloneRepository(Repository): + def __init__(self, session: AsyncSession): + super().__init__(session) + + async def count(self, *, name: str | None, user_id: str | None) -> int: + where, params = self._filters(name=name, user_id=user_id) + return int(await self.scalar(f"SELECT COUNT(*) FROM ai_voice_clone{where}", params) or 0) + + async def page( + self, + *, + page: int, + limit: int, + name: str | None, + user_id: str | None, + order_fields: Sequence[str], + ascending: bool, + ) -> list[dict[str, Any]]: + where, params = self._filters(name=name, user_id=user_id) + params.update(limit=limit, offset=max(page - 1, 0) * limit) + direction = "ASC" if ascending else "DESC" + order_by = ", ".join(f"{field} {direction}" for field in order_fields) + return await self.fetch_all( + f"SELECT {VOICE_COLUMNS} FROM ai_voice_clone{where} " + f"ORDER BY {order_by} LIMIT :limit OFFSET :offset", + params, + ) + + async def get(self, voice_id: str | None) -> dict[str, Any] | None: + if voice_id is None: + return None + return await self.fetch_one( + f"SELECT {VOICE_COLUMNS} FROM ai_voice_clone WHERE id = :id LIMIT 1", + {"id": voice_id}, + ) + + async def list_by_user(self, user_id: int) -> list[dict[str, Any]]: + return await self.fetch_all( + f"SELECT {VOICE_COLUMNS} FROM ai_voice_clone " + "WHERE user_id = :user_id ORDER BY create_date DESC", + {"user_id": user_id}, + ) + + async def voice_id_count(self, *, model_id: str, voice_id: str) -> int: + return int( + await self.scalar( + "SELECT COUNT(*) FROM ai_voice_clone WHERE voice_id = :voice_id AND model_id = :model_id", + {"model_id": model_id, "voice_id": voice_id}, + ) + or 0 + ) + + async def insert_many(self, values: Sequence[Mapping[str, Any]]) -> int: + return await self.execute_many( + "INSERT INTO ai_voice_clone " + "(id, name, model_id, voice_id, languages, user_id, voice, train_status, train_error, creator, " + "create_date) VALUES (:id, :name, :model_id, :voice_id, :languages, :user_id, :voice, :train_status, " + ":train_error, :creator, :create_date)", + values, + ) + + async def delete_many(self, ids: Sequence[str]) -> int: + if not ids: + return 0 + placeholders = ", ".join(f":id_{index}" for index in range(len(ids))) + params = {f"id_{index}": value for index, value in enumerate(ids)} + return await self.execute(f"DELETE FROM ai_voice_clone WHERE id IN ({placeholders})", params) + + async def update_voice(self, voice_id: str, data: bytes) -> int: + return await self.execute( + "UPDATE ai_voice_clone SET voice = :voice, train_status = 0 WHERE id = :id", + {"id": voice_id, "voice": data}, + ) + + async def update_name(self, voice_id: str, name: str) -> int: + return await self.execute( + "UPDATE ai_voice_clone SET name = :name WHERE id = :id", + {"id": voice_id, "name": name}, + ) + + async def update_training( + self, + voice_id: str, + *, + train_status: int, + train_error: str | None, + speaker_id: str | None = None, + ) -> int: + if speaker_id is None: + return await self.execute( + "UPDATE ai_voice_clone SET train_status = :train_status, train_error = :train_error WHERE id = :id", + {"id": voice_id, "train_status": train_status, "train_error": train_error}, + ) + return await self.execute( + "UPDATE ai_voice_clone SET train_status = :train_status, train_error = :train_error, " + "voice_id = :speaker_id WHERE id = :id", + { + "id": voice_id, + "train_status": train_status, + "train_error": train_error, + "speaker_id": speaker_id, + }, + ) + + async def get_model_config(self, model_id: str) -> dict[str, Any] | None: + return await self.fetch_one( + "SELECT id, model_name, config_json FROM ai_model_config WHERE id = :id LIMIT 1", + {"id": model_id}, + ) + + async def get_model_name(self, model_id: str) -> str | None: + value = await self.scalar( + "SELECT model_name FROM ai_model_config WHERE id = :id LIMIT 1", + {"id": model_id}, + ) + return None if value is None else str(value) + + async def get_usernames(self, user_ids: Sequence[int]) -> dict[int, str]: + if not user_ids: + return {} + unique_ids = list(dict.fromkeys(user_ids)) + placeholders = ", ".join(f":user_{index}" for index in range(len(unique_ids))) + params = {f"user_{index}": value for index, value in enumerate(unique_ids)} + rows = await self.fetch_all( + f"SELECT id, username FROM sys_user WHERE id IN ({placeholders})", + params, + ) + return {int(row["id"]): str(row["username"]) for row in rows} + + async def get_username(self, user_id: int) -> str | None: + value = await self.scalar( + "SELECT username FROM sys_user WHERE id = :id LIMIT 1", + {"id": user_id}, + ) + return None if value is None else str(value) + + async def get_tts_platforms(self) -> list[dict[str, Any]]: + return await self.fetch_all( + "SELECT id, model_name AS modelName FROM ai_model_config " + "WHERE model_type = 'TTS' AND JSON_EXTRACT(config_json, '$.type') = 'huoshan_double_stream'" + ) + + @staticmethod + def _filters(*, name: str | None, user_id: str | None) -> tuple[str, dict[str, Any]]: + clauses: list[str] = [] + params: dict[str, Any] = {} + if user_id is not None and user_id.strip(): + clauses.append("user_id = :user_id") + params["user_id"] = user_id + if name is not None and name.strip(): + clauses.append("(name LIKE :name OR voice_id = :exact_name)") + params["name"] = f"%{name}%" + params["exact_name"] = name + return (" WHERE " + " AND ".join(clauses) if clauses else "", params) diff --git a/main/manager-api-fastapi/app/routers/__init__.py b/main/manager-api-fastapi/app/routers/__init__.py new file mode 100644 index 00000000..4d13d175 --- /dev/null +++ b/main/manager-api-fastapi/app/routers/__init__.py @@ -0,0 +1,30 @@ +"""HTTP routers grouped by the Java business domains.""" + +from fastapi import APIRouter + + +def application_routers() -> list[APIRouter]: + """Return every migrated business router; imports stay explicit for coverage auditing.""" + from app.routers.agent import router as agent_router + from app.routers.config import config_router + from app.routers.correctword import correctword_router + from app.routers.device import device_router + from app.routers.knowledge import knowledge_router + from app.routers.model import model_router + from app.routers.security import security_router + from app.routers.sys import sys_router + from app.routers.timbre import timbre_router + from app.routers.voiceclone import voiceclone_router + + return [ + security_router, + sys_router, + config_router, + agent_router, + device_router, + voiceclone_router, + model_router, + timbre_router, + correctword_router, + knowledge_router, + ] diff --git a/main/manager-api-fastapi/app/routers/agent.py b/main/manager-api-fastapi/app/routers/agent.py new file mode 100644 index 00000000..b722f63c --- /dev/null +++ b/main/manager-api-fastapi/app/routers/agent.py @@ -0,0 +1,386 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, BackgroundTasks, Body, Depends, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.responses import Response + +from app.core.database import get_db +from app.core.errors import ErrorCode +from app.core.responses import JavaJSONResponse, error_response, ok +from app.core.security import AuthUser, require_normal, require_super_admin +from app.schemas.agent import ( + AgentChatHistoryReport, + AgentCreate, + AgentMemory, + AgentSnapshotPage, + AgentSnapshotRestore, + AgentTagAssignment, + AgentTemplate, + AgentUpdate, + AgentVoicePrintSave, + AgentVoicePrintUpdate, +) +from app.services.agent import AgentService, run_chat_summary_task + +router = APIRouter(tags=["agent"]) +DbSession = Annotated[AsyncSession, Depends(get_db)] +NormalUser = Annotated[AuthUser, Depends(require_normal)] +SuperUser = Annotated[AuthUser, Depends(require_super_admin)] + + +def _service(session: AsyncSession, user: AuthUser | None, request: Request) -> AgentService: + return AgentService(session, user, language=request.headers.get("Accept-Language")) + + +@router.post("/agent/chat-history/report") +async def report_chat_history(report: AgentChatHistoryReport, request: Request, session: DbSession) -> JavaJSONResponse: + return ok(await _service(session, None, request).report_chat(report)) + + +@router.post("/agent/chat-history/getDownloadUrl/{agentId}/{sessionId}") +async def issue_chat_history_download( + agentId: str, sessionId: str, request: Request, session: DbSession, user: NormalUser +) -> JavaJSONResponse: + service = _service(session, user, request) + if not await service.has_agent_permission(agentId): + return error_response(request, 10132) + return ok(await service.issue_history_token(agentId, sessionId)) + + +@router.get("/agent/chat-history/download/{uuid}/current") +async def download_current_chat_history(uuid: str, request: Request, session: DbSession) -> Response: + content = await _service(session, None, request).consume_history_download(uuid, previous=False) + return Response( + content.encode("utf-8"), + media_type="text/plain;charset=UTF-8", + headers={"Content-Disposition": "attachment;filename=history.txt"}, + ) + + +@router.get("/agent/chat-history/download/{uuid}/previous") +async def download_previous_chat_history(uuid: str, request: Request, session: DbSession) -> Response: + content = await _service(session, None, request).consume_history_download(uuid, previous=True) + return Response( + content.encode("utf-8"), + media_type="text/plain;charset=UTF-8", + headers={"Content-Disposition": "attachment;filename=history.txt"}, + ) + + +# Static paths are deliberately registered before /agent/{id}; Starlette resolves in declaration order. +@router.get("/agent/template/page") +async def template_page( + request: Request, + session: DbSession, + user: SuperUser, + page: int = Query(default=1), + limit: int = Query(default=10), + agentName: str | None = Query(default=None), +) -> JavaJSONResponse: + return ok(await _service(session, user, request).template_page(page, limit, agentName)) + + +@router.post("/agent/template/batch-remove") +async def batch_delete_templates( + ids: list[str], request: Request, session: DbSession, user: SuperUser +) -> JavaJSONResponse: + deleted = await _service(session, user, request).batch_delete_templates(ids) + return ( + ok("批量删除成功") if deleted else error_response(request, ErrorCode.INTERNAL_SERVER_ERROR, "批量删除模板失败") + ) + + +@router.get("/agent/template/{id}") +async def template_detail(id: str, request: Request, session: DbSession, user: SuperUser) -> JavaJSONResponse: + result = await _service(session, user, request).template_detail(id) + return ok(result) if result is not None else error_response(request, ErrorCode.INTERNAL_SERVER_ERROR, "模板不存在") + + +@router.post("/agent/template") +async def create_template( + template: AgentTemplate, request: Request, session: DbSession, user: SuperUser +) -> JavaJSONResponse: + return ok(await _service(session, user, request).create_template(template)) + + +@router.put("/agent/template") +async def update_template( + template: AgentTemplate, request: Request, session: DbSession, user: SuperUser +) -> JavaJSONResponse: + # MyBatis-Plus raises before returning a boolean when updateById receives + # an entity without its @TableId. Keep Java's generic error envelope for + # that exact input; an unknown but non-empty id still returns the controller's + # explicit "更新模板失败" message below. + if template.id is None: + return error_response(request, ErrorCode.INTERNAL_SERVER_ERROR) + updated = await _service(session, user, request).update_template(template) + return ok(template) if updated else error_response(request, ErrorCode.INTERNAL_SERVER_ERROR, "更新模板失败") + + +@router.delete("/agent/template/{id}") +async def delete_template(id: str, request: Request, session: DbSession, user: SuperUser) -> JavaJSONResponse: + service = _service(session, user, request) + if await service.template_detail(id) is None: + return error_response(request, ErrorCode.INTERNAL_SERVER_ERROR, "模板不存在") + return ( + ok("删除模板成功") + if await service.delete_template(id) + else error_response(request, ErrorCode.INTERNAL_SERVER_ERROR, "删除模板失败") + ) + + +@router.post("/agent/voice-print") +async def create_voiceprint( + dto: AgentVoicePrintSave, request: Request, session: DbSession, user: NormalUser +) -> JavaJSONResponse: + created = await _service(session, user, request).create_voiceprint(dto) + return ok() if created else error_response(request, 10057) + + +@router.put("/agent/voice-print") +async def update_voiceprint( + dto: AgentVoicePrintUpdate, request: Request, session: DbSession, user: NormalUser +) -> JavaJSONResponse: + updated = await _service(session, user, request).update_voiceprint(dto) + return ok() if updated else error_response(request, 10058) + + +@router.delete("/agent/voice-print/{id}") +async def delete_voiceprint(id: str, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse: + deleted = await _service(session, user, request).delete_voiceprint(id) + return ok() if deleted else error_response(request, 10059) + + +@router.get("/agent/voice-print/list/{id}") +async def list_voiceprints(id: str, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse: + return ok(await _service(session, user, request).voiceprint_list(id)) + + +@router.get("/agent/mcp/address/{agentId}") +async def mcp_address(agentId: str, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse: + service = _service(session, user, request) + if not await service.has_agent_permission(agentId): + return error_response(request, 10200) + address = await service.mcp_address(agentId) + return ok(address) if address is not None else error_response(request, 10201) + + +@router.get("/agent/mcp/tools/{agentId}") +async def mcp_tools(agentId: str, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse: + service = _service(session, user, request) + if not await service.has_agent_permission(agentId): + return error_response(request, 10202) + return ok(await service.mcp_tools(agentId)) + + +@router.get("/agent/tag/list") +async def all_tags(request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse: + return ok(await _service(session, user, request).all_tags()) + + +@router.post("/agent/tag") +async def create_tag( + request: Request, + session: DbSession, + user: NormalUser, + params: dict[str, str] = Body(...), +) -> JavaJSONResponse: + tag_name = params.get("tagName") + if tag_name is None or not tag_name.strip(): + return error_response(request, ErrorCode.INTERNAL_SERVER_ERROR, "标签名称不能为空") + return ok(await _service(session, user, request).save_tag(tag_name)) + + +@router.delete("/agent/tag/{id}") +async def delete_tag(id: str, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse: + await _service(session, user, request).delete_tag(id) + return ok() + + +@router.post("/agent/audio/{audioId}") +async def issue_audio_token(audioId: str, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse: + token = await _service(session, user, request).issue_audio_token(audioId) + return ok(token) if token is not None else error_response(request, ErrorCode.INTERNAL_SERVER_ERROR, "音频不存在") + + +@router.get("/agent/play/{uuid}") +async def play_agent_audio(uuid: str, request: Request, session: DbSession) -> Response: + audio = await _service(session, None, request).consume_audio_token(uuid) + if audio is None: + return Response(status_code=404) + return Response( + audio, + media_type="application/octet-stream", + headers={"Content-Disposition": 'attachment; filename="play.wav"'}, + ) + + +@router.put("/agent/saveMemory/{macAddress}") +async def update_memory( + macAddress: str, dto: AgentMemory, request: Request, session: DbSession, user: NormalUser +) -> JavaJSONResponse: + await _service(session, user, request).update_memory_by_mac(macAddress, dto) + return ok() + + +@router.post("/agent/chat-summary/{sessionId}/save") +async def save_chat_summary( + sessionId: str, background_tasks: BackgroundTasks, request: Request, session: DbSession +) -> JavaJSONResponse: + await _service(session, None, request).session_agent(sessionId) + background_tasks.add_task(run_chat_summary_task, sessionId) + return ok() + + +@router.post("/agent/chat-title/{sessionId}/generate") +async def generate_chat_title(sessionId: str, request: Request, session: DbSession) -> JavaJSONResponse: + service = _service(session, None, request) + await service.session_agent(sessionId) + await service.generate_chat_title(sessionId) + return ok() + + +@router.get("/agent/all") +async def admin_agent_list( + request: Request, + session: DbSession, + user: SuperUser, + page: int = Query(default=1), + limit: int = Query(default=10), + orderField: str | None = Query(default=None), + order: str | None = Query(default=None), +) -> JavaJSONResponse: + return ok(await _service(session, user, request).admin_agents(page, limit, orderField, order)) + + +@router.get("/agent/list") +async def user_agent_list( + request: Request, + session: DbSession, + user: NormalUser, + keyword: str | None = Query(default=None), + searchType: str = Query(default="name"), +) -> JavaJSONResponse: + del searchType # Java accepts the parameter but the consolidated implementation ignores it. + return ok(await _service(session, user, request).user_agents(keyword)) + + +@router.get("/agent/template") +async def template_list(request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse: + return ok(await _service(session, user, request).templates()) + + +@router.get("/agent/{agentId}/snapshots") +async def snapshot_page( + agentId: str, + request: Request, + session: DbSession, + user: NormalUser, + page: int | None = Query(default=1), + limit: int | None = Query(default=10), + maxVersionNo: int | None = Query(default=None), +) -> JavaJSONResponse: + params = AgentSnapshotPage(page=page, limit=limit, max_version_no=maxVersionNo) + return ok(await _service(session, user, request).snapshot_page(agentId, params)) + + +@router.get("/agent/{agentId}/snapshots/{snapshotId}") +async def snapshot_detail( + agentId: str, snapshotId: str, request: Request, session: DbSession, user: NormalUser +) -> JavaJSONResponse: + return ok(await _service(session, user, request).snapshot_detail(agentId, snapshotId)) + + +@router.post("/agent/{agentId}/snapshots/{snapshotId}/restore") +async def restore_snapshot( + agentId: str, + snapshotId: str, + dto: AgentSnapshotRestore, + request: Request, + session: DbSession, + user: NormalUser, +) -> JavaJSONResponse: + await _service(session, user, request).restore_snapshot(agentId, snapshotId, dto.current_state_token) + return ok() + + +@router.delete("/agent/{agentId}/snapshots/{snapshotId}") +async def delete_snapshot( + agentId: str, snapshotId: str, request: Request, session: DbSession, user: NormalUser +) -> JavaJSONResponse: + await _service(session, user, request).delete_snapshot(agentId, snapshotId) + return ok() + + +@router.get("/agent/{id}/sessions") +async def agent_sessions( + id: str, + request: Request, + session: DbSession, + user: NormalUser, + page: str | None = Query(default=None), + limit: str | None = Query(default=None), +) -> JavaJSONResponse: + return ok(await _service(session, user, request).sessions(id, page, limit)) + + +@router.get("/agent/{id}/chat-history/user") +async def recent_agent_history(id: str, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse: + service = _service(session, user, request) + if not await service.has_agent_permission(id): + return error_response(request, ErrorCode.INTERNAL_SERVER_ERROR, "没有权限查看该智能体的聊天记录") + return ok(await service.recent_user_history(id)) + + +@router.get("/agent/{id}/chat-history/audio") +async def agent_audio_content(id: str, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse: + return ok(await _service(session, user, request).audio_content(id)) + + +@router.get("/agent/{id}/chat-history/{sessionId}") +async def agent_history( + id: str, sessionId: str, request: Request, session: DbSession, user: NormalUser +) -> JavaJSONResponse: + service = _service(session, user, request) + if not await service.has_agent_permission(id): + return error_response(request, ErrorCode.INTERNAL_SERVER_ERROR, "没有权限查看该智能体的聊天记录") + return ok(await service.history(id, sessionId)) + + +@router.get("/agent/{id}/tags") +async def agent_tags(id: str, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse: + return ok(await _service(session, user, request).agent_tags(id)) + + +@router.put("/agent/{id}/tags") +async def save_agent_tags( + id: str, dto: AgentTagAssignment, request: Request, session: DbSession, user: NormalUser +) -> JavaJSONResponse: + await _service(session, user, request).save_agent_tags(id, dto.tag_ids, dto.tag_names) + return ok() + + +@router.post("/agent") +async def create_agent(dto: AgentCreate, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse: + return ok(await _service(session, user, request).create_agent(dto)) + + +@router.put("/agent/{id}") +async def update_agent( + id: str, dto: AgentUpdate, request: Request, session: DbSession, user: NormalUser +) -> JavaJSONResponse: + await _service(session, user, request).update_agent(id, dto) + return ok() + + +@router.delete("/agent/{id}") +async def delete_agent(id: str, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse: + await _service(session, user, request).delete_agent(id) + return ok() + + +@router.get("/agent/{id}") +async def agent_detail(id: str, request: Request, session: DbSession, user: NormalUser) -> JavaJSONResponse: + return ok(await _service(session, user, request).agent_detail(id)) diff --git a/main/manager-api-fastapi/app/routers/config.py b/main/manager-api-fastapi/app/routers/config.py new file mode 100644 index 00000000..2ff0311c --- /dev/null +++ b/main/manager-api-fastapi/app/routers/config.py @@ -0,0 +1,34 @@ +# ruff: noqa: B008 +# FastAPI evaluates dependency marker defaults intentionally when registering routes. +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.responses import JavaJSONResponse, ok +from app.core.serialization import preserve_java_map_keys +from app.repositories.config import ConfigRepository +from app.schemas.config import AgentModelsRequest, CorrectWordsRequest +from app.services.config import ConfigService + +config_router = APIRouter() + + +def _service(session: AsyncSession) -> ConfigService: + return ConfigService(ConfigRepository(session)) + + +@config_router.post("/config/server-base") +async def server_base(session: AsyncSession = Depends(get_db)) -> JavaJSONResponse: + return ok(preserve_java_map_keys(await _service(session).get_config(use_cache=True))) + + +@config_router.post("/config/agent-models") +async def agent_models(dto: AgentModelsRequest, session: AsyncSession = Depends(get_db)) -> JavaJSONResponse: + return ok(preserve_java_map_keys(await _service(session).get_agent_models(dto.mac_address, dto.selected_module))) + + +@config_router.post("/config/correct-words") +async def correct_words(dto: CorrectWordsRequest, session: AsyncSession = Depends(get_db)) -> JavaJSONResponse: + return ok(await _service(session).get_correct_words(dto.mac_address)) diff --git a/main/manager-api-fastapi/app/routers/correctword.py b/main/manager-api-fastapi/app/routers/correctword.py new file mode 100644 index 00000000..64245d4f --- /dev/null +++ b/main/manager-api-fastapi/app/routers/correctword.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from urllib.parse import quote + +from fastapi import APIRouter, Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.responses import Response + +from app.core.database import get_db +from app.core.responses import JavaJSONResponse, ok +from app.core.security import require_normal +from app.repositories.correctword import CorrectWordRepository +from app.schemas.correctword import CorrectWordFileBody +from app.services.correctword import CorrectWordService + +correctword_router = APIRouter() + + +def _java_urlencode(value: str) -> str: + # java.net.URLEncoder leaves alphanumerics plus .-*_ unescaped, encodes + # spaces as '+', and encodes '~'. The controller then replaces '+' with + # '%20'. urllib always leaves '~', so handle that final difference here. + return quote(value, safe="*.-_").replace("~", "%7E") + + +def _service(session: AsyncSession) -> CorrectWordService: + return CorrectWordService(CorrectWordRepository(session)) + + +@correctword_router.post("/correct-word/file") +async def create_file( + body: CorrectWordFileBody, request: Request, session: AsyncSession = Depends(get_db) +) -> JavaJSONResponse: + return ok(await _service(session).create(body, require_normal(request))) + + +@correctword_router.put("/correct-word/file/{file_id}") +async def update_file( + file_id: str, + body: CorrectWordFileBody, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + await _service(session).update(file_id, body, require_normal(request)) + return ok() + + +@correctword_router.get("/correct-word/file/list") +async def list_files( + request: Request, + page: str | None = None, + limit: str | None = None, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + return ok(await _service(session).page(require_normal(request), page, limit)) + + +@correctword_router.get("/correct-word/file/select") +async def select_files(request: Request, session: AsyncSession = Depends(get_db)) -> JavaJSONResponse: + return ok(await _service(session).all(require_normal(request))) + + +@correctword_router.get("/correct-word/file/download/{file_id}") +async def download_file( + file_id: str, request: Request, session: AsyncSession = Depends(get_db) +) -> Response: + require_normal(request) + item = await _service(session).get(file_id) + if item is None or not item["content"]: + return Response(status_code=404) + body = "\n".join(item["content"]).encode("utf-8") + file_name = str(item["fileName"]) + ascii_name = "".join(character if ord(character) < 128 else "_" for character in file_name) + disposition = f"attachment; filename=\"{ascii_name}\"; filename*=UTF-8''{_java_urlencode(file_name)}" + return Response( + body, + media_type="application/octet-stream", + headers={"Content-Disposition": disposition, "Content-Length": str(len(body))}, + ) + + +@correctword_router.delete("/correct-word/file/{file_id}") +async def delete_file( + file_id: str, request: Request, session: AsyncSession = Depends(get_db) +) -> JavaJSONResponse: + require_normal(request) + await _service(session).delete([file_id]) + return ok() + + +@correctword_router.post("/correct-word/file/batch-delete") +async def batch_delete_files( + file_ids: list[str], request: Request, session: AsyncSession = Depends(get_db) +) -> JavaJSONResponse: + require_normal(request) + await _service(session).delete(file_ids) + return ok() diff --git a/main/manager-api-fastapi/app/routers/device.py b/main/manager-api-fastapi/app/routers/device.py new file mode 100644 index 00000000..cdafbffa --- /dev/null +++ b/main/manager-api-fastapi/app/routers/device.py @@ -0,0 +1,505 @@ +from __future__ import annotations + +import json +from typing import Annotated, Any + +from fastapi import APIRouter, BackgroundTasks, Depends, File, Header, Query, Request, UploadFile +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.responses import Response + +from app.core.database import get_db +from app.core.i18n import resolve_language +from app.core.responses import JavaJSONResponse, envelope, error_response, ok +from app.core.security import require_normal, require_super_admin +from app.schemas.device import ( + DeviceAddressBookAliasRequest, + DeviceAddressBookPermissionRequest, + DeviceManualAddRequest, + DeviceRegisterRequest, + DeviceReportRequest, + DeviceToolCallRequest, + DeviceUnbindRequest, + DeviceUpdateRequest, + OtaRecord, +) +from app.services.device import MAC_PATTERN, DeviceService, is_blank + +device_router = APIRouter() +SessionDep = Annotated[AsyncSession, Depends(get_db)] +FirmwareUpload = Annotated[UploadFile, File()] +CallerMacQuery = Annotated[str, Query(alias="callerMac")] +DeviceIdHeader = Annotated[str | None, Header(alias="Device-Id")] +ClientIdHeader = Annotated[str | None, Header(alias="Client-Id")] + + +def _query_map(request: Request) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in request.query_params.multi_items(): + if key in result: + previous = result[key] + result[key] = [*previous, value] if isinstance(previous, list) else [previous, value] + else: + result[key] = value + return result + + +def _raw_ota(payload: dict[str, Any]) -> Response: + body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + return Response( + body, + status_code=200, + media_type="application/json", + headers={"Content-Length": str(len(body))}, + ) + + +@device_router.post("/device/bind/{agent_id}/{device_code}") +async def bind_device( + agent_id: str, + device_code: str, + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + user = require_normal(request) + await DeviceService(session).activate_bound_device(agent_id=agent_id, activation_code=device_code, user=user) + return ok() + + +@device_router.post("/device/register") +async def register_device( + body: DeviceRegisterRequest, + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + require_normal(request) + if is_blank(body.mac_address): + return error_response(request, 10175) + return ok(await DeviceService(session).register_device(body.mac_address or "")) + + +@device_router.get("/device/bind/{agent_id}") +async def get_bound_devices( + agent_id: str, + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + user = require_normal(request) + return ok(await DeviceService(session).list_user_devices(user.id, agent_id)) + + +@device_router.post("/device/bind/{agent_id}") +async def device_online( + agent_id: str, + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + user = require_normal(request) + await request.body() + try: + return ok(await DeviceService(session).get_online_data(agent_id, user)) + except Exception as exc: + return error_response(request, 500, f"转发请求失败: {exc}") + + +@device_router.post("/device/unbind") +async def unbind_device( + body: DeviceUnbindRequest, + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + user = require_normal(request) + # DeviceController does not apply @Valid to DeviceUnBindDTO. An empty + # object reaches the service with a null id and is a successful no-op. + await DeviceService(session).unbind(user_id=user.id, device_id=body.device_id or "") + return ok() + + +@device_router.put("/device/update/{device_id}") +async def update_device( + device_id: str, + body: DeviceUpdateRequest, + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + user = require_normal(request) + validation = _validate_device_update(body, request.headers.get("Accept-Language")) + if validation is not None: + return error_response(request, 10034, validation) + if not await DeviceService(session).update_device(device_id=device_id, request=body, user=user): + return error_response(request, 500, "设备不存在") + return ok() + + +@device_router.put("/user/configDevice/{device_id}") +async def configure_device( + device_id: str, + body: DeviceUpdateRequest, + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + user = require_normal(request) + validation = _validate_device_update(body, request.headers.get("Accept-Language")) + if validation is not None: + return error_response(request, 10034, validation) + if not await DeviceService(session).update_device(device_id=device_id, request=body, user=user): + return error_response(request, 500, "设备不存在") + return ok() + + +@device_router.post("/device/manual-add") +async def manual_add_device( + body: DeviceManualAddRequest, + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + user = require_normal(request) + await DeviceService(session).manual_add(request=body, user=user) + return ok() + + +@device_router.post("/device/tools/list/{device_id}") +async def list_device_tools( + device_id: str, + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + user = require_normal(request) + tools = await DeviceService(session).get_tools(device_id=device_id, user=user) + if tools is None: + return error_response(request, 10194) + return ok(tools) + + +@device_router.post("/device/tools/call/{device_id}") +async def call_device_tool( + device_id: str, + body: DeviceToolCallRequest, + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + user = require_normal(request) + if is_blank(body.name): + return error_response(request, 10034, "工具名称不能为空") + result = await DeviceService(session).call_tool( + device_id=device_id, + tool_name=body.name or "", + arguments=body.arguments, + user=user, + ) + if result is None: + return error_response(request, 10194) + return JavaJSONResponse(envelope(result, msg="Tools called successfully")) + + +# Static address-book paths deliberately precede /address-book/{mac_address}. +@device_router.get("/device/address-book/call") +async def call_address_book( + request: Request, + session: SessionDep, + caller_mac: CallerMacQuery, + nickname: str, + answer: bool = False, +) -> JavaJSONResponse: + result = await DeviceService(session).call_by_nickname( + caller_mac=caller_mac, + nickname=nickname, + answer=answer, + ) + return ok(result) + + +@device_router.get("/device/address-book/lookup") +async def lookup_address_book( + request: Request, + session: SessionDep, + caller_mac: CallerMacQuery, + nickname: str, +) -> JavaJSONResponse: + result = await DeviceService(session).lookup_address_book(caller_mac=caller_mac, nickname=nickname) + if result is None: + return error_response(request, 500, "未找到对应设备") + return ok(result) + + +@device_router.put("/device/address-book/alias") +async def update_address_alias( + body: DeviceAddressBookAliasRequest, + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + user = require_normal(request) + if is_blank(body.target_mac): + return error_response(request, 10034, "目标MAC地址不能为空") + if is_blank(body.mac_address): + return error_response(request, 10034, "MAC地址不能为空") + service = DeviceService(session) + caller = await service.repository.get_device_by_mac(body.mac_address or "") + if caller is None or int(caller.get("user_id") or -1) != user.id: + return error_response(request, 500, "无权限操作该设备") + await service.save_address_book( + mac_address=body.mac_address or "", + target_mac=body.target_mac or "", + alias=body.alias, + has_permission=None, + actor=user.id, + ) + return ok() + + +@device_router.put("/device/address-book/permission") +async def update_address_permission( + body: DeviceAddressBookPermissionRequest, + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + user = require_normal(request) + if is_blank(body.mac_address): + return error_response(request, 10034, "MAC地址不能为空") + if is_blank(body.target_mac): + return error_response(request, 10034, "目标MAC地址不能为空") + service = DeviceService(session) + caller = await service.repository.get_device_by_mac(body.mac_address or "") + if caller is None or int(caller.get("user_id") or -1) != user.id: + return error_response(request, 500, "无权限操作该设备") + await service.save_address_book( + mac_address=body.mac_address or "", + target_mac=body.target_mac or "", + alias=None, + has_permission=body.has_permission, + actor=user.id, + ) + return ok() + + +@device_router.get("/device/address-book/{mac_address}") +async def get_address_book( + mac_address: str, + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + require_normal(request) + return ok(await DeviceService(session).address_book(mac_address)) + + +@device_router.post("/ota/") +async def check_ota_version( + report: DeviceReportRequest, + request: Request, + session: SessionDep, + background_tasks: BackgroundTasks, + device_id: DeviceIdHeader = None, + client_id: ClientIdHeader = None, +) -> Response: + if is_blank(device_id): + # Java's required @RequestHeader fails before the controller's blank + # guard and is translated by its global handler into this envelope. + return error_response(request, 500) + if MAC_PATTERN.fullmatch(device_id or "") is None: + return _raw_ota({"error": "Invalid device ID"}) + selected_client = device_id if is_blank(client_id) else client_id + client_ip = request.client.host if request.client is not None else "unknown" + service = DeviceService(session) + + def defer_connection_update(device: str, agent: str | None, version: str | None) -> None: + background_tasks.add_task( + DeviceService.persist_connection_update_background, + device, + agent, + version, + ) + + payload = await service.check_ota( + device_id=device_id or "", + client_id=selected_client or device_id or "", + report=report, + request_url=str(request.url), + client_ip=client_ip, + defer_connection_update=defer_connection_update, + ) + return _raw_ota(payload) + + +@device_router.post("/ota/activate") +async def activate_ota_device( + request: Request, + session: SessionDep, + device_id: DeviceIdHeader = None, + client_id: ClientIdHeader = None, +) -> Response: + del client_id + if is_blank(device_id): + return error_response(request, 500) + if await DeviceService(session).repository.get_device_by_mac(device_id or "") is None: + return Response(status_code=202) + return Response("success", media_type="text/plain;charset=UTF-8") + + +@device_router.get("/ota/") +async def ota_health(session: SessionDep) -> Response: + return Response( + await DeviceService(session).ota_health_text(), + media_type="text/plain;charset=UTF-8", + ) + + +# Static otaMag paths deliberately precede /otaMag/{id}. +@device_router.get("/otaMag/getDownloadUrl/{ota_id}") +async def get_ota_download_url( + ota_id: str, + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + require_super_admin(request) + return ok(await DeviceService(session).create_ota_download_id(ota_id)) + + +@device_router.get("/otaMag/download/{download_id}") +async def download_ota(download_id: str, session: SessionDep) -> Response: + resolved = await DeviceService(session).resolve_ota_download(download_id) + if resolved is None: + return Response(status_code=404) + path, filename = resolved + try: + content = path.read_bytes() + except OSError: + return Response(status_code=500) + return Response( + content, + media_type="application/octet-stream", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Content-Length": str(len(content)), + }, + ) + + +@device_router.post("/otaMag/upload") +async def upload_firmware( + request: Request, + file: FirmwareUpload, + session: SessionDep, +) -> JavaJSONResponse: + require_super_admin(request) + service = DeviceService(session) + try: + content = await file.read() + return ok(await service.save_firmware_file(filename=file.filename, content=content)) + except ValueError as exc: + return error_response(request, 500, str(exc)) + except OSError as exc: + return error_response(request, 500, f"文件上传失败:{exc}") + + +@device_router.post("/otaMag/uploadAssetsBin") +async def upload_assets_firmware( + request: Request, + file: FirmwareUpload, + session: SessionDep, +) -> JavaJSONResponse: + user = require_normal(request) + service = DeviceService(session) + try: + content = await file.read() + return ok(await service.save_assets_file(filename=file.filename, content=content, user=user)) + except ValueError as exc: + return error_response(request, 500, str(exc)) + except OSError as exc: + return error_response(request, 500, f"文件上传失败:{exc}") + + +@device_router.get("/otaMag") +async def page_ota( + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + require_super_admin(request) + return ok(await DeviceService(session).ota_page(_query_map(request))) + + +@device_router.get("/otaMag/{ota_id}") +async def get_ota( + ota_id: str, + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + require_super_admin(request) + return ok(await DeviceService(session).get_ota_record(ota_id)) + + +@device_router.post("/otaMag") +async def save_ota( + request: Request, + session: SessionDep, + record: OtaRecord | None = None, +) -> JavaJSONResponse: + user = require_super_admin(request) + if record is None: + return error_response(request, 500, "固件信息不能为空") + if is_blank(record.firmware_name): + return error_response(request, 500, "固件名称不能为空") + if is_blank(record.type): + return error_response(request, 500, "固件类型不能为空") + if is_blank(record.version): + return error_response(request, 500, "版本号不能为空") + try: + await DeviceService(session).save_ota(record, user) + return ok() + except RuntimeError as exc: + return error_response(request, 500, str(exc)) + + +@device_router.delete("/otaMag/{ota_id}") +async def delete_ota( + ota_id: str, + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + require_super_admin(request) + ids = ota_id.split(",") if ota_id else [] + if not ids: + return error_response(request, 500, "删除的固件ID不能为空") + await DeviceService(session).delete_ota(ids) + return ok() + + +@device_router.put("/otaMag/{ota_id}") +async def update_ota( + ota_id: str, + request: Request, + session: SessionDep, + record: OtaRecord | None = None, +) -> JavaJSONResponse: + user = require_super_admin(request) + if record is None: + return error_response(request, 500, "固件信息不能为空") + try: + await DeviceService(session).update_ota(ota_id, record, user) + return ok() + except RuntimeError as exc: + return error_response(request, 500, str(exc)) + + +def _validate_device_update(body: DeviceUpdateRequest, accept_language: str | None) -> str | None: + language = resolve_language(accept_language) + if body.auto_update is not None and body.auto_update < 0: + return { + "zh-CN": "最小不能小于0", + "zh-TW": "必須大於或等於 0", + "de-DE": "muss größer-gleich 0 sein", + "pt-BR": "deve ser maior que ou igual à 0", + }.get(language, "must be greater than or equal to 0") + if body.auto_update is not None and body.auto_update > 1: + return { + "zh-CN": "最大不能超过1", + "zh-TW": "必須小於或等於 1", + "de-DE": "muss kleiner-gleich 1 sein", + "pt-BR": "deve ser menor que ou igual à 1", + }.get(language, "must be less than or equal to 1") + if body.alias is not None and len(body.alias.encode("utf-16-le")) // 2 > 64: + return { + "zh-CN": "个数必须在0和64之间", + "zh-TW": "大小必須在 0 和 64 之間", + "de-DE": "Größe muss zwischen 0 und 64 sein", + "pt-BR": "tamanho deve ser entre 0 e 64", + }.get(language, "size must be between 0 and 64") + return None diff --git a/main/manager-api-fastapi/app/routers/knowledge.py b/main/manager-api-fastapi/app/routers/knowledge.py new file mode 100644 index 00000000..2f8ce318 --- /dev/null +++ b/main/manager-api-fastapi/app/routers/knowledge.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import json +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.errors import AppError +from app.core.responses import JavaJSONResponse, envelope, ok +from app.core.security import require_normal +from app.repositories.knowledge import KnowledgeRepository +from app.schemas.knowledge import DocumentBatchBody, KnowledgeBaseBody, RetrievalBody +from app.services.knowledge import KnowledgeBaseService, KnowledgeDocumentService, dataset_dto + +knowledge_router = APIRouter() + + +def _base(session: AsyncSession) -> KnowledgeBaseService: + return KnowledgeBaseService(KnowledgeRepository(session)) + + +def _documents(session: AsyncSession) -> KnowledgeDocumentService: + return KnowledgeDocumentService(KnowledgeRepository(session)) + + +@knowledge_router.get("/datasets/rag-models") +async def rag_models(request: Request, session: AsyncSession = Depends(get_db)) -> JavaJSONResponse: + require_normal(request) + return ok(await _base(session).rag_models()) + + +@knowledge_router.delete("/datasets/batch") +async def delete_datasets_batch( + request: Request, ids: str = Query(), session: AsyncSession = Depends(get_db) +) -> JavaJSONResponse: + user = require_normal(request) + if not ids.strip(): + raise AppError(10003) + await _base(session).batch_delete( + ids.split(","), user, request.headers.get("Accept-Language") + ) + return ok() + + +@knowledge_router.get("/datasets") +async def datasets_page( + request: Request, + name: str | None = None, + page: int = 1, + page_size: int = 10, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + return ok( + await _base(session).page( + require_normal(request), + name, + page, + page_size, + request.headers.get("Accept-Language"), + ) + ) + + +@knowledge_router.post("/datasets") +async def create_dataset( + body: KnowledgeBaseBody, request: Request, session: AsyncSession = Depends(get_db) +) -> JavaJSONResponse: + return ok(await _base(session).create(body, require_normal(request))) + + +@knowledge_router.get("/datasets/{dataset_id}") +async def get_dataset( + dataset_id: str, request: Request, session: AsyncSession = Depends(get_db) +) -> JavaJSONResponse: + return ok(dataset_dto(await _base(session).get_owned(dataset_id, require_normal(request)))) + + +@knowledge_router.put("/datasets/{dataset_id}") +async def update_dataset( + dataset_id: str, + body: KnowledgeBaseBody, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + return ok(await _base(session).update(dataset_id, body, require_normal(request))) + + +@knowledge_router.delete("/datasets/{dataset_id}") +async def delete_dataset( + dataset_id: str, request: Request, session: AsyncSession = Depends(get_db) +) -> JavaJSONResponse: + await _base(session).delete( + dataset_id, require_normal(request), request.headers.get("Accept-Language") + ) + return ok() + + +@knowledge_router.get("/datasets/{dataset_id}/documents/status/{status}") +async def documents_by_status( + dataset_id: str, + status: str, + request: Request, + page: int = 1, + page_size: int = 10, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + return ok( + await _documents(session).page( + dataset_id, + require_normal(request), + name=None, + status=status, + page=page, + page_size=page_size, + ) + ) + + +@knowledge_router.get("/datasets/{dataset_id}/documents") +async def documents_page( + dataset_id: str, + request: Request, + name: str | None = None, + status: str | None = None, + page: int = 1, + page_size: int = 10, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + return ok( + await _documents(session).page( + dataset_id, + require_normal(request), + name=name, + status=status, + page=page, + page_size=page_size, + ) + ) + + +@knowledge_router.post("/datasets/{dataset_id}/documents") +async def upload_document( + dataset_id: str, + request: Request, + file: Annotated[UploadFile, File()], + name: Annotated[str | None, Form()] = None, + chunk_method: Annotated[str | None, Form(alias="chunkMethod")] = None, + meta_fields: Annotated[str | None, Form(alias="metaFields")] = None, + parser_config: Annotated[str | None, Form(alias="parserConfig")] = None, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + return ok( + await _documents(session).upload( + dataset_id, + require_normal(request), + file, + name=name, + meta_fields=_parse_form_json(meta_fields), + chunk_method=chunk_method, + parser_config=_parse_form_json(parser_config), + ) + ) + + +@knowledge_router.delete("/datasets/{dataset_id}/documents") +async def delete_documents( + dataset_id: str, + body: DocumentBatchBody, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + await _documents(session).delete( + dataset_id, + body.ids, + require_normal(request), + request.headers.get("Accept-Language"), + ) + return ok() + + +@knowledge_router.delete("/datasets/{dataset_id}/documents/{document_id}") +async def delete_document( + dataset_id: str, + document_id: str, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + await _documents(session).delete( + dataset_id, + [document_id], + require_normal(request), + request.headers.get("Accept-Language"), + ) + return ok() + + +@knowledge_router.post("/datasets/{dataset_id}/chunks") +async def parse_documents( + dataset_id: str, + body: dict[str, Any], + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + user = require_normal(request) + # Java validates dataset existence/ownership before it reads document_ids. + # A missing dataset must therefore win over the controller's empty-body + # business error. + await _base(session).get_owned(dataset_id, user) + document_ids = body.get("document_ids") + if document_ids is not None and not isinstance(document_ids, list): + # Spring fails Map> deserialization before entering + # the controller, which is handled as the generic code-500 envelope. + raise RuntimeError("document_ids must be an array") + if not document_ids: + return JavaJSONResponse(envelope(None, code=500, msg="document_ids参数不能为空")) + success = await _documents(session).parse(dataset_id, document_ids, user) + return ok() if success else JavaJSONResponse( + envelope(None, code=500, msg="文档解析失败,文档可能正在处理中") + ) + + +@knowledge_router.get("/datasets/{dataset_id}/documents/{document_id}/chunks") +async def list_chunks( + dataset_id: str, + document_id: str, + request: Request, + page: int = 1, + page_size: int = 10, + keywords: str | None = None, + id: str | None = None, # noqa: A002 - exact Java query parameter + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + return ok( + await _documents(session).chunks( + dataset_id, + document_id, + require_normal(request), + page=page, + page_size=page_size, + keywords=keywords, + chunk_id=id, + ) + ) + + +@knowledge_router.post("/datasets/{dataset_id}/retrieval-test") +async def retrieval_test( + dataset_id: str, + body: RetrievalBody, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + return ok(await _documents(session).retrieval(dataset_id, body, require_normal(request))) + + +def _parse_form_json(value: str | None) -> dict[str, Any] | None: + if value is None: + return None + try: + result = json.loads(value) + except json.JSONDecodeError as exc: + raise RuntimeError(f"解析JSON字符串失败: {value}") from exc + if not isinstance(result, dict): + raise RuntimeError(f"解析JSON字符串失败: {value}") + return dict(result) diff --git a/main/manager-api-fastapi/app/routers/model.py b/main/manager-api-fastapi/app/routers/model.py new file mode 100644 index 00000000..ba076eda --- /dev/null +++ b/main/manager-api-fastapi/app/routers/model.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.responses import JavaJSONResponse, envelope, ok +from app.core.security import require_normal, require_super_admin +from app.repositories.config import ConfigRepository +from app.repositories.model import ModelRepository +from app.schemas.model import ModelConfigBody, ModelProviderBody +from app.services.config import ConfigService +from app.services.model import ModelProviderService, ModelService + +model_router = APIRouter() + + +def _models(session: AsyncSession) -> ModelService: + return ModelService(ModelRepository(session)) + + +def _providers(session: AsyncSession) -> ModelProviderService: + return ModelProviderService(ModelRepository(session)) + + +async def _refresh_server_config(session: AsyncSession) -> None: + await ConfigService(ConfigRepository(session)).get_config(use_cache=False) + + +@model_router.get("/models/names") +async def model_names( + request: Request, + model_type: str = Query(alias="modelType"), + model_name: str | None = Query(default=None, alias="modelName"), + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + require_normal(request) + return ok(await _models(session).names(model_type, model_name)) + + +@model_router.get("/models/llm/names") +async def llm_names( + request: Request, + model_name: str | None = Query(default=None, alias="modelName"), + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + require_normal(request) + return ok(await _models(session).llm_names(model_name)) + + +@model_router.get("/models/list") +async def model_list( + request: Request, + model_type: str = Query(alias="modelType"), + model_name: str | None = Query(default=None, alias="modelName"), + page: str = "0", + limit: str = "10", + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + require_super_admin(request) + return ok(await _models(session).model_page(model_type, model_name, page, limit)) + + +@model_router.get("/models/provider/plugin/names") +async def plugin_names(request: Request, session: AsyncSession = Depends(get_db)) -> JavaJSONResponse: + user = require_normal(request) + return ok(await ModelRepository(session).list_plugins_for_user(user.id)) + + +@model_router.get("/models/provider") +async def provider_list( + request: Request, + model_type: str | None = Query(default=None, alias="modelType"), + name: str | None = None, + page: str = "0", + limit: str = "10", + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + require_super_admin(request) + return ok(await _providers(session).page(model_type, name, page, limit)) + + +@model_router.post("/models/provider") +async def provider_add( + body: ModelProviderBody, request: Request, session: AsyncSession = Depends(get_db) +) -> JavaJSONResponse: + return ok(await _providers(session).add(body, require_super_admin(request))) + + +@model_router.put("/models/provider") +async def provider_edit( + body: ModelProviderBody, request: Request, session: AsyncSession = Depends(get_db) +) -> JavaJSONResponse: + return ok(await _providers(session).edit(body, require_super_admin(request))) + + +@model_router.post("/models/provider/delete") +async def provider_delete( + ids: list[str], request: Request, session: AsyncSession = Depends(get_db) +) -> JavaJSONResponse: + require_super_admin(request) + await _providers(session).delete(ids) + return ok() + + +@model_router.get("/models/{model_type}/provideTypes") +async def provider_types( + model_type: str, request: Request, session: AsyncSession = Depends(get_db) +) -> JavaJSONResponse: + require_super_admin(request) + return ok(await ModelRepository(session).list_providers_by_type(model_type)) + + +@model_router.post("/models/{model_type}/{provide_code}") +async def model_add( + model_type: str, + provide_code: str, + body: ModelConfigBody, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + require_super_admin(request) + result = await _models(session).add(model_type, provide_code, body) + await _refresh_server_config(session) + return ok(result) + + +@model_router.put("/models/enable/{model_id}/{status}") +async def model_enable( + model_id: str, status: int, request: Request, session: AsyncSession = Depends(get_db) +) -> JavaJSONResponse: + require_super_admin(request) + message = await _models(session).enable(model_id, status) + return JavaJSONResponse(envelope(None, code=500, msg=message)) if message else ok() + + +@model_router.put("/models/{model_type}/{provide_code}/{model_id}") +async def model_edit( + model_type: str, + provide_code: str, + model_id: str, + body: ModelConfigBody, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + require_super_admin(request) + result = await _models(session).edit(model_type, provide_code, model_id, body) + await _refresh_server_config(session) + return ok(result) + + +@model_router.put("/models/default/{model_id}") +async def model_default( + model_id: str, request: Request, session: AsyncSession = Depends(get_db) +) -> JavaJSONResponse: + require_super_admin(request) + message = await _models(session).set_default(model_id) + if message: + return JavaJSONResponse(envelope(None, code=500, msg=message)) + await _refresh_server_config(session) + return ok() + + +@model_router.get("/models/{model_id}") +async def model_get( + model_id: str, request: Request, session: AsyncSession = Depends(get_db) +) -> JavaJSONResponse: + require_super_admin(request) + return ok(await _models(session).get_model(model_id)) + + +@model_router.delete("/models/{model_id}") +async def model_delete( + model_id: str, request: Request, session: AsyncSession = Depends(get_db) +) -> JavaJSONResponse: + require_super_admin(request) + await _models(session).delete(model_id) + return ok() diff --git a/main/manager-api-fastapi/app/routers/security.py b/main/manager-api-fastapi/app/routers/security.py new file mode 100644 index 00000000..d74d204d --- /dev/null +++ b/main/manager-api-fastapi/app/routers/security.py @@ -0,0 +1,111 @@ +# ruff: noqa: B008 +# FastAPI evaluates dependency marker defaults intentionally when registering routes. +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.responses import Response + +from app.core.database import get_db +from app.core.errors import AppError +from app.core.responses import JavaJSONResponse, ok +from app.core.security import require_normal +from app.repositories.security import SecurityRepository +from app.schemas.security import ( + LoginRequest, + PasswordChangeRequest, + RetrievePasswordRequest, + SmsVerificationRequest, +) +from app.services.security import CaptchaService, SecurityService + +security_router = APIRouter() + + +def _service(session: AsyncSession) -> SecurityService: + return SecurityService(SecurityRepository(session)) + + +@security_router.get("/user/captcha") +async def captcha(uuid: str | None = Query(default=None)) -> Response: + if uuid is None or not uuid.strip(): + raise AppError(10006) + content = await CaptchaService().create(uuid) + return Response( + content, + media_type="image/gif", + headers={ + "Pragma": "No-cache", + "Cache-Control": "no-cache", + "Expires": "Thu, 01 Jan 1970 00:00:00 GMT", + }, + ) + + +@security_router.post("/user/smsVerification") +async def sms_verification(dto: SmsVerificationRequest, session: AsyncSession = Depends(get_db)) -> JavaJSONResponse: + await _service(session).send_sms_verification(dto) + return ok() + + +@security_router.post("/user/login") +async def login( + dto: LoginRequest, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + return ok(await _service(session).login(dto, request)) + + +@security_router.post("/user/register") +async def register(dto: LoginRequest, session: AsyncSession = Depends(get_db)) -> JavaJSONResponse: + await _service(session).register(dto) + return ok() + + +@security_router.get("/user/info") +async def info(request: Request) -> JavaJSONResponse: + user = require_normal(request) + return ok( + { + "id": user.id, + "username": user.username, + "superAdmin": user.super_admin, + "token": user.token, + "status": user.status, + } + ) + + +@security_router.put("/user/change-password") +async def change_password( + dto: PasswordChangeRequest, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + await _service(session).change_password( + require_normal(request), + dto, + request.headers.get("Accept-Language"), + ) + return ok() + + +@security_router.put("/user/retrieve-password") +async def retrieve_password( + dto: RetrievePasswordRequest, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + await _service(session).retrieve_password(dto, request.headers.get("Accept-Language")) + return ok() + + +@security_router.get("/user/pub-config") +async def public_config(session: AsyncSession = Depends(get_db)) -> JavaJSONResponse: + return ok(await _service(session).public_config()) + + +@security_router.get("/api/ping") +async def api_ping() -> JavaJSONResponse: + return ok("pong") diff --git a/main/manager-api-fastapi/app/routers/sys.py b/main/manager-api-fastapi/app/routers/sys.py new file mode 100644 index 00000000..9638a310 --- /dev/null +++ b/main/manager-api-fastapi/app/routers/sys.py @@ -0,0 +1,334 @@ +# ruff: noqa: B008 +# FastAPI evaluates dependency and body marker defaults intentionally when registering routes. +from __future__ import annotations + +from fastapi import APIRouter, Body, Depends, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.errors import AppError +from app.core.responses import JavaJSONResponse, envelope, ok +from app.core.security import require_normal, require_super_admin +from app.repositories.sys import SysRepository +from app.schemas.sys import DictDataPayload, DictTypePayload, EmitServerActionRequest, SysParamPayload +from app.services.sys import AdminService, DictService, ServerActionService, SysParamService + +sys_router = APIRouter() + + +def _repository(session: AsyncSession) -> SysRepository: + return SysRepository(session) + + +def _admin(session: AsyncSession) -> AdminService: + return AdminService(_repository(session)) + + +def _params(session: AsyncSession) -> SysParamService: + return SysParamService(_repository(session)) + + +def _dict(session: AsyncSession) -> DictService: + return DictService(_repository(session)) + + +async def _refresh_server_config(session: AsyncSession) -> None: + from app.repositories.config import ConfigRepository + from app.services.config import ConfigService + + await ConfigService(ConfigRepository(session)).get_config(use_cache=False) + + +@sys_router.get("/admin/users") +async def page_users( + request: Request, + mobile: str | None = None, + page: str = Query(default="1"), + limit: str = Query(default="10"), + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + require_super_admin(request) + try: + current, size = int(page), int(limit) + except ValueError as exc: + # Java parses these Map-backed values inside the service; malformed + # numbers therefore reach its generic code=500 handler rather than + # Bean Validation. + raise AppError(500, "排序值不能小于0") from exc + return ok(await _admin(session).page_users(mobile=mobile, page=current, limit=size)) + + +@sys_router.put("/admin/users/{user_id}") +async def reset_user_password( + user_id: int, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + user = require_super_admin(request) + return ok(await _admin(session).reset_password(user_id, user)) + + +@sys_router.delete("/admin/users/{user_id}") +async def delete_user( + user_id: int, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + require_super_admin(request) + await _admin(session).delete_user(user_id) + return ok() + + +@sys_router.put("/admin/users/changeStatus/{status}") +async def change_user_status( + status: int, + request: Request, + user_ids: list[str] = Body(), + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + user = require_super_admin(request) + await _admin(session).change_status(status, user_ids, user) + return ok() + + +@sys_router.get("/admin/device/all") +async def page_all_devices( + request: Request, + keywords: str | None = None, + page: int = Query(default=1, ge=0), + limit: int = Query(default=10, ge=0), + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + require_super_admin(request) + return ok(await _admin(session).page_devices(keywords=keywords, page=page, limit=limit)) + + +@sys_router.get("/admin/server/server-list") +async def websocket_server_list(request: Request, session: AsyncSession = Depends(get_db)) -> JavaJSONResponse: + require_super_admin(request) + params = _params(session) + return ok(await ServerActionService(params).server_list()) + + +@sys_router.post("/admin/server/emit-action") +async def emit_server_action( + dto: EmitServerActionRequest, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + require_super_admin(request) + return ok(await ServerActionService(_params(session)).emit(dto)) + + +@sys_router.get("/admin/params/page") +async def page_params( + request: Request, + page: int = Query(default=1, ge=0), + limit: int = Query(default=10, ge=0), + order_field: str | None = Query(default=None, alias="orderField"), + order: str | None = None, + param_code: str | None = Query(default=None, alias="paramCode"), + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + require_super_admin(request) + return ok( + await _params(session).page( + param_code=param_code, + page=page, + limit=limit, + order_field=order_field, + order=order, + ) + ) + + +@sys_router.get("/admin/params/{param_id}") +async def get_param( + param_id: int, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + require_super_admin(request) + return ok(await _params(session).get(param_id)) + + +@sys_router.post("/admin/params") +async def save_param( + dto: SysParamPayload, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + await _params(session).save( + dto, + require_super_admin(request), + request.headers.get("Accept-Language"), + ) + await _refresh_server_config(session) + return ok() + + +@sys_router.put("/admin/params") +async def update_param( + dto: SysParamPayload, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + await _params(session).update( + dto, + require_super_admin(request), + request.headers.get("Accept-Language"), + ) + await _refresh_server_config(session) + return ok() + + +@sys_router.post("/admin/params/delete") +async def delete_params( + request: Request, + ids: list[str] = Body(), + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + require_super_admin(request) + await _params(session).delete(ids) + await _refresh_server_config(session) + return ok() + + +@sys_router.get("/admin/dict/type/page") +async def page_dict_types( + request: Request, + dict_type: str | None = Query(default=None, alias="dictType"), + dict_name: str | None = Query(default=None, alias="dictName"), + page: int = Query(default=1, ge=0), + limit: int = Query(default=10, ge=0), + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + require_super_admin(request) + return ok( + await _dict(session).page_types( + dict_type=dict_type, + dict_name=dict_name, + page=page, + limit=limit, + ) + ) + + +@sys_router.get("/admin/dict/type/{type_id}") +async def get_dict_type( + type_id: int, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + require_super_admin(request) + return ok(await _dict(session).get_type(type_id)) + + +@sys_router.post("/admin/dict/type/save") +async def save_dict_type( + dto: DictTypePayload, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + await _dict(session).save_type(dto, require_super_admin(request)) + return ok() + + +@sys_router.put("/admin/dict/type/update") +async def update_dict_type( + dto: DictTypePayload, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + await _dict(session).update_type(dto, require_super_admin(request)) + return ok() + + +@sys_router.post("/admin/dict/type/delete") +async def delete_dict_types( + request: Request, + ids: list[int] = Body(), + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + require_super_admin(request) + await _dict(session).delete_types(ids) + return ok() + + +@sys_router.get("/admin/dict/data/page") +async def page_dict_data( + request: Request, + dict_type_id: str | None = Query(default=None, alias="dictTypeId"), + dict_label: str | None = Query(default=None, alias="dictLabel"), + dict_value: str | None = Query(default=None, alias="dictValue"), + page: int = Query(default=1, ge=0), + limit: int = Query(default=10, ge=0), + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + require_super_admin(request) + if dict_type_id is None or not dict_type_id: + return JavaJSONResponse(envelope(None, code=500, msg="dictTypeId不能为空")) + try: + parsed_type_id = int(dict_type_id) + except ValueError as exc: + raise AppError(500) from exc + return ok( + await _dict(session).page_data( + dict_type_id=parsed_type_id, + dict_label=dict_label, + dict_value=dict_value, + page=page, + limit=limit, + ) + ) + + +@sys_router.get("/admin/dict/data/type/{dict_type}") +async def dict_items( + dict_type: str, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + require_normal(request) + return ok(await _dict(session).items(dict_type)) + + +@sys_router.get("/admin/dict/data/{data_id}") +async def get_dict_data( + data_id: int, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + require_super_admin(request) + return ok(await _dict(session).get_data(data_id)) + + +@sys_router.post("/admin/dict/data/save") +async def save_dict_data( + dto: DictDataPayload, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + await _dict(session).save_data(dto, require_super_admin(request)) + return ok() + + +@sys_router.put("/admin/dict/data/update") +async def update_dict_data( + dto: DictDataPayload, + request: Request, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + await _dict(session).update_data(dto, require_super_admin(request)) + return ok() + + +@sys_router.post("/admin/dict/data/delete") +async def delete_dict_data( + request: Request, + ids: list[int] = Body(), + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + require_super_admin(request) + await _dict(session).delete_data(ids) + return ok() diff --git a/main/manager-api-fastapi/app/routers/timbre.py b/main/manager-api-fastapi/app/routers/timbre.py new file mode 100644 index 00000000..ffa51fc7 --- /dev/null +++ b/main/manager-api-fastapi/app/routers/timbre.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.responses import JavaJSONResponse, ok +from app.core.security import require_normal, require_super_admin +from app.repositories.timbre import TimbreRepository +from app.schemas.timbre import TimbreBody +from app.services.timbre import TimbreService + +timbre_router = APIRouter() + + +def _service(session: AsyncSession) -> TimbreService: + return TimbreService(TimbreRepository(session)) + + +@timbre_router.get("/ttsVoice") +async def timbre_page( + request: Request, + tts_model_id: str | None = Query(default=None, alias="ttsModelId"), + name: str | None = None, + page: str | None = None, + limit: str | None = None, + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + require_super_admin(request) + return ok( + await _service(session).page( + tts_model_id, name, page, limit, request.headers.get("Accept-Language") + ) + ) + + +@timbre_router.post("/ttsVoice") +async def timbre_save( + body: TimbreBody, request: Request, session: AsyncSession = Depends(get_db) +) -> JavaJSONResponse: + await _service(session).save( + body, require_super_admin(request), request.headers.get("Accept-Language") + ) + return ok() + + +@timbre_router.put("/ttsVoice/{timbre_id}") +async def timbre_update( + timbre_id: str, body: TimbreBody, request: Request, session: AsyncSession = Depends(get_db) +) -> JavaJSONResponse: + await _service(session).update( + timbre_id, body, require_super_admin(request), request.headers.get("Accept-Language") + ) + return ok() + + +@timbre_router.post("/ttsVoice/delete") +async def timbre_delete( + ids: list[str], request: Request, session: AsyncSession = Depends(get_db) +) -> JavaJSONResponse: + require_super_admin(request) + await _service(session).delete(ids) + return ok() + + +@timbre_router.get("/models/{model_id}/voices") +async def model_voices( + model_id: str, + request: Request, + voice_name: str | None = Query(default=None, alias="voiceName"), + session: AsyncSession = Depends(get_db), +) -> JavaJSONResponse: + user = require_normal(request) + return ok(await _service(session).voices(model_id, voice_name, user, request.headers.get("Accept-Language"))) diff --git a/main/manager-api-fastapi/app/routers/voiceclone.py b/main/manager-api-fastapi/app/routers/voiceclone.py new file mode 100644 index 00000000..d9841f43 --- /dev/null +++ b/main/manager-api-fastapi/app/routers/voiceclone.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, File, Form, Request, UploadFile +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.responses import Response + +from app.core.database import get_db +from app.core.errors import AppError +from app.core.i18n import message_for +from app.core.responses import JavaJSONResponse, error_response, ok +from app.core.security import require_normal, require_super_admin +from app.schemas.voiceclone import VoiceCloneRenameRequest, VoiceCloneTrainRequest, VoiceResourceCreateRequest +from app.services.voiceclone import VoiceCloneService + +voiceclone_router = APIRouter() +SessionDep = Annotated[AsyncSession, Depends(get_db)] +VoiceFile = Annotated[UploadFile, File(alias="voiceFile")] +VoiceIdForm = Annotated[str, Form(alias="id")] + + +def _query_map(request: Request) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in request.query_params.multi_items(): + if key in result: + previous = result[key] + result[key] = [*previous, value] if isinstance(previous, list) else [previous, value] + else: + result[key] = value + return result + + +# Static voiceResource paths deliberately precede /voiceResource/{id}. +@voiceclone_router.get("/voiceResource/ttsPlatforms") +async def tts_platforms( + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + require_super_admin(request) + return ok(await VoiceCloneService(session).tts_platforms()) + + +@voiceclone_router.get("/voiceResource/user/{user_id}") +async def voice_resources_by_user( + user_id: int, + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + require_normal(request) + return ok(await VoiceCloneService(session).get_by_user(user_id)) + + +@voiceclone_router.get("/voiceResource") +async def page_voice_resources( + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + require_super_admin(request) + return ok(await VoiceCloneService(session).page(_query_map(request))) + + +@voiceclone_router.get("/voiceResource/{voice_id}") +async def get_voice_resource( + voice_id: str, + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + require_super_admin(request) + return ok(await VoiceCloneService(session).get_detail(voice_id)) + + +@voiceclone_router.post("/voiceResource") +async def create_voice_resource( + request: Request, + session: SessionDep, + body: VoiceResourceCreateRequest | None = None, +) -> JavaJSONResponse: + user = require_super_admin(request) + if body is None: + return error_response(request, 10145) + if body.model_id is None or body.model_id == "": + return error_response(request, 10146) + if not body.voice_ids: + return error_response(request, 10147) + if body.user_id is None: + return error_response(request, 10148) + try: + await VoiceCloneService(session).create_resources(body, actor=user) + return ok() + except AppError: + raise + except RuntimeError as exc: + return error_response(request, 10065, str(exc)) + + +@voiceclone_router.delete("/voiceResource/{voice_id}") +async def delete_voice_resource( + voice_id: str, + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + require_super_admin(request) + ids = voice_id.split(",") if voice_id else [] + if not ids: + return error_response(request, 10149) + await VoiceCloneService(session).delete(ids) + return ok() + + +@voiceclone_router.get("/voiceClone") +async def page_voice_clones( + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + user = require_normal(request) + return ok(await VoiceCloneService(session).page(_query_map(request), user_id=user.id)) + + +@voiceclone_router.post("/voiceClone/upload") +async def upload_voice_clone( + request: Request, + session: SessionDep, + voice_file: VoiceFile, + voice_id: VoiceIdForm = "", +) -> JavaJSONResponse: + user = require_normal(request) + service = VoiceCloneService(session) + try: + content = await voice_file.read() + if not content: + return error_response(request, 10140) + content_type = voice_file.content_type + if content_type is None or not content_type.startswith("audio/"): + return error_response(request, 10141) + filename = voice_file.filename + if filename is None or "." not in filename: + raise RuntimeError("文件名缺少扩展名") + extension = filename[filename.rfind(".") :].lower() + if extension not in {".mp3", ".wav"}: + return error_response(request, 500, "只允许上传.mp3和.wav格式的文件") + if len(content) > 10 * 1024 * 1024: + return error_response(request, 10142) + await service.check_permission(voice_id, user) + await service.upload_voice(voice_id, content) + return ok() + except Exception as exc: + if isinstance(exc, AppError): + message = exc.message or message_for(exc.code, request.headers.get("Accept-Language")) + else: + message = str(exc) + return error_response(request, 10143, message) + + +@voiceclone_router.post("/voiceClone/updateName") +async def update_voice_clone_name( + body: VoiceCloneRenameRequest, + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + user = require_normal(request) + if body.id is None or body.id == "": + return error_response(request, 10006) + if body.name is None or body.name == "": + return error_response(request, 10181) + service = VoiceCloneService(session) + try: + await service.check_permission(body.id, user) + await service.rename(body.id or "", body.name or "") + return ok() + except Exception as exc: + if isinstance(exc, AppError): + message = exc.message or message_for(exc.code, request.headers.get("Accept-Language")) + else: + message = str(exc) + return error_response(request, 10066, message) + + +@voiceclone_router.post("/voiceClone/audio/{voice_id}") +async def get_voice_clone_audio_id( + voice_id: str, + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + user = require_normal(request) + service = VoiceCloneService(session) + await service.check_permission(voice_id, user) + return ok(await service.create_audio_id(voice_id)) + + +@voiceclone_router.get("/voiceClone/play/{download_id}") +async def play_voice_clone(download_id: str, session: SessionDep) -> Response: + try: + content = await VoiceCloneService(session).consume_audio(download_id) + if content is None: + return Response(status_code=404) + return Response( + content, + media_type="audio/wav", + headers={ + "Content-Length": str(len(content)), + "Content-Disposition": "inline; filename=voice.wav", + }, + ) + except Exception: + return Response(status_code=500) + + +@voiceclone_router.post("/voiceClone/cloneAudio") +async def train_voice_clone( + body: VoiceCloneTrainRequest, + request: Request, + session: SessionDep, +) -> JavaJSONResponse: + user = require_normal(request) + service = VoiceCloneService(session) + await service.check_permission(body.clone_id, user) + await service.clone_audio( + body.clone_id or "", + accept_language=request.headers.get("Accept-Language"), + ) + return ok() diff --git a/main/manager-api-fastapi/app/schemas/__init__.py b/main/manager-api-fastapi/app/schemas/__init__.py new file mode 100644 index 00000000..4180682f --- /dev/null +++ b/main/manager-api-fastapi/app/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic request and response schemas.""" diff --git a/main/manager-api-fastapi/app/schemas/agent.py b/main/manager-api-fastapi/app/schemas/agent.py new file mode 100644 index 00000000..7f8239fc --- /dev/null +++ b/main/manager-api-fastapi/app/schemas/agent.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import json +from datetime import datetime +from typing import Any + +from pydantic import Field, field_validator +from pydantic_core import PydanticCustomError + +from app.schemas.common import JavaModel + + +class AgentCreate(JavaModel): + agent_name: str + + @field_validator("agent_name", mode="before") + @classmethod + def require_non_blank_name(cls, value: Any) -> Any: + if value is None or isinstance(value, str) and not value.strip(): + raise PydanticCustomError("java_not_blank", "智能体名称不能为空") + return value + + +class AgentMemory(JavaModel): + summary_memory: str | None = None + + +class ContextProvider(JavaModel): + url: str | None = None + headers: dict[str, Any] | None = None + + +class FunctionInfo(JavaModel): + plugin_id: str | None = None + param_info: dict[str, Any] = Field(default_factory=dict) + + @field_validator("param_info", mode="before") + @classmethod + def normalize_param_info(cls, value: Any) -> dict[str, Any]: + if value is None or value == "": + return {} + if isinstance(value, str): + parsed = json.loads(value) + if not isinstance(parsed, dict): + raise ValueError("paramInfo must be a JSON object") + return {str(key): item for key, item in parsed.items()} + if isinstance(value, dict): + return {str(key): item for key, item in value.items() if key is not None} + parsed = json.loads(json.dumps(value)) + if not isinstance(parsed, dict): + raise ValueError("paramInfo must be an object") + return {str(key): item for key, item in parsed.items()} + + +class AgentUpdate(JavaModel): + agent_code: str | None = None + agent_name: str | None = None + asr_model_id: str | None = None + vad_model_id: str | None = None + llm_model_id: str | None = None + slm_model_id: str | None = None + vllm_model_id: str | None = None + tts_model_id: str | None = None + tts_voice_id: str | None = None + tts_language: str | None = None + tts_volume: int | None = None + tts_rate: int | None = None + tts_pitch: int | None = None + mem_model_id: str | None = None + intent_model_id: str | None = None + functions: list[FunctionInfo] | None = None + system_prompt: str | None = None + summary_memory: str | None = None + chat_history_conf: int | None = None + lang_code: str | None = None + language: str | None = None + sort: int | None = None + context_providers: list[ContextProvider] | None = None + correct_word_file_ids: list[str] | None = None + tag_names: list[str] | None = None + tag_ids: list[str] | None = None + + +class AgentChatHistoryReport(JavaModel): + mac_address: str + session_id: str + chat_type: int + content: str + audio_base64: str | None = None + report_time: int | None = None + + @field_validator("mac_address", "session_id", "content", mode="before") + @classmethod + def require_non_blank(cls, value: Any) -> Any: + if value is None or isinstance(value, str) and not value.strip(): + raise PydanticCustomError("java_not_blank", "不能为空") + return value + + @field_validator("chat_type", mode="before") + @classmethod + def require_chat_type(cls, value: Any) -> Any: + if value is None: + raise PydanticCustomError("java_not_null", "不能为空") + return value + + +class AgentSnapshotPage(JavaModel): + page: int | None = 1 + limit: int | None = 10 + max_version_no: int | None = None + + def page_or_default(self) -> int: + return self.page if self.page is not None and self.page >= 1 else 1 + + def limit_or_default(self) -> int: + return self.limit if self.limit is not None and self.limit >= 1 else 10 + + +class AgentSnapshotRestore(JavaModel): + current_state_token: str + + @field_validator("current_state_token", mode="before") + @classmethod + def require_non_blank_token(cls, value: Any) -> Any: + if value is None or isinstance(value, str) and not value.strip(): + raise PydanticCustomError("java_not_blank", "不能为空") + return value + + +class AgentSnapshotTag(JavaModel): + id: str | None = None + tag_name: str | None = None + sort: int | None = None + + +class AgentSnapshotData(JavaModel): + agent_code: str | None = None + agent_name: str | None = None + asr_model_id: str | None = None + vad_model_id: str | None = None + llm_model_id: str | None = None + slm_model_id: str | None = None + vllm_model_id: str | None = None + tts_model_id: str | None = None + tts_voice_id: str | None = None + tts_language: str | None = None + tts_volume: int | None = None + tts_rate: int | None = None + tts_pitch: int | None = None + mem_model_id: str | None = None + intent_model_id: str | None = None + chat_history_conf: int | None = None + system_prompt: str | None = None + summary_memory: str | None = None + lang_code: str | None = None + language: str | None = None + sort: int | None = None + functions: list[FunctionInfo] | None = None + context_providers: list[ContextProvider] | None = None + correct_word_file_ids: list[str] | None = None + tag_names: list[str] | None = None + tags: list[AgentSnapshotTag] | None = None + + +class AgentTemplate(JavaModel): + id: str | None = None + agent_code: str | None = None + agent_name: str | None = None + asr_model_id: str | None = None + vad_model_id: str | None = None + llm_model_id: str | None = None + vllm_model_id: str | None = None + tts_model_id: str | None = None + tts_voice_id: str | None = None + tts_language: str | None = None + tts_volume: int | None = None + tts_rate: int | None = None + tts_pitch: int | None = None + mem_model_id: str | None = None + intent_model_id: str | None = None + chat_history_conf: int | None = None + system_prompt: str | None = None + summary_memory: str | None = None + lang_code: str | None = None + language: str | None = None + sort: int | None = None + creator: int | None = None + created_at: datetime | None = None + updater: int | None = None + updated_at: datetime | None = None + + +class AgentVoicePrintSave(JavaModel): + agent_id: str | None = None + audio_id: str | None = None + source_name: str | None = None + introduce: str | None = None + + +class AgentVoicePrintUpdate(JavaModel): + id: str | None = None + audio_id: str | None = None + source_name: str | None = None + introduce: str | None = None + + +class AgentTagAssignment(JavaModel): + tag_ids: list[str] | None = None + tag_names: list[str] | None = None + + +SNAPSHOT_FIELD_ORDER = [ + "agentCode", + "agentName", + "asrModelId", + "vadModelId", + "llmModelId", + "slmModelId", + "vllmModelId", + "ttsModelId", + "ttsVoiceId", + "ttsLanguage", + "ttsVolume", + "ttsRate", + "ttsPitch", + "memModelId", + "intentModelId", + "chatHistoryConf", + "systemPrompt", + "summaryMemory", + "langCode", + "language", + "sort", + "functions", + "contextProviders", + "correctWordFileIds", + "tagNames", +] diff --git a/main/manager-api-fastapi/app/schemas/common.py b/main/manager-api-fastapi/app/schemas/common.py new file mode 100644 index 00000000..694570d9 --- /dev/null +++ b/main/manager-api-fastapi/app/schemas/common.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Generic, TypeVar + +from pydantic import BaseModel, ConfigDict, Field + +T = TypeVar("T") + + +def to_camel(value: str) -> str: + head, *tail = value.split("_") + return head + "".join(part[:1].upper() + part[1:] for part in tail) + + +class JavaModel(BaseModel): + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + extra="ignore", + str_strip_whitespace=False, + serialize_by_alias=True, + ) + + +class PageData(JavaModel, Generic[T]): + total: int + list: list[T] + + +class PageQuery(JavaModel): + page: int = Field(default=1, ge=1) + limit: int = Field(default=10, ge=1) + order_field: str | list[str] | None = None + order: str | None = None + + +class DeleteIds(JavaModel): + ids: list[str] + + +def page_payload(rows: list[Any], total: int) -> dict[str, Any]: + return {"total": int(total), "list": rows} + + +def safe_order_by( + requested: str | list[str] | None, + *, + allowed: set[str], + default: str, + transform: Callable[[str], str] | None = None, +) -> list[str]: + fields = [requested] if isinstance(requested, str) else list(requested or []) + selected = [field for field in fields if field in allowed] + if not selected: + selected = [default] + return [transform(field) if transform else field for field in selected] diff --git a/main/manager-api-fastapi/app/schemas/config.py b/main/manager-api-fastapi/app/schemas/config.py new file mode 100644 index 00000000..7bbbc09c --- /dev/null +++ b/main/manager-api-fastapi/app/schemas/config.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from pydantic import field_validator + +from app.schemas.common import JavaModel + + +def _not_blank(value: str) -> str: + if not value or not value.strip(): + raise ValueError("must not be blank") + return value + + +class AgentModelsRequest(JavaModel): + mac_address: str + client_id: str + selected_module: dict[str, str] + + _validate_required = field_validator("mac_address", "client_id")(_not_blank) + + +class CorrectWordsRequest(JavaModel): + mac_address: str + + _validate_required = field_validator("mac_address")(_not_blank) diff --git a/main/manager-api-fastapi/app/schemas/correctword.py b/main/manager-api-fastapi/app/schemas/correctword.py new file mode 100644 index 00000000..d7a094e3 --- /dev/null +++ b/main/manager-api-fastapi/app/schemas/correctword.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from app.schemas.common import JavaModel + + +class CorrectWordFileBody(JavaModel): + file_name: str | None = None + content: list[str] | None = None + file_size: int | None = None diff --git a/main/manager-api-fastapi/app/schemas/device.py b/main/manager-api-fastapi/app/schemas/device.py new file mode 100644 index 00000000..9a142ecb --- /dev/null +++ b/main/manager-api-fastapi/app/schemas/device.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import AliasChoices, Field + +from app.schemas.common import JavaModel + + +class DeviceRegisterRequest(JavaModel): + mac_address: str | None = None + + +class DeviceUnbindRequest(JavaModel): + device_id: str | None = None + + +class DeviceUpdateRequest(JavaModel): + auto_update: int | None = None + alias: str | None = None + + +class DeviceManualAddRequest(JavaModel): + agent_id: str | None = None + board: str | None = None + app_version: str | None = None + mac_address: str | None = None + + +class DeviceToolCallRequest(JavaModel): + name: str | None = None + arguments: dict[str, Any] | None = None + + +class DeviceAddressBookAliasRequest(JavaModel): + mac_address: str | None = None + target_mac: str | None = None + alias: str | None = None + + +class DeviceAddressBookPermissionRequest(JavaModel): + mac_address: str | None = None + target_mac: str | None = None + has_permission: bool | None = None + + +class ChipInfo(JavaModel): + model: int | None = None + cores: int | None = None + revision: int | None = None + features: int | None = None + + +class ApplicationInfo(JavaModel): + name: str | None = None + version: str | None = None + compile_time: str | None = Field( + default=None, + validation_alias=AliasChoices("compile_time", "compileTime"), + serialization_alias="compile_time", + ) + idf_version: str | None = Field( + default=None, + validation_alias=AliasChoices("idf_version", "idfVersion"), + serialization_alias="idf_version", + ) + elf_sha256: str | None = Field( + default=None, + validation_alias=AliasChoices("elf_sha256", "elfSha256"), + serialization_alias="elf_sha256", + ) + + +class PartitionInfo(JavaModel): + label: str | None = None + type: int | None = None + subtype: int | None = None + address: int | None = None + size: int | None = None + + +class OtaPartitionInfo(JavaModel): + label: str | None = None + + +class BoardInfo(JavaModel): + type: str | None = None + ssid: str | None = None + rssi: int | None = None + channel: int | None = None + ip: str | None = None + mac: str | None = None + + +class DeviceReportRequest(JavaModel): + version: int | None = None + flash_size: int | None = Field( + default=None, + validation_alias=AliasChoices("flash_size", "flashSize"), + serialization_alias="flash_size", + ) + minimum_free_heap_size: int | None = Field( + default=None, + validation_alias=AliasChoices("minimum_free_heap_size", "minimumFreeHeapSize"), + serialization_alias="minimum_free_heap_size", + ) + mac_address: str | None = Field( + default=None, + validation_alias=AliasChoices("mac_address", "macAddress"), + serialization_alias="mac_address", + ) + uuid: str | None = None + chip_model_name: str | None = Field( + default=None, + validation_alias=AliasChoices("chip_model_name", "chipModelName"), + serialization_alias="chip_model_name", + ) + chip_info: ChipInfo | None = Field( + default=None, + validation_alias=AliasChoices("chip_info", "chipInfo"), + serialization_alias="chip_info", + ) + application: ApplicationInfo | None = None + partition_table: list[PartitionInfo] | None = Field( + default=None, + validation_alias=AliasChoices("partition_table", "partitionTable"), + serialization_alias="partition_table", + ) + ota: OtaPartitionInfo | None = None + board: BoardInfo | None = None + + +class OtaRecord(JavaModel): + id: str | None = None + firmware_name: str | None = None + type: str | None = None + version: str | None = None + size: int | None = None + remark: str | None = None + firmware_path: str | None = None + sort: int | None = None + updater: int | None = None + update_date: str | None = None + creator: int | None = None + create_date: str | None = None diff --git a/main/manager-api-fastapi/app/schemas/knowledge.py b/main/manager-api-fastapi/app/schemas/knowledge.py new file mode 100644 index 00000000..a45318f3 --- /dev/null +++ b/main/manager-api-fastapi/app/schemas/knowledge.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import AliasChoices, Field + +from app.schemas.common import JavaModel + + +class KnowledgeBaseBody(JavaModel): + id: str | None = None + dataset_id: str | None = None + rag_model_id: str | None = None + name: str | None = None + avatar: str | None = None + description: str | None = None + embedding_model: str | None = None + permission: str | None = None + chunk_method: str | None = None + parser_config: str | None = None + chunk_count: int | None = None + token_num: int | None = None + status: int | None = None + creator: int | None = None + created_at: datetime | None = None + updater: int | None = None + updated_at: datetime | None = None + document_count: int | None = None + error_message: str | None = None + + +class DocumentBatchBody(JavaModel): + ids: list[str] | None = Field( + default=None, + validation_alias=AliasChoices("ids", "document_ids"), + ) + + +class RetrievalBody(JavaModel): + dataset_ids: list[str] | None = None + document_ids: list[str] | None = None + question: str | None = None + page: int | None = None + page_size: int | None = None + similarity_threshold: float | None = None + vector_similarity_weight: float | None = None + top_k: int | None = None + rerank_id: str | None = None + highlight: bool | None = None + keyword: bool | None = None + cross_languages: list[str] | None = None + metadata_condition: dict[str, Any] | None = None diff --git a/main/manager-api-fastapi/app/schemas/model.py b/main/manager-api-fastapi/app/schemas/model.py new file mode 100644 index 00000000..c8353ef7 --- /dev/null +++ b/main/manager-api-fastapi/app/schemas/model.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Any + +from app.schemas.common import JavaModel + + +class ModelConfigBody(JavaModel): + id: str | None = None + model_code: str | None = None + model_name: str | None = None + is_default: int | None = None + is_enabled: int | None = None + config_json: dict[str, Any] | None = None + doc_link: str | None = None + remark: str | None = None + sort: int | None = None + +class ModelProviderBody(JavaModel): + id: str | None = None + model_type: str | None = None + provider_code: str | None = None + name: str | None = None + fields: str | None = None + sort: int | None = None diff --git a/main/manager-api-fastapi/app/schemas/security.py b/main/manager-api-fastapi/app/schemas/security.py new file mode 100644 index 00000000..f188f4e7 --- /dev/null +++ b/main/manager-api-fastapi/app/schemas/security.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import Any + +from app.schemas.common import JavaModel + + +class LoginRequest(JavaModel): + # LoginController does not use @Valid; null/blank values reach its service logic. + username: str | None = None + password: str | None = None + mobile_captcha: str | None = None + captcha_id: str | None = None + + +class SmsVerificationRequest(JavaModel): + # smsVerification likewise omits @Valid in the Java controller. + phone: str | None = None + captcha: str | None = None + captcha_id: str | None = None + + +class PasswordChangeRequest(JavaModel): + password: str | None = None + new_password: str | None = None + + +class RetrievePasswordRequest(JavaModel): + phone: str | None = None + code: str | None = None + password: str | None = None + captcha_id: str | None = None + + +class TokenData(JavaModel): + token: str + expire: int + client_hash: str | None + + +class UserDetailData(JavaModel): + id: int + username: str + super_admin: int + token: str + status: int + + +class PublicConfigData(JavaModel): + enable_mobile_register: bool + version: str + year: str + allow_user_register: bool + mobile_area_list: list[dict[str, Any]] + beian_icp_num: str | None + beian_ga_num: str | None + name: str | None + sm2_public_key: str + system_web_menu: Any | None = None diff --git a/main/manager-api-fastapi/app/schemas/sys.py b/main/manager-api-fastapi/app/schemas/sys.py new file mode 100644 index 00000000..642b4eef --- /dev/null +++ b/main/manager-api-fastapi/app/schemas/sys.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from pydantic import field_validator + +from app.schemas.common import JavaModel + + +def _not_blank(value: str) -> str: + if not value or not value.strip(): + raise ValueError("must not be blank") + return value + + +class SysParamPayload(JavaModel): + id: int | None = None + param_code: str | None = None + param_value: str | None = None + value_type: str | None = None + remark: str | None = None + + +class DictTypePayload(JavaModel): + # Controller calls ValidatorUtils without the DTO's custom groups, so these constraints don't execute in Java. + id: int | None = None + dict_type: str | None = None + dict_name: str | None = None + remark: str | None = None + sort: int | None = None + + +class DictDataPayload(JavaModel): + # See DictTypePayload: Add/Update/DefaultGroup annotations are skipped by the Java controller. + id: int | None = None + dict_type_id: int | None = None + dict_label: str | None = None + dict_value: str | None = None + remark: str | None = None + sort: int | None = None + + +class EmitServerActionRequest(JavaModel): + target_ws: str + action: str | None + + _validate_target = field_validator("target_ws")(_not_blank) diff --git a/main/manager-api-fastapi/app/schemas/timbre.py b/main/manager-api-fastapi/app/schemas/timbre.py new file mode 100644 index 00000000..dd16c333 --- /dev/null +++ b/main/manager-api-fastapi/app/schemas/timbre.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from app.schemas.common import JavaModel + + +class TimbreBody(JavaModel): + languages: str | None = None + name: str | None = None + remark: str | None = None + reference_audio: str | None = None + reference_text: str | None = None + sort: int | None = 0 + tts_model_id: str | None = None + tts_voice: str | None = None + voice_demo: str | None = None diff --git a/main/manager-api-fastapi/app/schemas/voiceclone.py b/main/manager-api-fastapi/app/schemas/voiceclone.py new file mode 100644 index 00000000..00bee764 --- /dev/null +++ b/main/manager-api-fastapi/app/schemas/voiceclone.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from app.schemas.common import JavaModel + + +class VoiceResourceCreateRequest(JavaModel): + model_id: str | None = None + voice_ids: list[str] | None = None + user_id: int | None = None + languages: str | None = None + + +class VoiceCloneRenameRequest(JavaModel): + id: str | None = None + name: str | None = None + + +class VoiceCloneTrainRequest(JavaModel): + clone_id: str | None = None diff --git a/main/manager-api-fastapi/app/services/__init__.py b/main/manager-api-fastapi/app/services/__init__.py new file mode 100644 index 00000000..b021e215 --- /dev/null +++ b/main/manager-api-fastapi/app/services/__init__.py @@ -0,0 +1 @@ +"""Business services.""" diff --git a/main/manager-api-fastapi/app/services/agent.py b/main/manager-api-fastapi/app/services/agent.py new file mode 100644 index 00000000..4f6ecef7 --- /dev/null +++ b/main/manager-api-fastapi/app/services/agent.py @@ -0,0 +1,1973 @@ +from __future__ import annotations + +import asyncio +import base64 +import copy +import hashlib +import json +import logging +import re +import time +import uuid +from collections import defaultdict +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import Any, NoReturn +from urllib.parse import unquote_plus, urlsplit +from zoneinfo import ZoneInfo + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import get_settings +from app.core.database import get_session_factory +from app.core.errors import AppError +from app.core.i18n import message_for +from app.core.ids import snowflake +from app.core.redis import distributed_lock, java_get, java_set +from app.core.security import AuthUser, shanghai_now_naive +from app.integrations.llm import SUMMARY_PROMPT, TITLE_PROMPT, openai_completion +from app.integrations.mcp import build_agent_mcp_address, list_mcp_tools +from app.integrations.voiceprint import VoicePrintClient, VoicePrintIntegrationError +from app.repositories.agent import AgentRepository +from app.schemas.agent import ( + SNAPSHOT_FIELD_ORDER, + AgentChatHistoryReport, + AgentCreate, + AgentMemory, + AgentSnapshotData, + AgentSnapshotPage, + AgentTemplate, + AgentUpdate, + AgentVoicePrintSave, + AgentVoicePrintUpdate, +) +from app.services.system_params import SystemParamService + +logger = logging.getLogger(__name__) + +MEMORY_NO_MEM = "Memory_nomem" +MEMORY_REPORT_ONLY = "Memory_mem_report_only" +MEMORY_MEM0AI = "Memory_mem0ai" +MEMORY_POWERMEM = "Memory_powermem" +SECRET_PLACEHOLDER = "__SNAPSHOT_SECRET_REDACTED__" # noqa: S105 - marker, not a credential +CURRENT_REDACTION_VERSION = 2 +MAX_SNAPSHOTS_PER_AGENT = 100 +LEGACY_REDACTION_BATCH_SIZE = 100 + +ERROR_AGENT_NOT_FOUND = 10053 +ERROR_VOICEPRINT_NOT_CONFIGURED = 10054 +ERROR_VOICEPRINT_CREATE_FAILED = 10057 +ERROR_VOICEPRINT_UPDATE_FAILED = 10058 +ERROR_VOICEPRINT_DELETE_FAILED = 10059 +ERROR_LLM_INTENT_MISMATCH = 10079 +ERROR_NO_PERMISSION = 10169 +ERROR_TAG_DUPLICATE = 10196 +ERROR_TAG_EMPTY = 10197 + +_DEVICE_CONTROL = re.compile("设备控制|设备操作|控制设备|设备状态", re.IGNORECASE) +_WEATHER = re.compile("天气|温度|湿度|降雨|气象", re.IGNORECASE) +_DATE_WORDS = re.compile("日期|时间|星期|月份|年份", re.IGNORECASE) +_TITLE_PUNCTUATION = re.compile("[,。!?、:;''\"“”【】()]") + + +def _uuid32() -> str: + return uuid.uuid4().hex + + +def _json_load(value: Any, default: Any) -> Any: + if value is None: + return copy.deepcopy(default) + if isinstance(value, bytes): + value = value.decode("utf-8") + if isinstance(value, str): + if not value.strip(): + return copy.deepcopy(default) + try: + return json.loads(value) + except json.JSONDecodeError: + return copy.deepcopy(default) + return copy.deepcopy(value) + + +def _json_dump(value: Any, *, sort_keys: bool = False) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=sort_keys) + + +def _as_api_row(row: Mapping[str, Any]) -> dict[str, Any]: + return dict(row) + + +def _first_language(value: Any) -> str | None: + if value is None: + return None + for item in re.split(r"[、;;,,]", str(value)): + if item.strip(): + return item.strip() + return None + + +def _cached_datetime(value: Any) -> datetime | None: + timezone = ZoneInfo(get_settings().timezone) + if isinstance(value, datetime): + return value.astimezone(timezone).replace(tzinfo=None) if value.tzinfo is not None else value + if ( + isinstance(value, list) + and len(value) == 2 + and value[0] == "java.util.Date" + and isinstance(value[1], int | float) + ): + return datetime.fromtimestamp(float(value[1]) / 1000, timezone).replace(tzinfo=None) + if isinstance(value, str): + try: + return datetime.strptime(value, "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + return None + + +class AgentService: + def __init__(self, session: AsyncSession, user: AuthUser | None = None, *, language: str | None = None): + self.session = session + self.repo = AgentRepository(session) + self.user = user + self.language = language + + @property + def actor_id(self) -> int: + return self.user.id if self.user is not None else 0 + + async def _rollback_and_raise(self, exception: Exception) -> NoReturn: + await self.session.rollback() + raise exception + + async def require_agent( + self, agent_id: str, *, permission: bool = True, for_update: bool = False + ) -> dict[str, Any]: + row = await self.repo.get_agent(agent_id, for_update=for_update) + if row is None: + raise AppError(ERROR_AGENT_NOT_FOUND) + if ( + permission + and self.user is not None + and not self.user.is_super_admin + and int(row.get("user_id") or 0) != self.user.id + ): + raise AppError(ERROR_NO_PERMISSION) + return row + + async def has_agent_permission(self, agent_id: str) -> bool: + if self.user is None: + return False + return await self.repo.check_agent_owner(agent_id, self.user.id, super_admin=self.user.is_super_admin) + + async def require_agent_permission(self, agent_id: str) -> None: + """Mirror AgentController.requireAgentPermission before loading a record.""" + if not await self.has_agent_permission(agent_id): + raise AppError(ERROR_NO_PERMISSION) + + async def require_snapshot_permission(self, agent_id: str) -> None: + """SnapshotController uses a domain-specific, non-i18n permission error.""" + if not await self.has_agent_permission(agent_id): + raise AppError(500, "没有权限访问该智能体快照") + + async def user_agents(self, keyword: str | None) -> list[dict[str, Any]]: + if self.user is None: + raise AppError(401) + rows = await self.repo.list_user_agents(self.user.id, keyword) + tags = await self.repo.get_tags_for_agents([str(row["id"]) for row in rows]) + tags_by_agent: dict[str, list[dict[str, Any]]] = defaultdict(list) + for tag in tags: + tags_by_agent[str(tag["agent_id"])].append({"id": tag["id"], "tagName": tag["tag_name"]}) + result: list[dict[str, Any]] = [] + for row in rows: + agent_id = str(row["id"]) + device_count = int(row.get("device_count") or 0) + last_connected_at = row.get("last_connected_at") + cached_count = await java_get(f"agent:device:count:{agent_id}") + if isinstance(cached_count, int) and not isinstance(cached_count, bool): + device_count = cached_count + else: + await java_set(f"agent:device:count:{agent_id}", device_count, 60) + cached_last_connected = _cached_datetime( + await java_get(f"agent:device:lastConnected:{agent_id}") + ) + if cached_last_connected is not None: + last_connected_at = cached_last_connected + elif isinstance(last_connected_at, datetime): + await java_set( + f"agent:device:lastConnected:{agent_id}", + last_connected_at, + 86400, + ) + voice_name = row.get("tts_voice_name") + if ( + voice_name + and row.get("tts_voice_id") + and not await self.repo.scalar("SELECT 1 FROM ai_tts_voice WHERE id=:id", {"id": row["tts_voice_id"]}) + ): + voice_name = f"{message_for(10158, self.language)}{voice_name}" + result.append( + { + "id": row["id"], + "agentName": row.get("agent_name"), + "ttsModelName": row.get("tts_model_name"), + "ttsVoiceName": voice_name, + "llmModelName": row.get("llm_model_name"), + "vllmModelName": row.get("vllm_model_name"), + "memModelId": row.get("mem_model_id"), + "systemPrompt": row.get("system_prompt"), + "summaryMemory": None, + "lastConnectedAt": last_connected_at, + "deviceCount": device_count, + "tags": tags_by_agent.get(str(row["id"])) or None, + } + ) + return result + + async def admin_agents(self, page: int, limit: int, order_field: str | None, order: str | None) -> dict[str, Any]: + rows, total = await self.repo.list_admin_agents( + page, limit, order_field or "agent_name", (order or "asc").lower() == "asc" + ) + return {"list": [_as_api_row(row) for row in rows], "total": total} + + async def agent_detail(self, agent_id: str, *, permission: bool = True) -> dict[str, Any]: + agent = await self.require_agent(agent_id, permission=permission) + result = _as_api_row(agent) + if result.get("mem_model_id") == MEMORY_NO_MEM: + result["chat_history_conf"] = 0 + elif result.get("chat_history_conf") is None: + result["chat_history_conf"] = 2 + plugins = await self.repo.get_agent_plugins(agent_id) + result["functions"] = [ + { + "id": row["id"], + "agent_id": row["agent_id"], + "plugin_id": row["plugin_id"], + "param_info": row.get("param_info"), + } + for row in plugins + ] + context = await self.repo.get_context_provider(agent_id) + result["context_providers"] = _json_load(context.get("context_providers") if context else None, []) + result["correct_word_file_ids"] = await self.repo.get_correct_word_ids(agent_id) + result["current_version_no"] = await self.repo.snapshot_max_version(agent_id) + return result + + async def create_agent(self, dto: AgentCreate) -> str: + now = shanghai_now_naive() + agent_id = _uuid32() + values: dict[str, Any] = { + "id": agent_id, + "user_id": self.actor_id, + "agent_code": f"AGT_{int(time.time() * 1000)}", + "agent_name": dto.agent_name, + "sort": 0, + "creator": self.actor_id, + "created_at": now, + } + try: + template = await self.repo.get_default_template() + if template: + for column in ( + "asr_model_id", + "vad_model_id", + "llm_model_id", + "vllm_model_id", + "tts_model_id", + "tts_voice_id", + "mem_model_id", + "intent_model_id", + "system_prompt", + "summary_memory", + "lang_code", + "language", + ): + values[column] = template.get(column) + if values.get("tts_voice_id") is None and values.get("tts_model_id"): + model = await self.repo.get_model_config(str(values["tts_model_id"])) + config = _json_load(model.get("config_json") if model else None, {}) + voice = config.get("voice") or config.get("speaker") + if voice: + timbre = await self.repo.find_timbre_by_voice_code(str(values["tts_model_id"]), str(voice)) + if timbre: + values["tts_voice_id"] = timbre["id"] + timbre = await self.repo.get_timbre(str(values["tts_voice_id"]) if values.get("tts_voice_id") else None) + values["tts_language"] = template.get("tts_language") or _first_language( + timbre.get("languages") if timbre else None + ) + if values.get("mem_model_id") in {MEMORY_NO_MEM, MEMORY_REPORT_ONLY}: + values["summary_memory"] = "" + values["chat_history_conf"] = ( + 0 + if values.get("mem_model_id") == MEMORY_NO_MEM + else 2 + if values.get("mem_model_id") is not None + else template.get("chat_history_conf") + ) + default_llm = await self.repo.get_default_llm_config() + values["slm_model_id"] = default_llm.get("id") if default_llm else None + await self.repo.insert_agent(values) + default_plugins: list[dict[str, Any]] = [] + for plugin_id in ("SYSTEM_PLUGIN_MUSIC", "SYSTEM_PLUGIN_WEATHER", "SYSTEM_PLUGIN_NEWS_NEWSNOW"): + provider = await self.repo.get_model_provider(plugin_id) + if provider is None: + continue + param_info: dict[str, Any] = {} + for field in _json_load(provider.get("fields"), []): + if isinstance(field, dict) and field.get("key") is not None: + param_info[str(field["key"])] = field.get("default") + default_plugins.append( + { + "id": snowflake.next_id(), + "plugin_id": plugin_id, + "param_info": _json_dump(param_info), + } + ) + await self.repo.replace_plugins(agent_id, default_plugins) + await self._create_snapshot(agent_id, "initial") + await self.session.commit() + return agent_id + except Exception as exc: + await self._rollback_and_raise(exc) + + async def update_agent( + self, + agent_id: str, + dto: AgentUpdate, + *, + check_permission: bool = True, + create_snapshot: bool = True, + ) -> None: + try: + agent = await self.require_agent(agent_id, permission=check_permission, for_update=True) + if create_snapshot: + version = await self.repo.snapshot_max_version(agent_id) + await self._create_snapshot(agent_id, "initial" if version == 0 else "current") + payload = dto.model_dump(by_alias=False) + scalar_columns = { + key: value + for key, value in payload.items() + if key + in { + "agent_code", + "agent_name", + "asr_model_id", + "vad_model_id", + "llm_model_id", + "slm_model_id", + "vllm_model_id", + "tts_model_id", + "tts_voice_id", + "tts_language", + "tts_volume", + "tts_rate", + "tts_pitch", + "mem_model_id", + "intent_model_id", + "system_prompt", + "summary_memory", + "chat_history_conf", + "lang_code", + "language", + "sort", + } + and value is not None + } + effective = {**agent, **scalar_columns} + now = shanghai_now_naive() + if dto.functions is not None: + await self.repo.replace_plugins( + agent_id, + [ + { + "id": snowflake.next_id(), + "plugin_id": item.plugin_id or "", + "param_info": _json_dump(item.param_info), + } + for item in dto.functions + ], + ) + if effective.get("mem_model_id") == MEMORY_NO_MEM: + await self.repo.delete_chat_history(agent_id, delete_audio=True, delete_text=True) + scalar_columns["summary_memory"] = "" + effective["summary_memory"] = "" + elif effective.get("mem_model_id") == MEMORY_REPORT_ONLY: + scalar_columns["summary_memory"] = "" + effective["summary_memory"] = "" + if dto.context_providers is not None: + await self.repo.upsert_context_provider( + agent_id, + _json_dump([item.model_dump(by_alias=True) for item in dto.context_providers]), + _uuid32(), + ) + if dto.correct_word_file_ids is not None: + await self.repo.replace_correct_words( + agent_id, + dto.correct_word_file_ids, + self.actor_id, + now, + [_uuid32() for _ in dto.correct_word_file_ids], + ) + if dto.tag_names is not None or dto.tag_ids is not None: + await self._replace_agent_tags(agent_id, dto.tag_ids, dto.tag_names) + await self._validate_llm_intent(effective.get("llm_model_id"), effective.get("intent_model_id")) + scalar_columns.update(updater=self.actor_id, updated_at=now) + await self.repo.update_agent(agent_id, scalar_columns) + if create_snapshot: + await self._create_snapshot(agent_id, "config") + await self.session.commit() + except Exception as exc: + await self._rollback_and_raise(exc) + + async def update_memory_by_mac(self, mac_address: str, dto: AgentMemory) -> None: + device = await self.repo.get_device_by_mac(mac_address) + if device is None or not device.get("agent_id"): + return + if self.user is not None and not self.user.is_super_admin and int(device.get("user_id") or 0) != self.user.id: + raise AppError(ERROR_NO_PERMISSION) + await self.update_agent( + str(device["agent_id"]), + AgentUpdate(summary_memory=dto.summary_memory), + check_permission=False, + create_snapshot=False, + ) + + async def delete_agent(self, agent_id: str) -> None: + try: + await self.require_agent(agent_id, permission=True, for_update=True) + await self.repo.delete_agent_cascade(agent_id) + await self.session.commit() + except Exception as exc: + await self._rollback_and_raise(exc) + + async def _validate_llm_intent(self, llm_model_id: Any, intent_model_id: Any) -> None: + if not llm_model_id: + return + model = await self.repo.get_model_config(str(llm_model_id)) + config = _json_load(model.get("config_json") if model else None, {}) + model_type = str(config.get("type") or "") + if ( + model is None + or not config + or (model_type not in {"openai", "ollama"} and intent_model_id == "Intent_function_call") + ): + raise AppError(ERROR_LLM_INTENT_MISMATCH) + + async def save_tag(self, tag_name: str) -> dict[str, Any]: + if not tag_name.strip(): + raise AppError(ERROR_TAG_EMPTY) + existing = await self.repo.find_active_tag_by_name(tag_name) + if existing is not None: + return _as_api_row(existing) + now = shanghai_now_naive() + values = { + "id": _uuid32(), + "tag_name": tag_name, + "sort": 0, + "deleted": 0, + "creator": None, + "created_at": now, + "updater": None, + "updated_at": now, + } + try: + await self.repo.insert_tag(values) + await self.session.commit() + return values + except Exception as exc: + await self._rollback_and_raise(exc) + + async def delete_tag(self, tag_id: str) -> None: + try: + await self.repo.soft_delete_tag(tag_id, shanghai_now_naive()) + await self.session.commit() + except Exception as exc: + await self._rollback_and_raise(exc) + + async def all_tags(self) -> list[dict[str, Any]]: + return [{"id": row["id"], "tagName": row["tag_name"]} for row in await self.repo.list_tags()] + + async def agent_tags(self, agent_id: str) -> list[dict[str, Any]]: + await self.require_agent_permission(agent_id) + return [{"id": row["id"], "tagName": row["tag_name"]} for row in await self.repo.get_agent_tags(agent_id)] + + async def save_agent_tags(self, agent_id: str, tag_ids: list[str] | None, tag_names: list[str] | None) -> None: + await self.require_agent_permission(agent_id) + await self.update_agent(agent_id, AgentUpdate(tag_ids=tag_ids, tag_names=tag_names)) + + async def _replace_agent_tags( + self, agent_id: str, tag_ids: Sequence[str] | None, tag_names: Sequence[str] | None + ) -> None: + new_names: list[str] = [] + seen: set[str] = set() + for name in tag_names or []: + if not name.strip(): + raise AppError(ERROR_TAG_EMPTY) + if name in seen: + raise AppError(ERROR_TAG_DUPLICATE) + seen.add(name) + new_names.append(name) + all_ids: list[str] = [] + now = shanghai_now_naive() + for name in new_names: + tag = await self.repo.find_active_tag_by_name(name) + if tag is None: + values = { + "id": _uuid32(), + "tag_name": name, + "sort": 0, + "deleted": 0, + "creator": None, + "created_at": now, + "updater": None, + "updated_at": now, + } + await self.repo.insert_tag(values) + all_ids.append(str(values["id"])) + else: + all_ids.append(str(tag["id"])) + selected_rows = [await self.repo.get_tag(tag_id) for tag_id in tag_ids or []] + selected_names = {str(row["tag_name"]) for row in selected_rows if row is not None} + if selected_names.intersection(new_names): + raise AppError(ERROR_TAG_DUPLICATE) + all_ids.extend(tag_ids or []) + relations = [ + { + "id": _uuid32(), + "agent_id": agent_id, + "tag_id": tag_id, + "sort": index, + "creator": None, + "created_at": now, + "updater": None, + "updated_at": now, + } + for index, tag_id in enumerate(all_ids) + ] + await self.repo.replace_tag_relations(agent_id, relations) + + async def templates(self) -> list[dict[str, Any]]: + rows, _ = await self.repo.list_templates() + return [_as_api_row(row) for row in rows] + + async def template_page(self, page: int, limit: int, agent_name: str | None) -> dict[str, Any]: + rows, total = await self.repo.list_templates(name=agent_name, page=page, limit=limit) + # The Java controller declares model-name fields but never populates them. + return {"list": [{**row, "ttsModelName": None, "llmModelName": None} for row in rows], "total": total} + + async def template_detail(self, template_id: str) -> dict[str, Any] | None: + row = await self.repo.get_template(template_id) + return None if row is None else {**row, "ttsModelName": None, "llmModelName": None} + + async def create_template(self, template: AgentTemplate) -> dict[str, Any]: + values = template.model_dump(by_alias=False) + values["id"] = values.get("id") or _uuid32() + values["sort"] = await self.repo.next_template_sort() + try: + await self.repo.insert_template(values) + await self.session.commit() + return values + except Exception as exc: + await self._rollback_and_raise(exc) + + async def update_template(self, template: AgentTemplate) -> bool: + if template.id is None: + return False + try: + affected = await self.repo.update_template(template.id, template.model_dump(by_alias=False)) + await self.session.commit() + return affected == 1 + except Exception as exc: + await self._rollback_and_raise(exc) + + async def delete_template(self, template_id: str) -> bool: + row = await self.repo.get_template(template_id) + if row is None: + return False + try: + affected = await self.repo.delete_template(template_id) + if affected == 1 and row.get("sort") is not None: + await self.repo.reorder_templates(int(row["sort"])) + await self.session.commit() + return affected == 1 + except Exception as exc: + await self._rollback_and_raise(exc) + + async def batch_delete_templates(self, ids: Sequence[str]) -> bool: + try: + affected = await self.repo.delete_templates(ids) + await self.session.commit() + return affected > 0 + except Exception as exc: + await self._rollback_and_raise(exc) + + async def report_chat(self, report: AgentChatHistoryReport) -> bool: + agent = await self.repo.get_agent_by_device_mac(report.mac_address) + if agent is None or agent.get("id") is None: + return False + now = shanghai_now_naive() + report_at = now + if report.report_time is not None: + report_at = datetime.fromtimestamp( + report.report_time / 1000, + ZoneInfo(get_settings().timezone), + ).replace(tzinfo=None) + try: + audio_id: str | None = None + history_conf = agent.get("chat_history_conf") + if history_conf is not None and int(history_conf) == 2: + if report.audio_base64: + try: + audio = base64.b64decode(report.audio_base64, validate=True) + audio_id = _uuid32() + await self.repo.insert_chat_audio(audio_id, audio) + except (ValueError, TypeError): + audio_id = None + if history_conf is not None and int(history_conf) in {1, 2}: + await self.repo.insert_chat_history( + { + "mac_address": report.mac_address, + "agent_id": agent["id"], + "session_id": report.session_id, + "chat_type": report.chat_type, + "content": report.content, + "audio_id": audio_id, + "created_at": report_at, + } + ) + await java_set(f"agent:device:lastConnected:{agent['id']}", now, 86400) + device = await self.repo.get_device_by_mac(report.mac_address) + if device is not None: + await self.repo.update_device_connection(str(device["id"]), now) + await self.session.commit() + return True + except Exception as exc: + await self._rollback_and_raise(exc) + + async def session_agent(self, session_id: str) -> str: + agent_id = await self.repo.get_session_agent_id(session_id) + if not agent_id: + raise AppError(ERROR_AGENT_NOT_FOUND) + await self.require_agent(agent_id, permission=False) + return agent_id + + async def sessions( + self, + agent_id: str, + page: int | str | None, + limit: int | str | None, + ) -> dict[str, Any]: + # Java checks ownership before reading the raw request-param map. This + # makes a missing agent return 10169 even when page/limit are absent. + await self.require_agent_permission(agent_id) + if page is None or limit is None: + raise AppError(500) + try: + page_number = int(page) + limit_number = int(limit) + except ValueError as exc: + # AgentChatHistoryServiceImpl dereferences and parses both values; + # absent or malformed values reach the generic exception handler. + raise AppError(500) from exc + rows, total = await self.repo.list_sessions(agent_id, page_number, limit_number) + return {"list": rows, "total": total} + + async def history(self, agent_id: str, session_id: str) -> list[dict[str, Any]]: + return await self.repo.get_chat_history(agent_id, session_id) + + async def recent_user_history(self, agent_id: str) -> list[dict[str, Any]]: + rows = await self.repo.get_recent_user_history(agent_id) + for row in rows: + content = row.get("content") + parsed = _json_load(content, None) + if isinstance(parsed, dict) and "content" in parsed and parsed["content"] is not None: + row["content"] = str(parsed["content"]) + return rows + + async def require_audio_permission(self, audio_id: str) -> None: + agent_id = await self.repo.get_audio_agent_id(audio_id) + if not agent_id or not await self.has_agent_permission(agent_id): + raise AppError(ERROR_NO_PERMISSION) + + async def audio_content(self, audio_id: str) -> str | None: + await self.require_audio_permission(audio_id) + return await self.repo.get_audio_content(audio_id) + + async def issue_audio_token(self, audio_id: str) -> str | None: + await self.require_audio_permission(audio_id) + if await self.repo.get_chat_audio(audio_id) is None: + return None + token = str(uuid.uuid4()) + await java_set(f"agent:audio:id:{token}", audio_id, 300) + return token + + async def consume_audio_token(self, token: str) -> bytes | None: + key = f"agent:audio:id:{token}" + audio_id = await java_get(key) + if not isinstance(audio_id, str) or not audio_id.strip(): + return None + audio = await self.repo.get_chat_audio(audio_id) + if audio is None: + return None + from app.core.redis import get_redis + + await get_redis().delete(key) + return audio + + async def issue_history_token(self, agent_id: str, session_id: str) -> str: + await self.require_agent(agent_id) + token = str(uuid.uuid4()) + await java_set(f"agent:chat:history:{token}", f"{agent_id}:{session_id}", 86400) + return token + + async def consume_history_download(self, token: str, *, previous: bool) -> str: + from app.core.redis import get_redis + + key = f"agent:chat:history:{token}" + value = await java_get(key) + if not isinstance(value, str) or not value.strip(): + raise AppError(10136) + try: + parts = value.split(":") + if len(parts) != 2: + raise AppError(10137) + agent_id, session_id = parts + session_ids = [session_id] + if previous: + sessions, _ = await self.repo.list_sessions(agent_id, 1, 1000) + index = next( + (position for position, item in enumerate(sessions) if item.get("session_id") == session_id), -1 + ) + if index >= 0: + session_ids = [str(item["session_id"]) for item in sessions[index : index + 21]] + chunks: list[str] = [] + for selected_session in session_ids: + messages = await self.repo.get_chat_history(agent_id, selected_session) + lines: list[str] = [] + if messages: + created = messages[0].get("created_at") + if isinstance(created, datetime): + lines.append(created.strftime("%Y-%m-%d %H:%M:%S")) + elif created is not None: + lines.append(str(created)) + for message in messages: + user_message = int(message.get("chat_type") or 0) == 1 + role = message_for(10138 if user_message else 10139, self.language) + direction = ">>" if user_message else "<<" + created = message.get("created_at") + stamp = created.strftime("%Y-%m-%d %H:%M:%S") if isinstance(created, datetime) else str(created) + lines.append(f"[{role}]-[{stamp}]{direction}:{message.get('content')}") + chunks.append("\n".join(lines)) + return "\n\n".join(chunks) + ("\n" if chunks and chunks[-1] else "") + finally: + await get_redis().delete(key) + + async def mcp_address(self, agent_id: str) -> str | None: + endpoint = await SystemParamService(self.session).get_value("server.mcp_endpoint", from_cache=True) + return build_agent_mcp_address(endpoint, agent_id) + + async def mcp_tools(self, agent_id: str) -> list[str]: + address = await self.mcp_address(agent_id) + return [] if not address else await list_mcp_tools(address) + + async def generate_chat_summary(self, session_id: str) -> bool: + try: + first = await self.repo.fetch_one( + "SELECT agent_id,mac_address FROM ai_agent_chat_history WHERE session_id=:id LIMIT 1", + {"id": session_id}, + ) + if first is None or not first.get("mac_address"): + return False + device = await self.repo.get_device_by_mac(str(first["mac_address"])) + if device is None or not device.get("agent_id"): + return False + agent = await self.repo.get_agent(str(device["agent_id"])) + if agent is None: + return False + memory_model = agent.get("mem_model_id") + if memory_model is None or memory_model == MEMORY_REPORT_ONLY: + return True + if memory_model in {MEMORY_NO_MEM, MEMORY_MEM0AI, MEMORY_POWERMEM}: + return True + messages = await self.repo.get_chat_history(str(agent["id"]), session_id) + meaningful = self._meaningful_messages(messages) + if not meaningful: + return False + conversation = "".join(f"消息{index}: {message}\n" for index, message in enumerate(meaningful, 1)) + model = await self._summary_model(agent) + config = _json_load(model.get("config_json") if model else None, {}) + if not config: + return False + prompt = SUMMARY_PROMPT.replace( + "{history_memory}", str(agent.get("summary_memory") or "无历史记忆") + ).replace("{conversation}", conversation) + summary = await openai_completion( + config, + prompt, + temperature=0.2, + max_tokens=2000, + timeout=get_settings().external_request_timeout_seconds, + ) + if not summary or summary in {"服务暂不可用", "总结生成失败"}: + return False + if len(summary) > 1800: + summary = summary[:1800] + "..." + await self.update_agent( + str(agent["id"]), + AgentUpdate(summary_memory=summary), + check_permission=False, + create_snapshot=False, + ) + return True + except Exception: + logger.exception("Failed to generate chat summary for session %s", session_id) + await self.session.rollback() + return False + + async def generate_chat_title(self, session_id: str) -> bool: + try: + agent_id = await self.repo.get_session_agent_id(session_id) + if not agent_id: + return False + agent = await self.repo.get_agent(agent_id) + if agent is None: + return False + meaningful = self._meaningful_messages(await self.repo.get_chat_history(agent_id, session_id)) + if not meaningful: + return False + conversation = "".join(f"消息{index}: {message}\n" for index, message in enumerate(meaningful, 1)) + model = await self._summary_model(agent) + config = _json_load(model.get("config_json") if model else None, {}) + if not config: + return False + title = await openai_completion( + config, + TITLE_PROMPT.replace("{conversation}", conversation), + temperature=0.3, + max_tokens=50, + timeout=get_settings().external_request_timeout_seconds, + ) + if not title or not title.strip(): + return False + title = _TITLE_PUNCTUATION.sub("", title.strip())[:15] + await self.repo.upsert_chat_title(session_id, title, shanghai_now_naive(), _uuid32()) + await self.session.commit() + return True + except Exception: + logger.exception("Failed to generate chat title for session %s", session_id) + await self.session.rollback() + return False + + async def _summary_model(self, agent: Mapping[str, Any]) -> dict[str, Any] | None: + # OpenAIStyleLLMServiceImpl.isAvailable() first requires a usable default + # model even when a concrete SLM model id is supplied. + default = await self.repo.get_default_llm_config() + default_config = _json_load(default.get("config_json") if default else None, {}) + if not default_config.get("base_url") or not default_config.get("api_key"): + return None + if agent.get("slm_model_id"): + return await self.repo.get_model_config(str(agent["slm_model_id"])) + return default + + def _meaningful_messages(self, messages: Sequence[Mapping[str, Any]]) -> list[str]: + result: list[str] = [] + for message in messages: + if int(message.get("chat_type") or 0) != 1: + continue + content = str(message.get("content") or "") + match = re.search(r"\{.*?\}", content, re.DOTALL) + if match: + parsed = _json_load(match.group(0), None) + if isinstance(parsed, dict) and parsed.get("content") is not None: + content = str(parsed["content"]) + else: + field = re.search(r'"content"\s*:\s*"([^"]*)"', match.group(0)) + content = field.group(1) if field else match.group(0) + if ( + len(content) >= 5 + and not _DEVICE_CONTROL.search(content) + and not _WEATHER.search(content) + and not _DATE_WORDS.search(content) + ): + result.append(content) + return result + + async def _snapshot_data(self, agent_id: str) -> dict[str, Any]: + detail = await self.repo.get_agent(agent_id) + if detail is None: + raise AppError(ERROR_AGENT_NOT_FOUND) + functions = [ + { + "pluginId": row.get("plugin_id"), + "paramInfo": _json_load(row.get("param_info"), {}), + } + for row in await self.repo.get_agent_plugins(agent_id) + ] + tags = await self.repo.get_agent_tags(agent_id) + snapshot_tags = [{"id": row["id"], "tagName": row["tag_name"], "sort": index} for index, row in enumerate(tags)] + scalar_mapping = { + "agentCode": "agent_code", + "agentName": "agent_name", + "asrModelId": "asr_model_id", + "vadModelId": "vad_model_id", + "llmModelId": "llm_model_id", + "slmModelId": "slm_model_id", + "vllmModelId": "vllm_model_id", + "ttsModelId": "tts_model_id", + "ttsVoiceId": "tts_voice_id", + "ttsLanguage": "tts_language", + "ttsVolume": "tts_volume", + "ttsRate": "tts_rate", + "ttsPitch": "tts_pitch", + "memModelId": "mem_model_id", + "intentModelId": "intent_model_id", + "chatHistoryConf": "chat_history_conf", + "systemPrompt": "system_prompt", + "summaryMemory": "summary_memory", + "langCode": "lang_code", + "language": "language", + "sort": "sort", + } + data = {api_name: detail.get(column) for api_name, column in scalar_mapping.items()} + context = await self.repo.get_context_provider(agent_id) + data.update( + functions=functions, + contextProviders=_json_load(context.get("context_providers") if context else None, []), + correctWordFileIds=await self.repo.get_correct_word_ids(agent_id), + tagNames=[str(row["tag_name"]) for row in tags], + tags=snapshot_tags, + ) + return AgentSnapshotData.model_validate(data).model_dump(by_alias=True) + + async def _create_snapshot(self, agent_id: str, source: str) -> None: + agent = await self.repo.get_agent(agent_id, for_update=True) + if agent is None: + raise AppError(ERROR_AGENT_NOT_FOUND) + data = await self._snapshot_data(agent_id) + previous = await self.repo.latest_snapshot(agent_id) + if previous is None: + changed = ["initial"] + else: + previous_data = _parse_snapshot_data(previous.get("snapshot_data")) + changed = _changed_snapshot_fields(previous_data, data) + if not changed: + return + await self._insert_snapshot(agent_id, int(agent.get("user_id") or 0), source, data, changed) + + async def _insert_snapshot( + self, + agent_id: str, + user_id: int, + source: str, + data: Mapping[str, Any], + changed_fields: Sequence[str], + *, + restore_from_id: str | None = None, + restore_from_version: int | None = None, + prune: bool = True, + ) -> None: + values = { + "id": _uuid32(), + "agent_id": agent_id, + "user_id": user_id, + "snapshot_data": _json_dump(_redact_snapshot_data(dict(data))), + "changed_fields": _json_dump(list(changed_fields)), + "source": source.strip() or "config", + "restore_from_snapshot_id": restore_from_id, + "restore_from_version_no": restore_from_version, + "creator": self.actor_id, + "created_at": shanghai_now_naive(), + "redaction_version": CURRENT_REDACTION_VERSION, + } + if await self.repo.insert_snapshot_next_version(values) != 1: + raise AppError(500, "快照版本号生成失败") + if prune: + await self.repo.prune_snapshots(agent_id, MAX_SNAPSHOTS_PER_AGENT) + + async def snapshot_page(self, agent_id: str, params: AgentSnapshotPage) -> dict[str, Any]: + await self.require_snapshot_permission(agent_id) + await self.require_agent(agent_id) + rows, total = await self.repo.list_snapshots( + agent_id, params.page_or_default(), params.limit_or_default(), params.max_version_no + ) + return {"list": [self._snapshot_vo(row, include_data=False) for row in rows], "total": total} + + async def snapshot_detail(self, agent_id: str, snapshot_id: str) -> dict[str, Any]: + await self.require_snapshot_permission(agent_id) + await self.require_agent(agent_id, for_update=True) + snapshot = await self._snapshot_entity(agent_id, snapshot_id) + result = self._snapshot_vo(snapshot, include_data=True) + next_snapshot = await self.repo.next_snapshot(agent_id, int(snapshot["version_no"])) + result["afterSnapshotData"] = ( + _redact_snapshot_data(_parse_snapshot_data(next_snapshot.get("snapshot_data"))) + if next_snapshot is not None + else None + ) + current = await self._snapshot_data(agent_id) + result["currentSnapshotData"] = _redact_snapshot_data(current) + result["currentStateToken"] = _snapshot_state_token(current) + return result + + async def restore_snapshot(self, agent_id: str, snapshot_id: str, state_token: str) -> None: + try: + await self.require_snapshot_permission(agent_id) + agent = await self.require_agent(agent_id, for_update=True) + snapshot = await self._snapshot_entity(agent_id, snapshot_id) + target = _parse_snapshot_data(snapshot.get("snapshot_data")) + if not target: + raise AppError(500, "快照数据为空,无法恢复") + current = await self._snapshot_data(agent_id) + if _snapshot_state_token(current) != state_token: + raise AppError(500, "当前配置已变化,请重新打开恢复预览后再试") + restored = _preserve_snapshot_sensitive(target, current) + try: + _preserve_snapshot_sensitive(current, restored) + except AppError as exc: + raise AppError(500, "目标版本会移除无法写入历史的敏感配置,请先手动处理相关密钥后再恢复") from exc + requested = _changed_snapshot_fields(current, restored) + if not requested: + return + latest = await self.repo.latest_snapshot(agent_id) + latest_data = _parse_snapshot_data(latest.get("snapshot_data")) if latest else None + backup_changed = _changed_snapshot_fields(latest_data, current) + backup_created = bool(backup_changed) + if backup_created: + await self._insert_snapshot( + agent_id, + int(agent.get("user_id") or 0), + "current", + current, + backup_changed, + prune=False, + ) + scalar_map = { + "agentCode": "agent_code", + "agentName": "agent_name", + "asrModelId": "asr_model_id", + "vadModelId": "vad_model_id", + "llmModelId": "llm_model_id", + "slmModelId": "slm_model_id", + "vllmModelId": "vllm_model_id", + "ttsModelId": "tts_model_id", + "ttsVoiceId": "tts_voice_id", + "ttsLanguage": "tts_language", + "ttsVolume": "tts_volume", + "ttsRate": "tts_rate", + "ttsPitch": "tts_pitch", + "memModelId": "mem_model_id", + "intentModelId": "intent_model_id", + "chatHistoryConf": "chat_history_conf", + "systemPrompt": "system_prompt", + "summaryMemory": "summary_memory", + "langCode": "lang_code", + "language": "language", + "sort": "sort", + } + update = {column: restored.get(api_name) for api_name, column in scalar_map.items()} + await self._validate_llm_intent(update.get("llm_model_id"), update.get("intent_model_id")) + if update.get("mem_model_id") == MEMORY_NO_MEM: + await self.repo.delete_chat_history(agent_id, delete_audio=True, delete_text=True) + update["summary_memory"] = "" + elif update.get("mem_model_id") == MEMORY_REPORT_ONLY: + update["summary_memory"] = "" + update.update(updater=self.actor_id, updated_at=shanghai_now_naive()) + affected = await self.repo.update_agent(agent_id, update, include_null=True) + if affected < 0 or affected > 1: + raise AppError(500, "智能体快照恢复失败") + await self.repo.delete_plugins(agent_id) + await self.repo.replace_plugins( + agent_id, + [ + { + "id": snowflake.next_id(), + "plugin_id": str(item.get("pluginId") or ""), + "param_info": _json_dump(item.get("paramInfo") or {}), + } + for item in restored.get("functions") or [] + if isinstance(item, dict) + ], + ) + now = shanghai_now_naive() + await self.repo.upsert_context_provider( + agent_id, + _json_dump(restored.get("contextProviders") or []), + _uuid32(), + ) + correct_ids = [str(value) for value in restored.get("correctWordFileIds") or []] + await self.repo.replace_correct_words( + agent_id, correct_ids, self.actor_id, now, [_uuid32() for _ in correct_ids] + ) + await self._restore_snapshot_tags(agent_id, restored, now) + actual = await self._snapshot_data(agent_id) + actual_changed = _changed_snapshot_fields(current, actual) + if actual_changed: + await self._insert_snapshot( + agent_id, + int(agent.get("user_id") or 0), + "restore", + actual, + actual_changed, + restore_from_id=str(snapshot["id"]), + restore_from_version=int(snapshot["version_no"]), + prune=False, + ) + if backup_created or actual_changed: + await self.repo.prune_snapshots(agent_id, MAX_SNAPSHOTS_PER_AGENT) + await self.session.commit() + except Exception as exc: + await self._rollback_and_raise(exc) + + async def _restore_snapshot_tags(self, agent_id: str, data: Mapping[str, Any], now: datetime) -> None: + snapshot_tags = data.get("tags") + if not isinstance(snapshot_tags, list) or not snapshot_tags: + await self._replace_agent_tags(agent_id, None, [str(value) for value in data.get("tagNames") or []]) + return + relations: list[dict[str, Any]] = [] + for index, item in enumerate(snapshot_tags): + if not isinstance(item, dict) or not str(item.get("tagName") or "").strip(): + continue + tag = await self.repo.get_tag(str(item.get("id") or "")) if item.get("id") else None + if tag is None: + tag = await self.repo.find_any_tag_by_name(str(item["tagName"])) + if tag is not None and int(tag.get("deleted") or 0) == 1: + raise AppError(500, "快照引用的标签已被删除,无法恢复,请先重新创建或选择标签") + if tag is None: + tag = { + "id": _uuid32(), + "tag_name": str(item["tagName"]), + "sort": int(item.get("sort") or 0), + "deleted": 0, + "creator": self.actor_id, + "created_at": now, + "updater": self.actor_id, + "updated_at": now, + } + await self.repo.insert_tag(tag) + relations.append( + { + "id": _uuid32(), + "agent_id": agent_id, + "tag_id": tag["id"], + "sort": index, + "creator": self.actor_id, + "created_at": now, + "updater": self.actor_id, + "updated_at": now, + } + ) + await self.repo.replace_tag_relations(agent_id, relations) + + async def delete_snapshot(self, agent_id: str, snapshot_id: str) -> None: + try: + await self.require_snapshot_permission(agent_id) + await self.require_agent(agent_id, for_update=True) + snapshot = await self._snapshot_entity(agent_id, snapshot_id) + if int(snapshot["version_no"]) == await self.repo.snapshot_max_version(agent_id): + raise AppError(500, "最新历史版本不能删除") + await self.repo.delete_snapshot(snapshot_id) + await self.session.commit() + except Exception as exc: + await self._rollback_and_raise(exc) + + async def _snapshot_entity(self, agent_id: str, snapshot_id: str) -> dict[str, Any]: + snapshot = await self.repo.get_snapshot(snapshot_id) + if snapshot is None or str(snapshot.get("agent_id")) != agent_id: + raise AppError(500, "快照不存在") + return snapshot + + def _snapshot_vo(self, row: Mapping[str, Any], *, include_data: bool) -> dict[str, Any]: + result = { + "id": row.get("id"), + "agentId": row.get("agent_id"), + "userId": row.get("user_id"), + "versionNo": row.get("version_no"), + "changedFields": _json_load(row.get("changed_fields"), []), + "fieldOrder": list(SNAPSHOT_FIELD_ORDER), + "source": row.get("source"), + "restoreFromSnapshotId": row.get("restore_from_snapshot_id"), + "restoreFromVersionNo": row.get("restore_from_version_no"), + "creator": row.get("creator"), + "createdAt": row.get("created_at"), + "snapshotData": None, + "afterSnapshotData": None, + "currentSnapshotData": None, + "currentStateToken": None, + } + if include_data: + result["snapshotData"] = _redact_snapshot_data(_parse_snapshot_data(row.get("snapshot_data"))) + return result + + async def redact_legacy_snapshots(self) -> int: + after_id: str | None = None + migrated = 0 + while True: + batch = await self.repo.list_legacy_snapshots( + after_id, LEGACY_REDACTION_BATCH_SIZE, CURRENT_REDACTION_VERSION + ) + if not batch: + return migrated + try: + for snapshot in batch: + raw = _json_load(snapshot.get("snapshot_data"), None) + if not isinstance(raw, dict): + raise AppError(500, f"历史快照数据无法解析,已中止脱敏迁移: {snapshot['id']}") + migrated += await self.repo.update_redacted_snapshot( + str(snapshot["id"]), + _json_dump(_redact_sensitive_map(raw)), + CURRENT_REDACTION_VERSION, + ) + await self.session.commit() + except Exception as exc: + await self._rollback_and_raise(exc) + after_id = str(batch[-1]["id"]) + + async def voiceprint_list(self, agent_id: str) -> list[dict[str, Any]]: + configured = await SystemParamService(self.session).get_value("server.voice_print", from_cache=True) + if configured is None or not configured.strip() or configured == "null": + raise AppError(ERROR_VOICEPRINT_NOT_CONFIGURED) + return await self.repo.list_voiceprints(agent_id, self.actor_id) + + async def _voiceprint_audio(self, agent_id: str | None, audio_id: str | None) -> bytes: + if not agent_id or not audio_id or not await self.repo.is_audio_owned(audio_id, agent_id): + raise AppError(10085) + audio = await self.repo.get_chat_audio(audio_id) + if not audio: + raise AppError(10086) + return audio + + async def _voiceprint_client(self) -> VoicePrintClient: + configured = await SystemParamService(self.session).get_value("server.voice_print", from_cache=True) + try: + return VoicePrintClient(configured or "", timeout=get_settings().external_request_timeout_seconds) + except VoicePrintIntegrationError as exc: + raise AppError(exc.code, exc.message, exc.params) from exc + + async def create_voiceprint(self, dto: AgentVoicePrintSave) -> bool: + audio = await self._voiceprint_audio(dto.agent_id, dto.audio_id) + client = await self._voiceprint_client() + ids = await self.repo.list_voiceprint_ids(str(dto.agent_id)) + try: + identified = await client.identify(ids, audio) + if identified and identified[1] is not None and identified[1] > 0.5: + existing = await self.repo.get_voiceprint(str(identified[0])) if identified[0] else None + name = str(existing.get("source_name") if existing else "未知用户") + raise AppError(10080, params=(name,)) + now = shanghai_now_naive() + voiceprint_id = _uuid32() + await self.repo.insert_voiceprint( + { + "id": voiceprint_id, + "agent_id": dto.agent_id, + "audio_id": dto.audio_id, + "source_name": dto.source_name, + "introduce": dto.introduce, + "creator": self.actor_id, + "create_date": now, + "updater": self.actor_id, + "update_date": now, + } + ) + await client.register(voiceprint_id, audio) + await self.session.commit() + return True + except VoicePrintIntegrationError as exc: + await self.session.rollback() + raise AppError(exc.code, exc.message, exc.params) from exc + except Exception as exc: + await self.session.rollback() + if isinstance(exc, AppError): + raise + raise AppError(10046) from exc + + async def update_voiceprint(self, dto: AgentVoicePrintUpdate) -> bool: + if dto.id is None: + return False + existing = await self.repo.get_voiceprint(dto.id, self.actor_id) + if existing is None: + return False + client = await self._voiceprint_client() + audio: bytes | None = None + if dto.audio_id and dto.audio_id != existing.get("audio_id"): + audio = await self._voiceprint_audio(str(existing["agent_id"]), dto.audio_id) + try: + identified = await client.identify( + await self.repo.list_voiceprint_ids(str(existing["agent_id"])), audio + ) + except VoicePrintIntegrationError as exc: + raise AppError(exc.code, exc.message, exc.params) from exc + if identified and identified[1] is not None and identified[1] > 0.5 and identified[0] != dto.id: + duplicate = await self.repo.get_voiceprint(str(identified[0])) if identified[0] else None + name = str(duplicate.get("source_name") if duplicate else "未知用户") + raise AppError(10082, params=(name,)) + try: + values = { + "audio_id": dto.audio_id, + "source_name": dto.source_name, + "introduce": dto.introduce, + "updater": self.actor_id, + "update_date": shanghai_now_naive(), + } + affected = await self.repo.update_voiceprint(dto.id, self.actor_id, values) + if affected != 1: + await self.session.rollback() + return False + if audio is not None: + await client.cancel(dto.id) + await client.register(dto.id, audio) + await self.session.commit() + return True + except VoicePrintIntegrationError as exc: + await self.session.rollback() + raise AppError(exc.code, exc.message, exc.params) from exc + except Exception as exc: + await self.session.rollback() + if isinstance(exc, AppError): + raise + raise AppError(10083) from exc + + async def delete_voiceprint(self, voiceprint_id: str) -> bool: + try: + affected = await self.repo.delete_voiceprint(voiceprint_id, self.actor_id) + await self.session.commit() + except Exception as exc: + await self.session.rollback() + raise AppError(10081) from exc + if affected == 1: + asyncio.create_task(_cancel_voiceprint_after_commit(voiceprint_id)) + return affected == 1 + + +def _parse_snapshot_data(value: Any) -> dict[str, Any] | None: + parsed = _json_load(value, None) + if parsed is None: + return None + if not isinstance(parsed, dict): + raise AppError(500, "快照数据无法解析") + # Snapshot payloads intentionally ignore fields unknown to this application version. + accepted = set(SNAPSHOT_FIELD_ORDER) | {"tags"} + return AgentSnapshotData.model_validate({key: item for key, item in parsed.items() if key in accepted}).model_dump( + by_alias=True + ) + + +def _normalized_key(value: str | None) -> str: + return re.sub("[^a-z0-9]", "", (value or "").lower()) + + +def _is_sensitive_key(value: str | None) -> bool: + key = _normalized_key(value) + return ( + key == "authorization" + or "authorization" in key + or "authentication" in key + or key == "auth" + or key.endswith("auth") + or key in {"cookie", "cookie2", "setcookie", "setcookie2"} + or key.endswith("cookie") + or key == "session" + or key.endswith("session") + or any(marker in key for marker in ("sessionid", "sessionkey", "sessiontoken", "sessioncookie")) + or key.endswith("sessid") + or key == "token" + or key.endswith("token") + or any( + marker in key + for marker in ( + "apikey", + "appkey", + "accesskey", + "subscriptionkey", + "privatekey", + "password", + "passwd", + "secret", + "credential", + ) + ) + ) + + +def _is_url_key(value: str | None) -> bool: + key = _normalized_key(value) + return key.endswith(("url", "uri", "endpoint", "webhook")) + + +def _is_webhook_semantic_key(value: str | None) -> bool: + key = _normalized_key(value) + return "webhook" in key or key in {"hook", "hooks"} or key.endswith(("hook", "hooks")) + + +def _resolve_url_semantic_key(parent_key: str | None, child_key: str | None) -> str | None: + if _is_webhook_semantic_key(child_key): + return child_key + if _is_webhook_semantic_key(parent_key): + return parent_key if not child_key else f"{parent_key}.{child_key}" + return child_key + + +def _looks_like_url(value: str) -> bool: + return value.startswith("//") or bool(re.match(r"^[A-Za-z][A-Za-z0-9+.-]*://", value)) + + +def _should_treat_as_url(key: str | None, value: Any) -> bool: + return isinstance(value, str) and (_is_url_key(key) or _is_webhook_semantic_key(key) or _looks_like_url(value)) + + +def _split_sensitive_url(value: str) -> tuple[str, str | None, str | None, str | None]: + remaining = value or "" + fragment: str | None = None + if "#" in remaining: + remaining, fragment = remaining.split("#", 1) + query: str | None = None + if "?" in remaining: + remaining, query = remaining.split("?", 1) + user_info: str | None = None + match = re.match(r"^(?P(?:[A-Za-z][A-Za-z0-9+.-]*:)?//)(?P[^/]*)(?P/.*)?$", remaining) + if match and "@" in match.group("authority"): + authority = match.group("authority") + user_info, authority = authority.rsplit("@", 1) + remaining = f"{match.group('prefix')}{authority}{match.group('path') or ''}" + return remaining, user_info, query, fragment + + +def _join_sensitive_url(base: str, user_info: str | None, query: str | None, fragment: str | None) -> str: + result = base + if user_info is not None: + match = re.match(r"^(?P(?:[A-Za-z][A-Za-z0-9+.-]*:)?//)(?P.*)$", result) + if not match: + raise AppError(500, "带用户凭据的 URL 格式无效,无法安全恢复") + result = f"{match.group('prefix')}{user_info}@{match.group('rest')}" + if query is not None: + result += f"?{query}" + if fragment is not None: + result += f"#{fragment}" + return result + + +def _url_host_and_path(base: str) -> tuple[str, str, list[str]] | None: + match = re.match(r"^(?P(?:[A-Za-z][A-Za-z0-9+.-]*:)?//[^/]*)(?P/.*)?$", base) + if match: + prefix = match.group("prefix") + host = urlsplit(prefix if "://" in prefix else f"http:{prefix}").hostname or "" + path = match.group("path") or "" + return prefix, host.lower(), path.split("/") + if not base or any(character.isspace() for character in base): + return None + return "", "", base.split("/") + + +def _capability_secret(value: str) -> bool: + normalized = _normalized_key(value) + if any( + marker in normalized + for marker in ("secret", "token", "capability", "credential", "signature", "apikey", "accesskey", "authkey") + ): + return True + if len(value) < 20: + return False + categories = sum( + ( + any(character.isalpha() for character in value), + any(character.isdigit() for character in value), + any(not character.isalnum() for character in value), + ) + ) + return len(set(value)) >= 10 and categories >= 2 + + +def _redact_url_path(base: str, semantic_key: str | None) -> str: + parsed = _url_host_and_path(base) + if parsed is None: + return base + prefix, host, segments = parsed + slots: list[tuple[int, int, str]] = [] + if host in {"hooks.slack.com", "hooks.slack-gov.com"}: + for index, segment in enumerate(segments): + if segment.lower() == "services" and index + 3 < len(segments) and all(segments[index + 1 : index + 4]): + slots.append((index + 3, index + 4, SECRET_PLACEHOLDER)) + if ( + host == "discord.com" + or host.endswith(".discord.com") + or host == "discordapp.com" + or host.endswith(".discordapp.com") + ): + for index, segment in enumerate(segments): + if ( + segment.lower() == "webhooks" + and ( + (index >= 1 and segments[index - 1].lower() == "api") + or ( + index >= 2 + and segments[index - 2].lower() == "api" + and re.fullmatch(r"v\d+", segments[index - 1], re.IGNORECASE) is not None + ) + ) + and index + 2 < len(segments) + and segments[index + 1] + and segments[index + 2] + ): + slots.append((index + 2, index + 3, SECRET_PLACEHOLDER)) + if host == "api.telegram.org": + for index, segment in enumerate(segments): + if segment.lower().startswith("bot") and ":" in segment: + prefix_part, secret = segment.split(":", 1) + if len(prefix_part) > 3 and secret: + slots.append((index, index + 1, f"{prefix_part}:{SECRET_PLACEHOLDER}")) + if not slots: + markers = {"webhook", "webhooks", "hook", "hooks"} + for index, segment in enumerate(segments): + suffix = [value for value in segments[index + 1 :] if value] + host_labels = set(host.split(".")) + if ( + segment.lower() in markers + and suffix + and ( + _is_webhook_semantic_key(semantic_key) + or bool(markers.intersection(host_labels)) + or any(_capability_secret(item) for item in suffix) + ) + ): + slots.append((index + 1, len(segments), SECRET_PLACEHOLDER)) + break + if not slots and _is_webhook_semantic_key(semantic_key): + last = next((index for index in range(len(segments) - 1, -1, -1) if segments[index]), -1) + if last >= 0: + slots.append((last, last + 1, SECRET_PLACEHOLDER)) + for start, end, replacement in sorted(slots, reverse=True): + segments[start:end] = [replacement] + return prefix + "/".join(segments) + + +def _url_parameters(value: str | None) -> list[tuple[str, str, bool]]: + if value is None: + return [] + result: list[tuple[str, str, bool]] = [] + for item in value.split("&"): + if "=" in item: + key, raw_value = item.split("=", 1) + result.append((key, raw_value, True)) + else: + result.append((item, "", False)) + return result + + +def _parameter_sensitive(raw_key: str) -> bool: + try: + key = unquote_plus(raw_key) + except ValueError: + key = raw_key + normalized = _normalized_key(key) + return _is_sensitive_key(key) or normalized in { + "key", + "sig", + "signature", + "xamzsignature", + "xgoogsignature", + "sas", + } + + +def _url_parameter_identity(raw_key: str) -> str: + try: + decoded = unquote_plus(raw_key) + except ValueError: + decoded = raw_key + return _normalized_key(decoded) + + +def _redact_url_parameters(value: str | None) -> str | None: + if value is None: + return None + result: list[str] = [] + for key, item, has_equals in _url_parameters(value): + if _parameter_sensitive(key): + result.append(f"{key}={SECRET_PLACEHOLDER}" if has_equals else key) + else: + result.append(f"{key}={item}" if has_equals else key) + return "&".join(result) + + +def _redact_sensitive_url(value: str | None, semantic_key: str | None = None) -> str | None: + if value is None: + return None + base, user_info, query, fragment = _split_sensitive_url(value) + redacted_fragment = ( + _redact_url_parameters(fragment) + if fragment is not None and ("=" in fragment or "&" in fragment) + else SECRET_PLACEHOLDER + if fragment is not None + else None + ) + return _join_sensitive_url( + _redact_url_path(base, semantic_key), + SECRET_PLACEHOLDER if user_info is not None else None, + _redact_url_parameters(query), + redacted_fragment, + ) + + +def _preserve_url_parameters(target: str | None, current: str | None) -> str | None: + if target is None: + return None + target_values: dict[str, list[tuple[str, str, bool]]] = defaultdict(list) + current_values: dict[str, list[tuple[str, str, bool]]] = defaultdict(list) + for item in _url_parameters(target): + if _parameter_sensitive(item[0]): + target_values[_url_parameter_identity(item[0])].append(item) + for item in _url_parameters(current): + if _parameter_sensitive(item[0]): + current_values[_url_parameter_identity(item[0])].append(item) + restored: list[str] = [] + for key, value, has_equals in _url_parameters(target): + if _parameter_sensitive(key): + identity = _url_parameter_identity(key) + matches = current_values.get(identity, []) + if len(target_values.get(identity, [])) != 1 or len(matches) != 1 or SECRET_PLACEHOLDER in matches[0][1]: + raise AppError(500, "快照 URL 敏感参数无法与当前配置可靠匹配") + current_key, current_value, current_equals = matches[0] + del current_key + restored.append(f"{key}={current_value}" if current_equals else key) + else: + restored.append(f"{key}={value}" if has_equals else key) + return "&".join(restored) + + +def _preserve_sensitive_url(target: str | None, current: str | None, semantic_key: str | None = None) -> str | None: + if target is None: + return None + if SECRET_PLACEHOLDER not in target: + return target + if current is None or SECRET_PLACEHOLDER in current: + raise AppError(500, "快照 URL 敏感信息无法与当前配置可靠匹配") + if _redact_sensitive_url(current, semantic_key) == target: + return current + target_base, target_user, target_query, target_fragment = _split_sensitive_url(target) + current_base, current_user, current_query, current_fragment = _split_sensitive_url(current) + copies_sensitive = ( + target_user is not None + or SECRET_PLACEHOLDER in target_base + or any(_parameter_sensitive(item[0]) for item in _url_parameters(target_query)) + or target_fragment is not None + ) + if copies_sensitive and _redact_url_path(target_base, semantic_key) != _redact_url_path(current_base, semantic_key): + raise AppError(500, "快照 URL 敏感信息无法与当前配置的公开地址标识匹配") + if SECRET_PLACEHOLDER in target_base: + if _redact_url_path(current_base, semantic_key) != target_base: + raise AppError(500, "快照 URL 路径敏感信息无法与当前配置的公开标识唯一匹配") + target_base = current_base + if target_user == SECRET_PLACEHOLDER: + if not current_user or SECRET_PLACEHOLDER in current_user: + raise AppError(500, "快照 URL 用户凭据无法与当前配置匹配") + target_user = current_user + target_query = _preserve_url_parameters(target_query, current_query) + if target_fragment == SECRET_PLACEHOLDER: + if current_fragment is None or SECRET_PLACEHOLDER in current_fragment: + raise AppError(500, "快照 URL 片段敏感信息无法与当前配置匹配") + target_fragment = current_fragment + elif target_fragment is not None: + target_fragment = _preserve_url_parameters(target_fragment, current_fragment) + return _join_sensitive_url(target_base, target_user, target_query, target_fragment) + + +def _map_value(mapping: Mapping[Any, Any] | None, key: str) -> Any: + if mapping is None: + return None + if key in mapping: + return mapping[key] + for item_key, value in mapping.items(): + if str(item_key).lower() == key.lower(): + return value + return None + + +def _structured_sensitive_key(mapping: Mapping[Any, Any] | None) -> str | None: + for discriminator in ("key", "name"): + value = _map_value(mapping, discriminator) + if value is not None and _is_sensitive_key(str(value)): + return str(value) + return None + + +def _redact_sensitive_value(value: Any, parent_key: str | None = None) -> Any: + if _should_treat_as_url(parent_key, value): + return _redact_sensitive_url(str(value), parent_key) + if isinstance(value, dict): + return _redact_sensitive_map(value, parent_key) + if isinstance(value, list): + return [_redact_sensitive_value(item, parent_key) for item in value] + return copy.deepcopy(value) + + +def _redact_sensitive_map(mapping: Mapping[Any, Any] | None, parent_key: str | None = None) -> dict[str, Any]: + result: dict[str, Any] = {} + if mapping is None: + return result + structured = _structured_sensitive_key(mapping) + for raw_key, value in mapping.items(): + if raw_key is None: + continue + key = str(raw_key) + semantic_key = _resolve_url_semantic_key(parent_key, key) + if _is_sensitive_key(key) or (structured is not None and key.lower() == "value"): + result[key] = SECRET_PLACEHOLDER + elif _should_treat_as_url(semantic_key, value): + result[key] = _redact_sensitive_url(str(value), semantic_key) + else: + result[key] = _redact_sensitive_value(value, semantic_key) + return result + + +def _contains_sensitive(value: Any, parent_key: str | None = None) -> bool: + if value == SECRET_PLACEHOLDER: + return True + if isinstance(value, dict): + if _structured_sensitive_key(value) is not None: + return True + return any( + _is_sensitive_key(str(key)) + or ( + _should_treat_as_url(_resolve_url_semantic_key(parent_key, str(key)), item) + and SECRET_PLACEHOLDER + in str(_redact_sensitive_url(str(item), _resolve_url_semantic_key(parent_key, str(key)))) + ) + or _contains_sensitive(item, _resolve_url_semantic_key(parent_key, str(key))) + for key, item in value.items() + ) + if isinstance(value, list): + return any(_contains_sensitive(item, parent_key) for item in value) + return bool( + _should_treat_as_url(parent_key, value) + and SECRET_PLACEHOLDER in str(_redact_sensitive_url(str(value), parent_key)) + ) + + +def _stable_identities(value: Any, parent_key: str | None = None) -> list[str]: + if _should_treat_as_url(parent_key, value): + return [f"url:{_url_public_identity(str(value), parent_key)}"] + if not isinstance(value, dict): + return [] + identities: list[str] = [] + for field in ("id", "name", "key", "url"): + item = _map_value(value, field) + if item is None or not str(item).strip(): + continue + normalized = _url_public_identity(str(item), field) if field == "url" else str(item).strip() + if field in {"name", "key"}: + normalized = normalized.lower() + identities.append(f"{field}:{normalized}") + if value: + identities.append(f"fingerprint:{_json_dump(_normalize_map(value, parent_key), sort_keys=True)}") + return identities + + +def _preserve_sensitive_value(target: Any, current: Any, parent_key: str | None = None) -> Any: + if _should_treat_as_url(parent_key, target): + return _preserve_sensitive_url(str(target), str(current) if isinstance(current, str) else None, parent_key) + if isinstance(target, dict): + return _preserve_sensitive_map(target, current if isinstance(current, dict) else None, parent_key) + if isinstance(target, list): + current_list = current if isinstance(current, list) else [] + target_index: dict[str, list[Any]] = defaultdict(list) + current_index: dict[str, list[Any]] = defaultdict(list) + for item in target: + for identity in _stable_identities(item, parent_key): + target_index[identity].append(item) + for item in current_list: + for identity in _stable_identities(item, parent_key): + current_index[identity].append(item) + result: list[Any] = [] + for item in target: + candidates = { + id(current_index[identity][0]): current_index[identity][0] + for identity in _stable_identities(item, parent_key) + if len(target_index[identity]) == 1 and len(current_index.get(identity, [])) == 1 + } + current_item = next(iter(candidates.values())) if len(candidates) == 1 else None + if current_item is None and _contains_sensitive(item, parent_key): + raise AppError(500, "快照中的敏感列表项缺少唯一稳定标识,无法安全恢复") + result.append(_preserve_sensitive_value(item, current_item, parent_key)) + return result + return copy.deepcopy(target) + + +def _preserve_sensitive_map( + target: Mapping[Any, Any] | None, + current: Mapping[Any, Any] | None, + parent_key: str | None = None, +) -> dict[str, Any]: + result: dict[str, Any] = {} + if target is None: + return result + structured = _structured_sensitive_key(target) + if structured is not None: + target_id = _normalized_key(structured) + current_structured = _structured_sensitive_key(current) + if current_structured is None or _normalized_key(current_structured) != target_id: + raise AppError(500, "快照结构化敏感值的类型标识无法与当前配置唯一匹配") + for raw_key, value in target.items(): + if raw_key is None: + continue + key = str(raw_key) + semantic_key = _resolve_url_semantic_key(parent_key, key) + current_value = _map_value(current, key) + if _is_sensitive_key(key) or (structured is not None and key.lower() == "value"): + if current_value is None or current_value == SECRET_PLACEHOLDER: + raise AppError(500, "快照敏感值无法与当前配置可靠匹配") + result[key] = copy.deepcopy(current_value) + elif _should_treat_as_url(semantic_key, value): + result[key] = _preserve_sensitive_url( + str(value), str(current_value) if isinstance(current_value, str) else None, semantic_key + ) + else: + result[key] = _preserve_sensitive_value(value, current_value, semantic_key) + return result + + +def _url_public_identity(value: str | None, semantic_key: str | None = None) -> str: + if not value: + return "" + base, _user, _query, _fragment = _split_sensitive_url(value) + return _redact_url_path(base, semantic_key) + + +def _redact_snapshot_data(data: dict[str, Any] | None) -> dict[str, Any] | None: + if data is None: + return None + result = copy.deepcopy(data) + functions = result.get("functions") + if isinstance(functions, list): + for function in functions: + if isinstance(function, dict): + function["paramInfo"] = _redact_sensitive_map(function.get("paramInfo") or {}) + providers = result.get("contextProviders") + if isinstance(providers, list): + for provider in providers: + if isinstance(provider, dict): + provider["url"] = _redact_sensitive_url(provider.get("url"), "url") + provider["headers"] = _redact_sensitive_map(provider.get("headers") or {}) + return result + + +def _preserve_snapshot_sensitive(target: dict[str, Any], current: dict[str, Any]) -> dict[str, Any]: + result = copy.deepcopy(target) + current_functions: dict[str, dict[str, Any]] = {} + for item in current.get("functions") or []: + if isinstance(item, dict) and item.get("pluginId"): + current_functions.setdefault(str(item["pluginId"]), item) + for function in result.get("functions") or []: + if not isinstance(function, dict): + continue + current_function = current_functions.get(str(function.get("pluginId"))) + function["paramInfo"] = _preserve_sensitive_map( + function.get("paramInfo") or {}, + current_function.get("paramInfo") if current_function else None, + ) + current_providers: dict[str, list[dict[str, Any]]] = defaultdict(list) + target_providers: dict[str, list[dict[str, Any]]] = defaultdict(list) + for provider in current.get("contextProviders") or []: + if isinstance(provider, dict): + current_providers[_url_public_identity(provider.get("url"), "url")].append(provider) + for provider in result.get("contextProviders") or []: + if isinstance(provider, dict): + target_providers[_url_public_identity(provider.get("url"), "url")].append(provider) + for provider in result.get("contextProviders") or []: + if not isinstance(provider, dict): + continue + identity = _url_public_identity(provider.get("url"), "url") + matches = current_providers.get(identity, []) + current_provider = ( + matches[0] if identity and len(matches) == 1 and len(target_providers[identity]) == 1 else None + ) + if current_provider is None and ( + SECRET_PLACEHOLDER in str(_redact_sensitive_url(provider.get("url"), "url")) + or _contains_sensitive(provider.get("headers") or {}) + ): + raise AppError(500, "上下文源敏感配置缺少唯一 URL 标识,无法安全恢复") + provider["url"] = _preserve_sensitive_url( + provider.get("url"), current_provider.get("url") if current_provider else None, "url" + ) + provider["headers"] = _preserve_sensitive_map( + provider.get("headers") or {}, current_provider.get("headers") if current_provider else None + ) + return result + + +def _normalize_value(value: Any, parent_key: str | None = None) -> Any: + if _should_treat_as_url(parent_key, value): + return _redact_sensitive_url(str(value), parent_key) + if isinstance(value, dict): + return _normalize_map(value, parent_key) + if isinstance(value, list): + return [_normalize_value(item, parent_key) for item in value] + return value + + +def _normalize_map(mapping: Mapping[Any, Any] | None, parent_key: str | None = None) -> dict[str, Any]: + if mapping is None: + return {} + redacted = _redact_sensitive_map(mapping, parent_key) + return {key: _normalize_value(redacted[key], key) for key in sorted(redacted)} + + +def _normalized_snapshot_field(field: str, value: Any) -> Any: + if field == "functions": + result: dict[str, Any] = {} + for function in value or []: + if isinstance(function, dict) and str(function.get("pluginId") or "").strip(): + result[str(function["pluginId"])] = _normalize_map(function.get("paramInfo") or {}) + return {key: result[key] for key in sorted(result)} + if field == "contextProviders": + return [_json_dump(_normalize_value(item), sort_keys=True) for item in value or [] if item is not None] + if field in {"correctWordFileIds", "tagNames"}: + return sorted(str(item) for item in value or [] if str(item).strip()) + if field == "summaryMemory": + return "" if value is None or not str(value).strip() else str(value) + return value + + +def _changed_snapshot_fields(current: dict[str, Any] | None, next_value: dict[str, Any] | None) -> list[str]: + if next_value is None: + return ["restore"] + source = current or {} + return [ + field + for field in SNAPSHOT_FIELD_ORDER + if _normalized_snapshot_field(field, source.get(field)) + != _normalized_snapshot_field(field, next_value.get(field)) + ] + + +def _snapshot_state_token(data: Mapping[str, Any]) -> str: + normalized = {field: _normalized_snapshot_field(field, data.get(field)) for field in sorted(SNAPSHOT_FIELD_ORDER)} + payload = _json_dump(normalized, sort_keys=True).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +async def run_chat_summary_task(session_id: str) -> None: + async with get_session_factory()() as session: + await AgentService(session).generate_chat_summary(session_id) + + +async def run_chat_title_task(session_id: str) -> None: + async with get_session_factory()() as session: + await AgentService(session).generate_chat_title(session_id) + + +async def _cancel_voiceprint_after_commit(voiceprint_id: str) -> None: + try: + async with get_session_factory()() as session: + configured = await SystemParamService(session).get_value("server.voice_print", from_cache=True) + client = VoicePrintClient(configured or "", timeout=get_settings().external_request_timeout_seconds) + await client.cancel(voiceprint_id) + except Exception: + logger.exception("Unable to cancel external voiceprint %s after database deletion", voiceprint_id) + + +async def redact_legacy_agent_snapshots() -> int: + """Worker-safe entry point for the legacy snapshot redaction job.""" + settings = get_settings() + async with distributed_lock("jobs:agent-snapshot-redaction", settings.job_lock_ttl_seconds) as acquired: + if not acquired: + return 0 + async with get_session_factory()() as session: + return await AgentService(session).redact_legacy_snapshots() diff --git a/main/manager-api-fastapi/app/services/config.py b/main/manager-api-fastapi/app/services/config.py new file mode 100644 index 00000000..b9076691 --- /dev/null +++ b/main/manager-api-fastapi/app/services/config.py @@ -0,0 +1,521 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import logging +import math +import urllib.parse +from copy import deepcopy +from typing import Any, cast + +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from redis.asyncio import Redis + +from app.core.errors import AppError +from app.core.redis import JavaRedisCodec, get_redis +from app.repositories.config import ConfigRepository + +logger = logging.getLogger(__name__) + + +class ConfigService: + def __init__(self, repository: ConfigRepository, *, redis: Redis | None = None): + self.repository = repository + self.redis = redis or get_redis() + + async def get_config(self, *, use_cache: bool) -> dict[str, Any]: + if use_cache: + cached = JavaRedisCodec.decode(await cast(Any, self.redis.get)("server:config")) + if isinstance(cached, dict): + return cast(dict[str, Any], cached) + result = self._build_base_config(await self.repository.list_params()) + template = await self.repository.get_default_template() + if template is None: + raise AppError(10183) + await self._build_module_config( + result=result, + assistant_name=None, + prompt=None, + summary_memory=None, + voice=None, + reference_audio=None, + reference_text=None, + language=None, + tts_volume=None, + tts_rate=None, + tts_pitch=None, + vad_model_id=self._string(template.get("vad_model_id")), + asr_model_id=self._string(template.get("asr_model_id")), + llm_model_id=None, + vllm_model_id=None, + slm_model_id=None, + tts_model_id=None, + mem_model_id=None, + intent_model_id=None, + rag_model_id=None, + ) + await cast(Any, self.redis.set)("server:config", JavaRedisCodec.encode(result), ex=24 * 60 * 60) + return result + + async def get_agent_models( + self, + mac_address: str, + selected_module: dict[str, str], + ) -> dict[str, Any]: + temporary_key = f"tmp_register_mac:{mac_address}" + temporary = JavaRedisCodec.decode(await cast(Any, self.redis.get)(temporary_key)) + if temporary == "true": + await cast(Any, self.redis.delete)(temporary_key) + return await self.get_config(use_cache=True) + + device = await self.repository.get_device_by_mac(mac_address) + if device is None: + safe_address = mac_address.replace(":", "_").lower() + activation = JavaRedisCodec.decode( + await cast(Any, self.redis.get)(f"ota:activation:data:{safe_address}") + ) + if isinstance(activation, dict) and activation.get("activation_code"): + raise AppError(10042, params=(str(activation["activation_code"]),)) + raise AppError(10041) + + agent_id = self._string(device.get("agent_id")) + agent = await self.repository.get_agent(agent_id or "") if agent_id else None + if agent is None: + raise AppError(10053) + + voice: str | None = None + reference_audio: str | None = None + reference_text: str | None = None + language: str | None = None + voice_id = self._string(agent.get("tts_voice_id")) + timbre = await self._timbre(voice_id) if voice_id else None + if timbre is not None: + voice = self._string(timbre.get("tts_voice")) + reference_audio = self._string(timbre.get("reference_audio")) + reference_text = self._string(timbre.get("reference_text")) + chosen_language = self._string(agent.get("tts_language")) + if chosen_language and chosen_language.strip(): + language = chosen_language + else: + languages = self._string(timbre.get("languages")) + if languages and languages.strip(): + language = languages.split("、", 1)[0].strip() + elif voice_id: + clone = await self.repository.get_voice_clone(voice_id) + if clone is not None: + voice = self._string(clone.get("voice_id")) + chosen_language = self._string(agent.get("tts_language")) + language = chosen_language if chosen_language and chosen_language.strip() else "普通话" + + result: dict[str, Any] = { + "device_max_output_size": await self._param("device_max_output_size", from_cache=True) + } + memory_model = self._string(agent.get("mem_model_id")) + chat_history = agent.get("chat_history_conf") + if memory_model == "Memory_nomem": + chat_history = 0 + elif memory_model is not None and memory_model != "Memory_nomem" and chat_history is None: + chat_history = 2 + result["chat_history_conf"] = chat_history + + vad_model_id = self._string(agent.get("vad_model_id")) + asr_model_id = self._string(agent.get("asr_model_id")) + if selected_module.get("VAD") == vad_model_id: + vad_model_id = None + if selected_module.get("ASR") == asr_model_id: + asr_model_id = None + + if self._string(agent.get("intent_model_id")) != "Intent_nointent": + plugins = await self._plugins(str(agent["id"])) + if plugins: + result["plugins"] = plugins + + mcp_endpoint = await self._mcp_address(str(agent["id"])) + if mcp_endpoint and mcp_endpoint.startswith("ws"): + result["mcp_endpoint"] = mcp_endpoint.replace("/mcp/", "/call/") + + context_providers = self._json_value(await self.repository.get_context_providers(str(agent["id"]))) + if isinstance(context_providers, list) and context_providers: + result["context_providers"] = context_providers + + await self._add_voiceprint(str(agent["id"]), result) + await self._build_module_config( + result=result, + assistant_name=self._string(agent.get("agent_name")), + prompt=self._string(agent.get("system_prompt")), + summary_memory=self._string(agent.get("summary_memory")), + voice=voice, + reference_audio=reference_audio, + reference_text=reference_text, + language=language, + tts_volume=self._integer(agent.get("tts_volume")), + tts_rate=self._integer(agent.get("tts_rate")), + tts_pitch=self._integer(agent.get("tts_pitch")), + vad_model_id=vad_model_id, + asr_model_id=asr_model_id, + llm_model_id=self._string(agent.get("llm_model_id")), + vllm_model_id=self._string(agent.get("vllm_model_id")), + slm_model_id=self._string(agent.get("slm_model_id")), + tts_model_id=self._string(agent.get("tts_model_id")), + mem_model_id=memory_model, + intent_model_id=self._string(agent.get("intent_model_id")), + rag_model_id=None, + ) + return result + + async def get_correct_words(self, mac_address: str) -> list[str]: + device = await self.repository.get_device_by_mac(mac_address) + if device is None or device.get("agent_id") is None: + return [] + rows = await self.repository.get_correct_word_items(str(device["agent_id"])) + return [ + f"{self._java_string(row.get('source_word'))}|{self._java_string(row.get('target_word'))}" + for row in rows + ] + + @staticmethod + def _build_base_config(rows: list[dict[str, Any]]) -> dict[str, Any]: + config: dict[str, Any] = {} + for row in rows: + code = str(row.get("param_code") or "") + keys = code.split(".") + current = config + for key in keys[:-1]: + if key not in current: + current[key] = {} + nested = current[key] + if not isinstance(nested, dict): + raise TypeError(f"configuration path {code} collides with scalar key {key}") + current = nested + value = str(row.get("param_value") or "") + value_type = str(row.get("value_type") or "string").lower() + current[keys[-1]] = ConfigService._typed_param(value, value_type) + return config + + @staticmethod + def _typed_param(value: str, value_type: str) -> Any: + if value_type == "number": + try: + number = float(value) + # Java's implementation returns an Integer only when the double + # equals its narrowing conversion to a signed 32-bit int. + if math.isnan(number): + narrowed = 0 + elif number >= 2**31 - 1: + narrowed = 2**31 - 1 + elif number <= -(2**31): + narrowed = -(2**31) + else: + narrowed = int(number) + return narrowed if number == narrowed else number + except ValueError: + return value + if value_type == "boolean": + return value.lower() == "true" + if value_type == "array": + return [item.strip() for item in value.split(";") if item.strip()] + if value_type == "json": + try: + return json.loads(value) + except json.JSONDecodeError: + return value + return value + + async def _build_module_config( + self, + *, + result: dict[str, Any], + assistant_name: str | None, + prompt: str | None, + summary_memory: str | None, + voice: str | None, + reference_audio: str | None, + reference_text: str | None, + language: str | None, + tts_volume: int | None, + tts_rate: int | None, + tts_pitch: int | None, + vad_model_id: str | None, + asr_model_id: str | None, + llm_model_id: str | None, + vllm_model_id: str | None, + slm_model_id: str | None, + tts_model_id: str | None, + mem_model_id: str | None, + intent_model_id: str | None, + rag_model_id: str | None, + ) -> None: + selected: dict[str, str] = {} + model_types = ("VAD", "ASR", "TTS", "Memory", "Intent", "LLM", "VLLM", "SLM", "RAG") + model_ids = ( + vad_model_id, + asr_model_id, + tts_model_id, + mem_model_id, + intent_model_id, + llm_model_id, + vllm_model_id, + slm_model_id, + rag_model_id, + ) + intent_llm_id: str | None = None + memory_llm_id: str | None = None + for model_type, model_id in zip(model_types, model_ids, strict=True): + if model_id is None: + continue + model = await self._model(model_id) + if model is None: + continue + configuration = self._json_value(model.get("config_json")) + type_config: dict[str, Any] = {} + if isinstance(configuration, dict): + configuration = deepcopy(configuration) + type_config[str(model["id"])] = configuration + if model_type == "TTS": + optional_values = { + "private_voice": voice, + "ref_audio": reference_audio, + "ref_text": reference_text, + "language": language, + "ttsVolume": tts_volume, + "ttsRate": tts_rate, + "ttsPitch": tts_pitch, + } + configuration.update({key: value for key, value in optional_values.items() if value is not None}) + if configuration.get("type") == "huoshan_double_stream" and voice and voice.startswith("S_"): + configuration["resource_id"] = "seed-icl-1.0" + elif model_type == "Intent": + if configuration.get("type") == "intent_llm": + intent_llm_id = self._string(configuration.get("llm")) + if intent_llm_id == llm_model_id: + intent_llm_id = None + functions = configuration.get("functions") + if isinstance(functions, str) and functions.strip(): + configuration["functions"] = functions.split(";") + elif model_type == "Memory" and configuration.get("type") == "mem_local_short": + memory_llm_id = self._string(configuration.get("llm")) + if memory_llm_id == llm_model_id: + memory_llm_id = None + elif model_type == "LLM": + for extra_id in (intent_llm_id, memory_llm_id): + if extra_id and extra_id not in type_config: + extra = await self._model(extra_id) + if extra is not None: + type_config[str(extra["id"])] = deepcopy(self._json_value(extra.get("config_json"))) + if slm_model_id and slm_model_id != llm_model_id and slm_model_id not in type_config: + small = await self._model(slm_model_id) + small_config = None if small is None else self._json_value(small.get("config_json")) + if small is not None and small_config is not None: + type_config[str(small["id"])] = deepcopy(small_config) + result[model_type] = type_config + selected[model_type] = str(model["id"]) + result["selected_module"] = selected + if prompt and prompt.strip(): + replacement = assistant_name if assistant_name and assistant_name.strip() else "小智" + prompt = prompt.replace("{{assistant_name}}", replacement) + result["prompt"] = prompt + result["summaryMemory"] = summary_memory + + async def _plugins(self, agent_id: str) -> dict[str, Any]: + mappings = await self.repository.get_plugin_mappings(agent_id) + result: dict[str, Any] = {} + knowledge_groups: dict[str, list[dict[str, Any]]] = {} + knowledge_models: dict[str, dict[str, Any]] = {} + for mapping in mappings: + provider_code = self._string(mapping.get("provider_code")) + if provider_code and provider_code.strip(): + value = mapping.get("param_info") + result[provider_code] = ( + json.dumps(value, ensure_ascii=False, separators=(",", ":")) if isinstance(value, dict) else value + ) + # Java removes knowledge mappings by iterating the original list backwards, which reverses dataset order. + for mapping in reversed(mappings): + provider_code = self._string(mapping.get("provider_code")) + if provider_code and provider_code.strip(): + continue + dataset = await self.repository.get_dataset(str(mapping["plugin_id"])) + if dataset is None or dataset.get("rag_model_id") is None: + continue + model = await self._model(str(dataset["rag_model_id"])) + if model is None or not model.get("model_code"): + continue + code = str(model["model_code"]) + knowledge_groups.setdefault(code, []).append(dataset) + knowledge_models[code] = model + for code, datasets in knowledge_groups.items(): + model_config = self._json_value(knowledge_models[code].get("config_json")) + if not isinstance(model_config, dict): + continue + names = ",".join(self._java_string(dataset.get("name")) for dataset in datasets) + descriptions = ",".join( + self._java_string(dataset.get("description")) for dataset in datasets + ) + params = { + "base_url": model_config.get("base_url"), + "api_key": model_config.get("api_key"), + "dataset_ids": [dataset.get("dataset_id") for dataset in datasets], + "description": ( + f"如果用户询问与【{names}】涵盖的主体范围相关内容时应调用本方法," + f"用于查询:{descriptions}" + ), + } + result[f"search_from_{code}"] = json.dumps(params, ensure_ascii=False, separators=(",", ":")) + return result + + async def _mcp_address(self, agent_id: str) -> str | None: + endpoint = await self._param("server.mcp_endpoint", from_cache=True) + if endpoint is None or not endpoint.strip() or endpoint == "null": + return None + parsed = urllib.parse.urlsplit(endpoint) + query = parsed.query + marker_index = query.find("key=") + key = query[marker_index + len("key=") :] + scheme = "wss" if parsed.scheme == "https" else "ws" + path = parsed.path + prefix_path = path[: path.rfind("/")] if "/" in path else "" + prefix = urllib.parse.urlunsplit((scheme, parsed.netloc, prefix_path, "", "")) + token = self._aes_encrypt( + key, + json.dumps( + {"agentId": hashlib.md5(agent_id.encode(), usedforsecurity=False).hexdigest()}, + ensure_ascii=False, + separators=(", ", ": "), + ), + ) + return f"{prefix}/mcp/?token={urllib.parse.quote_plus(token)}" + + @staticmethod + def _aes_encrypt(key: str, plaintext: str) -> str: + key_bytes = key.encode() + if len(key_bytes) not in {16, 24, 32}: + key_bytes = (key_bytes + bytes(32))[:32] + block_size = 16 + padding_length = block_size - len(plaintext.encode()) % block_size + padded = plaintext.encode() + bytes([padding_length]) * padding_length + # Java's published MCP token format is AES/ECB/PKCS5Padding; changing modes breaks existing servers. + encryptor = Cipher(algorithms.AES(key_bytes), modes.ECB()).encryptor() # noqa: S305 + encrypted = encryptor.update(padded) + encryptor.finalize() + return base64.b64encode(encrypted).decode("ascii") + + async def _add_voiceprint(self, agent_id: str, result: dict[str, Any]) -> None: + try: + url = await self._param("server.voice_print", from_cache=True) + if url is None or not url.strip() or url == "null": + return + rows = await self.repository.get_voiceprints(agent_id) + if not rows: + return + speakers = [ + ( + f"{self._java_string(row.get('id'))}," + f"{self._java_string(row.get('source_name'))},{row.get('introduce') or ''}" + ) + for row in rows + ] + threshold_value = await self._param("server.voiceprint_similarity_threshold", from_cache=True) + try: + threshold = ( + float(threshold_value) + if threshold_value is not None and threshold_value not in ("", "null") + else 0.4 + ) + except ValueError: + threshold = 0.4 + result["voiceprint"] = {"url": url, "speakers": speakers, "similarity_threshold": threshold} + except Exception: + logger.warning("Voiceprint configuration lookup failed", exc_info=True) + + async def _param(self, code: str, *, from_cache: bool) -> str | None: + if from_cache: + cached = JavaRedisCodec.decode(await cast(Any, self.redis.hget)("sys:params", code)) + if cached is not None: + return str(cached) + value = await self.repository.get_param_value(code) + if value is not None and from_cache: + await cast(Any, self.redis.hset)("sys:params", code, JavaRedisCodec.encode(value)) + await cast(Any, self.redis.expire)("sys:params", 24 * 60 * 60) + return value + + async def _model(self, model_id: str) -> dict[str, Any] | None: + key = f"model:data:{model_id}" + cached = JavaRedisCodec.decode(await cast(Any, self.redis.get)(key)) + if isinstance(cached, dict): + return self._normalize_cached(cast(dict[str, Any], cached)) + model = await self.repository.get_model(model_id) + if model is not None: + raw_configuration = model.get("config_json") + if isinstance(raw_configuration, str): + parsed_configuration = json.loads(raw_configuration) + if parsed_configuration is not None and not isinstance(parsed_configuration, dict): + raise TypeError("ModelConfigEntity.configJson must be a JSON object") + model["config_json"] = parsed_configuration + await cast(Any, self.redis.set)( + key, + JavaRedisCodec.encode( + model, + java_type="xiaozhi.modules.model.entity.ModelConfigEntity", + field_java_types={ + "configJson": "cn.hutool.json.JSONObject", + "creator": "java.lang.Long", + "updater": "java.lang.Long", + }, + ), + ex=24 * 60 * 60, + ) + return model + + async def _timbre(self, timbre_id: str) -> dict[str, Any] | None: + key = f"timbre:details:{timbre_id}" + cached = JavaRedisCodec.decode(await cast(Any, self.redis.get)(key)) + if isinstance(cached, dict): + return self._normalize_cached(cast(dict[str, Any], cached)) + timbre = await self.repository.get_timbre(timbre_id) + if timbre is not None: + await cast(Any, self.redis.set)( + key, + JavaRedisCodec.encode( + timbre, + java_type="xiaozhi.modules.timbre.vo.TimbreDetailsVO", + field_java_types={"sort": "java.lang.Long"}, + ), + ex=24 * 60 * 60, + ) + return timbre + + @staticmethod + def _normalize_cached(value: dict[str, Any]) -> dict[str, Any]: + aliases = { + "modelType": "model_type", + "modelCode": "model_code", + "modelName": "model_name", + "configJson": "config_json", + "ttsVoice": "tts_voice", + "referenceAudio": "reference_audio", + "referenceText": "reference_text", + "ttsModelId": "tts_model_id", + } + return {aliases.get(key, key): item for key, item in value.items() if key != "@class"} + + @staticmethod + def _json_value(value: Any) -> Any: + if isinstance(value, bytes): + value = value.decode() + if isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError: + return value + return value + + @staticmethod + def _string(value: Any) -> str | None: + return None if value is None else str(value) + + @staticmethod + def _integer(value: Any) -> int | None: + return None if value is None else int(value) + + @staticmethod + def _java_string(value: Any) -> str: + return "null" if value is None else str(value) diff --git a/main/manager-api-fastapi/app/services/correctword.py b/main/manager-api-fastapi/app/services/correctword.py new file mode 100644 index 00000000..6374e6a6 --- /dev/null +++ b/main/manager-api-fastapi/app/services/correctword.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import uuid +from typing import Any + +from app.core.errors import AppError +from app.core.security import AuthUser, shanghai_now_naive +from app.repositories.correctword import CorrectWordRepository +from app.schemas.correctword import CorrectWordFileBody + + +def _parse_lines(lines: list[str]) -> list[tuple[str, str]]: + result: list[tuple[str, str]] = [] + for raw in lines: + line = raw.strip() + if not line or "|" not in line: + continue + source, target = line.split("|", 1) + if source.strip() and target.strip(): + result.append((source.strip(), target.strip())) + return result + + +def _content_lines(value: str | None) -> list[str]: + if value is None: + return [] + # Java String.split keeps one empty element for the empty source string, + # while still discarding trailing empty elements for non-empty strings. + if value == "": + return [""] + lines = value.split("\n") + while lines and lines[-1] == "": + lines.pop() + return lines + + +def file_vo(row: dict[str, Any]) -> dict[str, Any]: + return { + "id": row.get("id"), + "fileName": row.get("file_name"), + "wordCount": row.get("word_count"), + "content": _content_lines(row.get("content")), + "createdAt": row.get("created_at"), + "updatedAt": row.get("updated_at"), + } + + +class CorrectWordService: + def __init__(self, repository: CorrectWordRepository): + self.repository = repository + + @staticmethod + def validate(body: CorrectWordFileBody, *, check_size: bool) -> None: + if body.file_name is None or not body.file_name.strip(): + raise AppError(10034, "文件名不能为空") + if not body.content: + raise AppError(10034, "替换词内容不能为空") + if check_size and body.file_size is not None and body.file_size > 1024 * 1024: + raise AppError(10204) + + async def create(self, body: CorrectWordFileBody, user: AuthUser) -> dict[str, Any]: + self.validate(body, check_size=True) + assert body.file_name is not None + assert body.content is not None + items = _parse_lines(body.content) + file_id, now = uuid.uuid4().hex, shanghai_now_naive() + values = { + "id": file_id, + "file_name": body.file_name, + "word_count": len(items), + "content": "\n".join(body.content), + "creator": user.id, + "now": now, + } + async with self.repository.session.begin(): + if await self.repository.name_exists(user.id, body.file_name): + raise AppError(10203) + await self.repository.insert_file(values) + await self.repository.insert_items( + [ + {"id": uuid.uuid4().hex, "file_id": file_id, "source_word": source, "target_word": target} + for source, target in items + ] + ) + return file_vo({**values, "created_at": now, "updated_at": None}) + + async def update(self, file_id: str, body: CorrectWordFileBody, user: AuthUser) -> None: + self.validate(body, check_size=False) + assert body.file_name is not None + assert body.content is not None + items = _parse_lines(body.content) + async with self.repository.session.begin(): + row = await self.repository.get_file(file_id, for_update=True) + if row is None: + return + if await self.repository.name_exists(user.id, body.file_name, file_id): + raise AppError(500, f"文件名已存在:{body.file_name}") + await self.repository.delete_items(file_id) + await self.repository.insert_items( + [ + {"id": uuid.uuid4().hex, "file_id": file_id, "source_word": source, "target_word": target} + for source, target in items + ] + ) + await self.repository.update_file( + { + "id": file_id, + "file_name": body.file_name, + "word_count": len(items), + "content": "\n".join(body.content), + "updater": user.id, + "now": shanghai_now_naive(), + } + ) + + async def page(self, user: AuthUser, page: str | None, limit: str | None) -> dict[str, Any]: + current, size = max(int(page or "1"), 1), int(limit or "10") + rows, total = await self.repository.list_files(user.id, offset=(current - 1) * size, limit=size) + return {"total": total, "list": [file_vo(row) for row in rows]} + + async def all(self, user: AuthUser) -> list[dict[str, Any]]: + rows, _ = await self.repository.list_files(user.id) + return [file_vo(row) for row in rows] + + async def get(self, file_id: str) -> dict[str, Any] | None: + row = await self.repository.get_file(file_id) + return file_vo(row) if row else None + + async def delete(self, file_ids: list[str]) -> None: + async with self.repository.session.begin(): + for file_id in file_ids: + if file_id and file_id.strip(): + await self.repository.delete_file_graph(file_id.strip()) diff --git a/main/manager-api-fastapi/app/services/device.py b/main/manager-api-fastapi/app/services/device.py new file mode 100644 index 00000000..dd787a4a --- /dev/null +++ b/main/manager-api-fastapi/app/services/device.py @@ -0,0 +1,978 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import logging +import random +import re +import secrets +import uuid +from collections.abc import Callable, Mapping, Sequence +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, cast +from zoneinfo import ZoneInfo + +import httpx +from redis.asyncio import Redis +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import get_settings +from app.core.database import get_session_factory +from app.core.errors import AppError +from app.core.redis import JavaRedisCodec, get_redis +from app.core.security import AuthUser, shanghai_now_naive +from app.integrations.mqtt_gateway import post_json +from app.repositories.device import DeviceRepository +from app.schemas.device import DeviceManualAddRequest, DeviceReportRequest, DeviceUpdateRequest, OtaRecord +from app.services.system_params import SystemParamService + +logger = logging.getLogger(__name__) + +DEFAULT_TTL_SECONDS = 24 * 60 * 60 +INVALID_FIRMWARE_URL = ( + "http://xiaozhi.server.com:8002/xiaozhi/otaMag/download/NOT_ACTIVATED_FIRMWARE_THIS_IS_A_INVALID_URL" +) +MAC_PATTERN = re.compile(r"^([0-9A-Za-z]{2}[:-]){5}([0-9A-Za-z]{2})$") +OTA_ORDER_COLUMNS = { + "id": "id", + "firmwareName": "firmware_name", + "firmware_name": "firmware_name", + "type": "type", + "version": "version", + "size": "size", + "sort": "sort", + "updateDate": "update_date", + "update_date": "update_date", + "createDate": "create_date", + "create_date": "create_date", +} + + +def is_blank(value: str | None) -> bool: + return value is None or not value.strip() + + +def _java_semicolon_split(value: str) -> list[str]: + parts = value.split(";") + while parts and parts[-1] == "": + parts.pop() + return parts + + +def _mapping(value: Any) -> dict[str, Any] | None: + if isinstance(value, dict): + if "@class" in value: + return {str(key): item for key, item in value.items() if key != "@class"} + return {str(key): item for key, item in value.items()} + if isinstance(value, list) and len(value) == 2 and isinstance(value[1], dict): + return {str(key): item for key, item in value[1].items()} + return None + + +async def redis_get(key: str, client: Redis | None = None) -> Any: + selected = client or get_redis() + raw = await cast(Any, selected.get(key)) + return JavaRedisCodec.decode(raw) + + +async def redis_set(key: str, value: Any, *, ttl: int = DEFAULT_TTL_SECONDS, client: Redis | None = None) -> None: + selected = client or get_redis() + await cast(Any, selected.set(key, JavaRedisCodec.encode(value), ex=ttl)) + + +async def redis_delete(*keys: str, client: Redis | None = None) -> None: + if not keys: + return + selected = client or get_redis() + await cast(Any, selected.delete(*keys)) + + +async def redis_increment(key: str, *, ttl: int = DEFAULT_TTL_SECONDS, client: Redis | None = None) -> int: + selected = client or get_redis() + value = int(await cast(Any, selected.incr(key))) + await cast(Any, selected.expire(key, ttl)) + return value + + +class DeviceService: + def __init__( + self, + session: AsyncSession, + *, + redis_client: Redis | None = None, + http_client: httpx.AsyncClient | None = None, + ): + self.session = session + self.repository = DeviceRepository(session) + self.params = SystemParamService(session) + self.redis = redis_client + self.http_client = http_client + + async def register_device(self, mac_address: str) -> str: + while True: + code = f"{secrets.randbelow(1_000_000):06d}" + key = f"sys:device:captcha:{code}" + if is_blank(cast(str | None, await redis_get(key, self.redis))): + await redis_set(key, mac_address, client=self.redis) + return code + + async def activate_bound_device(self, *, agent_id: str, activation_code: str, user: AuthUser) -> None: + if is_blank(activation_code): + raise AppError(10061) + code_key = f"ota:activation:code:{activation_code}" + device_id_value = await redis_get(code_key, self.redis) + if device_id_value in (None, ""): + raise AppError(10062) + device_id = str(device_id_value) + safe_device_id = device_id.replace(":", "_").lower() + data_key = f"ota:activation:data:{safe_device_id}" + cached = _mapping(await redis_get(data_key, self.redis)) + if cached is None or str(cached.get("activation_code") or "") != activation_code: + raise AppError(10062) + if await self.repository.get_device(device_id) is not None: + raise AppError(10063) + + now = shanghai_now_naive() + values = { + "id": device_id, + "user_id": user.id, + "mac_address": cached.get("mac_address"), + "last_connected_at": now, + "auto_update": 1, + "board": cached.get("board"), + "alias": None, + "agent_id": agent_id, + "app_version": cached.get("app_version"), + "sort": None, + "updater": user.id, + "update_date": now, + "creator": user.id, + "create_date": now, + } + try: + await self.repository.insert_device(values) + await self.session.commit() + except Exception: + await self.session.rollback() + raise + await redis_delete(data_key, code_key, f"agent:device:count:{agent_id}", client=self.redis) + + async def list_user_devices(self, user_id: int, agent_id: str) -> list[dict[str, Any]]: + devices = await self.repository.get_user_devices(user_id, agent_id) + return [self._user_device_view(row) for row in devices] + + async def get_online_data(self, agent_id: str, user: AuthUser) -> str: + gateway = await self.params.get_value("server.mqtt_manager_api", from_cache=True) + if is_blank(gateway) or gateway == "null": + return "" + devices = await self.repository.get_user_devices(user.id, agent_id) + client_ids = { + self._mqtt_client_id( + str(device.get("board") or "GID_default"), + str(device.get("mac_address") or "unknown"), + ) + for device in devices + } + if not client_ids: + return "" + signature_key = await self.params.get_value("server.mqtt_signature_key", from_cache=False) + return await post_json( + f"http://{gateway}/api/devices/status", + {"clientIds": sorted(client_ids)}, + signature_key or "", + timeout_seconds=get_settings().external_request_timeout_seconds, + client=self.http_client, + ) + + async def unbind(self, *, user_id: int, device_id: str) -> None: + device = await self.repository.get_device(device_id) + if device is None: + return + mac_address = device.get("mac_address") + agent_id = device.get("agent_id") + if not is_blank(None if agent_id is None else str(agent_id)): + await redis_delete(f"agent:device:count:{agent_id}", client=self.redis) + try: + await self.repository.delete_device_for_user(device_id, user_id) + await self.session.commit() + except Exception: + await self.session.rollback() + raise + try: + if mac_address is not None: + await self.repository.delete_address_books_for_macs([str(mac_address)]) + await self.session.commit() + except Exception: + await self.session.rollback() + raise + await self.refresh_address_book_cache() + + async def update_device( + self, + *, + device_id: str, + request: DeviceUpdateRequest, + user: AuthUser, + ) -> bool: + device = await self.repository.get_device(device_id) + if device is None or int(device.get("user_id") or -1) != user.id: + return False + await self.repository.update_device_info( + device_id, + auto_update=request.auto_update, + alias=request.alias, + updater=user.id, + now=shanghai_now_naive(), + ) + await self.session.commit() + return True + + async def manual_add(self, *, request: DeviceManualAddRequest, user: AuthUser) -> None: + mac_address = request.mac_address + if mac_address is not None and await self.repository.get_device_by_mac(mac_address) is not None: + raise AppError(10161) + now = shanghai_now_naive() + values = { + "id": uuid.uuid4().hex if mac_address in (None, "") else mac_address, + "user_id": user.id, + "mac_address": mac_address, + "last_connected_at": now, + "auto_update": 1, + "board": request.board, + "alias": None, + "agent_id": request.agent_id, + "app_version": request.app_version, + "sort": None, + "updater": user.id, + "update_date": now, + "creator": user.id, + "create_date": now, + } + try: + await self.repository.insert_device(values) + await self.session.commit() + except Exception: + await self.session.rollback() + raise + agent_cache_id = "null" if request.agent_id is None else request.agent_id + await redis_delete(f"agent:device:count:{agent_cache_id}", client=self.redis) + + async def get_tools(self, *, device_id: str, user: AuthUser) -> dict[str, Any] | None: + gateway_and_device = await self._gateway_device(device_id, user) + if gateway_and_device is None: + return None + gateway, device = gateway_and_device + client_id = self._mqtt_client_id( + str(device.get("board") or "GID_default"), + str(device.get("mac_address") or "unknown"), + ) + url = f"http://{gateway}/api/commands/{client_id}" + all_tools: list[Any] = [] + cursor: str | None = None + while True: + params: dict[str, Any] = {"withUserTools": True} + if cursor is not None and cursor.strip(): + params["cursor"] = cursor + body = { + "type": "mcp", + "payload": {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": params}, + } + response_body = await self._post_gateway(url, body) + if is_blank(response_body): + break + payload = json.loads(response_body) + if not isinstance(payload, dict) or not bool(payload.get("success", False)): + break + data = payload.get("data") + if not isinstance(data, dict): + break + tools = data.get("tools") + if isinstance(tools, list): + all_tools.extend(tools) + next_cursor = data.get("nextCursor") + if not isinstance(next_cursor, str) or not next_cursor.strip(): + break + cursor = next_cursor + return None if not all_tools else {"tools": all_tools} + + async def call_tool( + self, + *, + device_id: str, + tool_name: str, + arguments: dict[str, Any] | None, + user: AuthUser, + ) -> Any: + gateway_and_device = await self._gateway_device(device_id, user) + if gateway_and_device is None: + return None + gateway, device = gateway_and_device + client_id = self._mqtt_client_id( + str(device.get("board") or "GID_default"), + str(device.get("mac_address") or "unknown"), + ) + response_body = await self._post_gateway( + f"http://{gateway}/api/commands/{client_id}", + { + "type": "mcp", + "payload": { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": tool_name, "arguments": arguments}, + }, + }, + ) + if is_blank(response_body): + return None + payload = json.loads(response_body) + if not isinstance(payload, dict) or not bool(payload.get("success", False)): + return None + data = payload.get("data") + content = data.get("content") if isinstance(data, dict) else None + if not isinstance(content, list) or not content or not isinstance(content[0], dict): + return None + first = content[0] + if first.get("type") != "text" or not isinstance(first.get("text"), str): + return None + text = str(first["text"]) + if not text.strip(): + return None + trimmed = text.strip() + if trimmed.startswith("{") or trimmed.startswith("["): + try: + parsed = json.loads(trimmed) + return parsed if isinstance(parsed, dict) else trimmed + except json.JSONDecodeError: + return trimmed + if trimmed == "true": + return True + if trimmed == "false": + return False + return trimmed + + async def address_book(self, mac_address: str) -> list[dict[str, Any]]: + rows = await self.repository.get_address_book(mac_address) + for row in rows: + if row.get("has_permission") is not None: + row["has_permission"] = bool(row["has_permission"]) + return rows + + async def lookup_address_book(self, *, caller_mac: str, nickname: str) -> dict[str, str | None] | None: + books = await self.all_address_books() + caller_book = books.get(caller_mac.lower()) + if caller_book is None: + return None + target_with_permission = caller_book.get(nickname) + if target_with_permission is None: + return None + parts = target_with_permission.split("|") + target_mac = parts[0] + has_permission = len(parts) > 1 and parts[1] == "1" + target_book = books.get(target_mac.lower()) + if target_book is None: + return None + caller_nickname = target_book.get(caller_mac.lower()) + return { + "targetMac": target_mac, + "callerNickname": caller_nickname, + "hasPermission": "true" if has_permission else "false", + } + + async def call_by_nickname(self, *, caller_mac: str, nickname: str, answer: bool) -> dict[str, Any]: + books = await self.all_address_books() + if answer: + return await self._post_call("/api/call/accept", {"mac": caller_mac}, "接听") + caller_book = books.get(caller_mac.lower()) + if caller_book is None or nickname not in caller_book: + return {"status": "error", "message": f"未找到备注为'{nickname}'的设备"} + parts = caller_book[nickname].split("|") + target_mac = parts[0] + allowed = len(parts) > 1 and parts[1] == "1" + if not allowed: + return {"status": "error", "message": "呼叫失败,您没有权限呼叫该设备"} + target_book = books.get(target_mac.lower()) + caller_nickname = target_book.get(caller_mac.lower()) if target_book is not None else None + if is_blank(caller_nickname): + caller = await self.repository.get_device_by_mac(caller_mac) + if caller is None: + raise RuntimeError("caller device does not exist") + caller_nickname = None if caller.get("alias") is None else str(caller["alias"]) + if is_blank(caller_nickname): + caller_nickname = self._mac_device_name(caller_mac) + return await self._post_call( + "/api/call/request", + {"caller_mac": caller_mac, "target_mac": target_mac, "caller_nickname": caller_nickname}, + "呼叫", + ) + + async def save_address_book( + self, + *, + mac_address: str, + target_mac: str, + alias: str | None, + has_permission: bool | None, + actor: int, + ) -> None: + record = await self.repository.get_address_book_record(mac_address, target_mac) + now = shanghai_now_naive() + if record is None: + final_alias = alias + if is_blank(final_alias): + target = await self.repository.get_device_by_mac(target_mac) + if target is None: + raise RuntimeError("target device does not exist") + final_alias = None if target.get("alias") is None else str(target["alias"]) + final_alias = await self._unique_alias(mac_address, final_alias) + await self.repository.insert_address_book( + mac_address=mac_address, + target_mac=target_mac, + alias=final_alias, + has_permission=has_permission, + actor=actor, + now=now, + ) + await self.session.commit() + else: + if alias is not None: + await self.repository.update_address_alias( + mac_address, + target_mac, + await self._unique_alias(mac_address, alias), + now=now, + ) + await self.session.commit() + await self.refresh_address_book_cache() + if has_permission is not None: + await self.repository.update_address_permission( + mac_address, + target_mac, + has_permission, + now=now, + ) + await self.session.commit() + await self.refresh_address_book_cache() + + async def all_address_books(self) -> dict[str, dict[str, str]]: + cached = _mapping(await redis_get("device:address_book:all", self.redis)) + if cached is not None: + result: dict[str, dict[str, str]] = {} + for key, value in cached.items(): + nested = _mapping(value) + if nested is not None: + result[key] = {str(field): str(item) for field, item in nested.items()} + return result + return await self.refresh_address_book_cache() + + async def refresh_address_book_cache(self) -> dict[str, dict[str, str]]: + records = await self.repository.get_all_address_book() + result: dict[str, dict[str, str]] = {} + reverse: dict[str, str] = {} + for record in records: + mac_a = str(record["mac_address"]).lower() + mac_b = str(record["target_mac"]).lower() + alias = record.get("alias") + if alias not in (None, ""): + alias_string = str(alias) + result.setdefault(mac_a, {})[alias_string] = ( + f"{mac_b}|{'1' if bool(record.get('has_permission')) else '0'}" + ) + reverse[f"{mac_b}:{mac_a}"] = alias_string + for record in records: + mac_a = str(record["mac_address"]).lower() + mac_b = str(record["target_mac"]).lower() + reverse_alias = reverse.get(f"{mac_a}:{mac_b}") + if isinstance(reverse_alias, str) and reverse_alias: + result.setdefault(mac_b, {})[mac_a] = reverse_alias + await redis_set("device:address_book:all", result, client=self.redis) + return result + + async def check_ota( + self, + *, + device_id: str, + client_id: str, + report: DeviceReportRequest, + request_url: str, + client_ip: str, + defer_connection_update: Callable[[str, str | None, str | None], None] | None = None, + ) -> dict[str, Any]: + now = datetime.now(tz=ZoneInfo(get_settings().timezone)) + utc_offset = now.utcoffset() + response: dict[str, Any] = { + "server_time": { + "timestamp": int(now.timestamp() * 1000), + "timeZone": get_settings().timezone, + "timezone_offset": int((utc_offset.total_seconds() if utc_offset is not None else 0) / 60), + }, + "activation": None, + "error": None, + "firmware": None, + "websocket": None, + "mqtt": None, + } + device = await self.repository.get_device_by_mac(device_id) + if device is None: + if report.application is None: + raise RuntimeError("application is required") + response["firmware"] = { + "version": report.application.version, + "url": INVALID_FIRMWARE_URL, + } + elif device.get("auto_update") is None: + raise RuntimeError("auto_update is null") + elif int(device["auto_update"]) != 0: + ota_type = report.board.type if report.board is not None else None + current_version = report.application.version if report.application is not None else None + response["firmware"] = await self._firmware_info(ota_type, current_version, request_url) + + websocket_url = await self.params.get_value("server.websocket", from_cache=True) + auth_enabled = await self.params.get_value("server.auth.enabled", from_cache=True) + websocket_token = "" + if (auth_enabled or "").lower() == "true": + try: + websocket_token = await self._websocket_token(client_id, device_id) + except Exception: + logger.exception("WebSocket token generation failed") + if is_blank(websocket_url) or websocket_url == "null": + selected_websocket = "ws://xiaozhi.server.com:8000/xiaozhi/v1/" + else: + websocket_urls = _java_semicolon_split(websocket_url or "") + selected_websocket = ( + random.choice(websocket_urls) # noqa: S311 + if websocket_urls + else "ws://xiaozhi.server.com:8000/xiaozhi/v1/" + ) + response["websocket"] = {"url": selected_websocket, "token": websocket_token} + + mqtt_endpoint = await self.params.get_value("server.mqtt_gateway", from_cache=True) + if mqtt_endpoint not in (None, "", "null"): + try: + group_id = str(device.get("board") or "GID_default") if device is not None else "GID_default" + mqtt = await self._mqtt_config(device_id, group_id, client_ip) + if mqtt is not None: + mqtt["endpoint"] = mqtt_endpoint + response["mqtt"] = mqtt + except Exception: + logger.exception("MQTT credential generation failed") + + if device is None: + response["activation"] = await self._activation(device_id, report) + else: + app_version = report.application.version if report.application is not None else None + agent_id = device.get("agent_id") + normalized_agent_id = None if agent_id is None else str(agent_id) + if defer_connection_update is not None: + defer_connection_update(str(device["id"]), normalized_agent_id, app_version) + else: + try: + await self._persist_connection_update( + str(device["id"]), + normalized_agent_id, + app_version, + ) + except Exception: + logger.exception("Asynchronous device connection update failed") + return cast(dict[str, Any], self._drop_none(response)) + + async def _persist_connection_update( + self, + device_id: str, + agent_id: str | None, + app_version: str | None, + ) -> None: + connection_time = shanghai_now_naive() + try: + await self.repository.update_connection(device_id, app_version=app_version, now=connection_time) + await self.session.commit() + except Exception: + await self.session.rollback() + raise + if not is_blank(agent_id): + await redis_set(f"agent:device:lastConnected:{agent_id}", connection_time, client=self.redis) + + @staticmethod + async def persist_connection_update_background( + device_id: str, + agent_id: str | None, + app_version: str | None, + ) -> None: + try: + async with get_session_factory()() as session: + await DeviceService(session)._persist_connection_update(device_id, agent_id, app_version) + except Exception: + logger.exception("Asynchronous device connection update failed") + + async def ota_health_text(self) -> str: + mqtt_gateway = await self.params.get_value("server.mqtt_gateway", from_cache=False) + if is_blank(mqtt_gateway): + return "OTA接口不正常,缺少mqtt_gateway地址,请登录智控台,在参数管理找到【server.mqtt_gateway】配置" + websocket = await self.params.get_value("server.websocket", from_cache=True) + if is_blank(websocket) or websocket == "null": + return "OTA接口不正常,缺少websocket地址,请登录智控台,在参数管理找到【server.websocket】配置" + ota_url = await self.params.get_value("server.ota", from_cache=True) + if is_blank(ota_url) or ota_url == "null": + return "OTA接口不正常,缺少ota地址,请登录智控台,在参数管理找到【server.ota】配置" + return f"OTA接口运行正常,websocket集群数量:{len(_java_semicolon_split(websocket or ''))}" + + async def ota_page(self, query: Mapping[str, Any]) -> dict[str, Any]: + page = self._positive_int(query.get("page"), 1) + limit = self._positive_int(query.get("limit"), 10) + requested = query.get("orderField") + requested_fields = [requested] if isinstance(requested, str) else list(requested or []) + fields = [OTA_ORDER_COLUMNS[field] for field in requested_fields if field in OTA_ORDER_COLUMNS] + if not fields: + fields = ["update_date"] + ascending = str(query.get("order") or "").lower() == "asc" if requested_fields else True + firmware_name = query.get("firmwareName") + name = str(firmware_name) if firmware_name is not None else None + rows = await self.repository.list_ota( + page=page, + limit=limit, + firmware_name=name, + order_fields=fields, + ascending=ascending, + ) + rows = [self._ota_response_record(row) for row in rows] + return {"total": await self.repository.count_ota(name), "list": rows} + + async def get_ota_record(self, ota_id: str) -> dict[str, Any] | None: + row = await self.repository.get_ota(ota_id) + return None if row is None else self._ota_response_record(row) + + async def save_ota(self, record: OtaRecord, user: AuthUser) -> None: + values = record.model_dump(by_alias=False) + existing = await self.repository.get_first_ota_by_type(record.type or "") + now = shanghai_now_naive() + if existing is not None: + values["updater"] = record.updater if record.updater is not None else user.id + values["update_date"] = record.update_date if record.update_date is not None else now + await self.repository.update_ota(str(existing["id"]), values) + else: + values["id"] = record.id or uuid.uuid4().hex + values["creator"] = record.creator if record.creator is not None else user.id + values["updater"] = record.updater if record.updater is not None else user.id + values["create_date"] = record.create_date if record.create_date is not None else now + values["update_date"] = record.update_date if record.update_date is not None else now + await self.repository.insert_ota(values) + await self.session.commit() + + async def update_ota(self, ota_id: str, record: OtaRecord, user: AuthUser) -> None: + if await self.repository.count_duplicate_ota( + ota_id=ota_id, + ota_type=record.type, + version=record.version, + ): + raise RuntimeError("已存在相同类型和版本的固件,请修改后重试") + values = record.model_dump(by_alias=False) + values["updater"] = record.updater if record.updater is not None else user.id + values["update_date"] = shanghai_now_naive() + await self.repository.update_ota(ota_id, values) + await self.session.commit() + + async def delete_ota(self, ids: Sequence[str]) -> None: + await self.repository.delete_ota(ids) + await self.session.commit() + + async def create_ota_download_id(self, ota_id: str) -> str: + value = str(uuid.uuid4()) + await redis_set(f"ota:id:{value}", ota_id, client=self.redis) + return value + + async def resolve_ota_download(self, download_id: str) -> tuple[Path, str] | None: + id_key = f"ota:id:{download_id}" + ota_value = await redis_get(id_key, self.redis) + if is_blank(None if ota_value is None else str(ota_value)): + return None + count_key = f"ota:download:count:{download_id}" + count_value = await redis_get(count_key, self.redis) + count = int(count_value or 0) + if count >= 3: + await redis_delete(count_key, id_key, client=self.redis) + return None + await redis_set(count_key, count + 1, client=self.redis) + + ota_id = str(ota_value) + if ota_id.startswith("file:"): + firmware_path = ota_id[5:] + ota_type = "assets" + version = "1.0.0" + else: + record = await self.repository.get_ota(ota_id) + firmware_value = None if record is None else record.get("firmware_path") + if record is None or is_blank(None if firmware_value is None else str(firmware_value)): + return None + firmware_path = str(record["firmware_path"]) + ota_type = str(record.get("type")) + version = str(record.get("version")) + raw_path = Path(firmware_path) + candidates = [raw_path] if raw_path.is_absolute() else [Path.cwd() / raw_path] + if not raw_path.is_absolute() and raw_path.parts and raw_path.parts[0] == "uploadfile": + candidates.insert(0, get_settings().upload_dir.joinpath(*raw_path.parts[1:])) + candidates.append(Path.cwd() / "firmware" / raw_path.name) + resolved = next((candidate for candidate in candidates if candidate.is_file()), None) + if resolved is None: + return None + original_name = f"{ota_type}_{version}" + dot_index = firmware_path.rfind(".") + if dot_index >= 0: + original_name += firmware_path[dot_index:] + safe_name = re.sub(r"[^a-zA-Z0-9._-]", "_", original_name) + return resolved, safe_name + + async def save_firmware_file(self, *, filename: str | None, content: bytes) -> str: + if not content: + raise ValueError("上传文件不能为空") + if filename is None: + raise ValueError("文件名不能为空") + dot_index = filename.rfind(".") + if dot_index < 0: + raise RuntimeError("文件名缺少扩展名") + extension = filename[dot_index:].lower() + if extension not in {".bin", ".apk"}: + raise ValueError("只允许上传.bin和.apk格式的文件") + digest = hashlib.md5(content, usedforsecurity=False).hexdigest() + directory = get_settings().upload_dir + directory.mkdir(parents=True, exist_ok=True) + filename_on_disk = f"{digest}{extension}" + physical_path = directory / filename_on_disk + if not physical_path.exists(): + with physical_path.open("xb") as stream: + stream.write(content) + # Keep Java's database/API value stable even when the physical upload + # volume is mounted elsewhere (for example /data/uploads in Docker). + return str(Path("uploadfile") / filename_on_disk) + + async def save_assets_file(self, *, filename: str | None, content: bytes, user: AuthUser) -> str: + ota_url = await self.params.get_value("server.ota", from_cache=True) + if is_blank(ota_url) or ota_url == "null": + raise AppError(10102) + if len(content) > 20 * 1024 * 1024: + raise AppError(10142) + if not user.is_super_admin: + count_key = f"ota:upload:count:{user.id}" + current = int(await redis_get(count_key, self.redis) or 0) + if current >= 50: + raise AppError(10195) + await redis_increment(count_key, client=self.redis) + path = await self.save_firmware_file(filename=filename, content=content) + download_id = await self.create_ota_download_id(f"file:{path}") + return (ota_url or "").replace("/ota/", "/otaMag/download/") + download_id + + async def _gateway_device(self, device_id: str, user: AuthUser) -> tuple[str, dict[str, Any]] | None: + gateway = await self.params.get_value("server.mqtt_manager_api", from_cache=True) + if is_blank(gateway) or gateway == "null": + return None + device = await self.repository.get_device(device_id) + if device is None or int(device.get("user_id") or -1) != user.id: + return None + return gateway or "", device + + async def _post_gateway(self, url: str, body: Any, *, timeout_seconds: float | None = None) -> str: + key = await self.params.get_value("server.mqtt_signature_key", from_cache=False) + return await post_json( + url, + body, + key or "", + timeout_seconds=timeout_seconds or get_settings().external_request_timeout_seconds, + client=self.http_client, + ) + + async def _post_call(self, path: str, body: dict[str, Any], action: str) -> dict[str, Any]: + gateway = await self.params.get_value("server.mqtt_manager_api", from_cache=True) + key = await self.params.get_value("server.mqtt_signature_key", from_cache=True) + if is_blank(gateway) or gateway == "null" or is_blank(key) or (key or "").strip().lower() == "null": + return {"status": "error", "message": f"{action}失败,网关配置缺失"} + result: dict[str, Any] = {"status": "error"} + try: + text = await post_json( + f"http://{gateway}{path}", + body, + key or "", + timeout_seconds=5.0, + client=self.http_client, + ) + if text.strip(): + payload = json.loads(text) + if isinstance(payload, dict): + result["status"] = payload.get("status") + result["message"] = payload.get("message") + return result + except Exception: + return {"status": "error", "message": f"{action}失败,请稍后再试"} + + async def _firmware_info( + self, + ota_type: str | None, + current_version: str | None, + request_url: str, + ) -> dict[str, Any] | None: + if is_blank(ota_type): + return None + selected_version = current_version if not is_blank(current_version) else "0.0.0" + ota = await self.repository.get_latest_ota(ota_type or "") + download_url: str | None = None + if ota is not None and self._compare_versions(ota.get("version"), selected_version) > 0: + ota_url = await self.params.get_value("server.ota", from_cache=True) + if is_blank(ota_url) or ota_url == "null": + ota_url = request_url + download_id = await self.create_ota_download_id(str(ota["id"])) + download_url = (ota_url or "").replace("/ota/", "/otaMag/download/") + download_id + return { + "version": selected_version if ota is None else ota.get("version"), + "url": download_url or INVALID_FIRMWARE_URL, + } + + async def _activation(self, device_id: str, report: DeviceReportRequest) -> dict[str, Any]: + safe_device_id = device_id.replace(":", "_").lower() + data_key = f"ota:activation:data:{safe_device_id}" + cached = _mapping(await redis_get(data_key, self.redis)) + code = str(cached.get("activation_code")) if cached and cached.get("activation_code") is not None else None + frontend = await self.params.get_value("server.fronted_url", from_cache=True) + if code is None or not code.strip(): + code = f"{secrets.randbelow(1_000_000):06d}" + board = ( + report.board.type + if report.board is not None and report.board.type is not None + else (report.chip_model_name or "unknown") + ) + app_version = report.application.version if report.application is not None else None + await redis_set( + data_key, + { + "id": device_id, + "mac_address": device_id, + "board": board, + "app_version": app_version, + "deviceId": device_id, + "activation_code": code, + }, + client=self.redis, + ) + await redis_set(f"ota:activation:code:{code}", device_id, client=self.redis) + return { + "code": code, + "message": f"{frontend if frontend is not None else 'null'}\n{code}", + "challenge": device_id, + } + + async def _websocket_token(self, client_id: str, username: str) -> str: + secret = await self.params.get_value("server.secret", from_cache=False) + if is_blank(secret): + raise RuntimeError("WebSocket认证密钥未配置(server.secret)") + timestamp = int(datetime.now().timestamp()) + message = f"{client_id}|{username}|{timestamp}".encode() + signature = hmac.new((secret or "").encode(), message, hashlib.sha256).digest() + encoded = base64.urlsafe_b64encode(signature).decode().rstrip("=") + return f"{encoded}.{timestamp}" + + async def _mqtt_config(self, mac_address: str, group_id: str, client_ip: str) -> dict[str, Any] | None: + key = await self.params.get_value("server.mqtt_signature_key", from_cache=True) + if is_blank(key): + return None + client_id = self._mqtt_client_id(group_id, mac_address) + user_data = json.dumps({"ip": client_ip}, ensure_ascii=False, separators=(",", ":")) + username = base64.b64encode(user_data.encode()).decode() + password = base64.b64encode( + hmac.new((key or "").encode(), f"{client_id}|{username}".encode(), hashlib.sha256).digest() + ).decode() + safe_mac = mac_address.replace(":", "_") + return { + "client_id": client_id, + "username": username, + "password": password, + "publish_topic": "device-server", + "subscribe_topic": f"devices/p2p/{safe_mac}", + } + + @staticmethod + def _mqtt_client_id(group_id: str, mac_address: str) -> str: + safe_group = group_id.replace(":", "_") + safe_mac = mac_address.replace(":", "_") + return f"{safe_group}@@@{safe_mac}@@@{safe_mac}" + + @staticmethod + def _compare_versions(first: Any, second: Any) -> int: + if first is None or second is None: + return 0 + first = str(first) + second = str(second) + first_parts = first.split(".") + second_parts = second.split(".") + for index in range(max(len(first_parts), len(second_parts))): + first_value = int(first_parts[index]) if index < len(first_parts) else 0 + second_value = int(second_parts[index]) if index < len(second_parts) else 0 + if first_value != second_value: + return 1 if first_value > second_value else -1 + return 0 + + async def _unique_alias(self, mac_address: str, alias: str | None) -> str | None: + existing = await self.repository.get_aliases(mac_address) + if alias not in existing: + return alias + suffix = 1 + while f"{alias}{suffix}" in existing: + suffix += 1 + return f"{alias}{suffix}" + + @staticmethod + def _mac_device_name(mac: str) -> str: + return mac if len(mac) < 2 else f"尾号为{mac[-2:]}的设备" + + @staticmethod + def _positive_int(value: Any, default: int) -> int: + if value is None: + return default + return int(str(value)) + + @staticmethod + def _drop_none(value: Any) -> Any: + if isinstance(value, dict): + return {key: DeviceService._drop_none(item) for key, item in value.items() if item is not None} + if isinstance(value, list): + return [DeviceService._drop_none(item) for item in value] + return value + + @staticmethod + def _user_device_view(row: Mapping[str, Any]) -> dict[str, Any]: + return { + "app_version": row.get("app_version"), + "bind_user_name": None, + "device_type": row.get("board"), + "board": row.get("board"), + "id": row.get("id"), + "mac_address": row.get("mac_address"), + "alias": row.get("alias"), + "ota_upgrade": None, + "recent_chat_time": None, + "last_connected_at_timestamp": DeviceService._timestamp(row.get("last_connected_at")), + "create_date_timestamp": DeviceService._timestamp(row.get("create_date")), + # UserShowDeviceListVO pins only this field to UTC. The companion + # epoch value still uses the configured Asia/Shanghai instant. + "create_date": DeviceService._utc_datetime(row.get("create_date")), + } + + @staticmethod + def _utc_datetime(value: Any) -> Any: + if not isinstance(value, datetime): + return value + localized = value.replace(tzinfo=ZoneInfo(get_settings().timezone)) if value.tzinfo is None else value + return localized.astimezone(timezone.utc).replace(tzinfo=None) + + @staticmethod + def _ota_response_record(row: Mapping[str, Any]) -> dict[str, Any]: + result = dict(row) + if result.get("size") is not None: + result["size"] = str(result["size"]) + return result + + @staticmethod + def _timestamp(value: Any) -> int | None: + if not isinstance(value, datetime): + return None + localized = value.replace(tzinfo=ZoneInfo(get_settings().timezone)) if value.tzinfo is None else value + return int(localized.timestamp() * 1000) diff --git a/main/manager-api-fastapi/app/services/java_validation.py b/main/manager-api-fastapi/app/services/java_validation.py new file mode 100644 index 00000000..6566c5ac --- /dev/null +++ b/main/manager-api-fastapi/app/services/java_validation.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + +from app.core.config import get_settings +from app.core.i18n import LANGUAGE_FILES, _load_properties, resolve_language + + +@lru_cache(maxsize=16) +def _validation_messages(language: str, directory: str) -> dict[str, str]: + root = Path(directory) + values = _load_properties(root / "validation.properties") + localized = LANGUAGE_FILES[language].replace("messages_", "validation_") + values.update(_load_properties(root / localized)) + return values + + +def validation_message(key: str, accept_language: str | None) -> str: + language = resolve_language(accept_language) + return _validation_messages(language, str(get_settings().i18n_dir)).get(key, key) diff --git a/main/manager-api-fastapi/app/services/knowledge.py b/main/manager-api-fastapi/app/services/knowledge.py new file mode 100644 index 00000000..16aa6d37 --- /dev/null +++ b/main/manager-api-fastapi/app/services/knowledge.py @@ -0,0 +1,723 @@ +from __future__ import annotations + +import json +from collections import defaultdict +from datetime import datetime +from typing import Any +from zoneinfo import ZoneInfo + +from fastapi import UploadFile + +from app.core.config import get_settings +from app.core.errors import AppError +from app.core.i18n import message_for +from app.core.redis import get_redis +from app.core.security import AuthUser, shanghai_now_naive +from app.core.serialization import preserve_java_map_keys +from app.integrations.ragflow import RAGFlowClient +from app.repositories.knowledge import KnowledgeRepository +from app.schemas.knowledge import KnowledgeBaseBody, RetrievalBody + + +def dataset_dto(row: dict[str, Any]) -> dict[str, Any]: + return { + "id": row.get("id"), + "datasetId": row.get("dataset_id"), + "ragModelId": row.get("rag_model_id"), + "name": row.get("name"), + "avatar": row.get("avatar"), + "description": row.get("description"), + "embeddingModel": row.get("embedding_model"), + "permission": row.get("permission"), + "chunkMethod": row.get("chunk_method"), + "parserConfig": row.get("parser_config"), + "chunkCount": None if row.get("chunk_count") is None else str(row["chunk_count"]), + "tokenNum": None if row.get("token_num") is None else str(row["token_num"]), + "status": row.get("status"), + "creator": row.get("creator"), + "createdAt": row.get("created_at"), + "updater": row.get("updater"), + "updatedAt": row.get("updated_at"), + # KnowledgeBaseEntity.documentCount is Long while KnowledgeBaseDTO uses + # Integer. Spring BeanUtils does not coerce that property, so local DTO + # conversion leaves it null; list enrichment fills it from RAGFlow. + "documentCount": None, + "errorMessage": row.get("error_message"), + } + + +def document_dto(row: dict[str, Any]) -> dict[str, Any]: + return { + "id": row.get("document_id"), + "documentId": row.get("document_id"), + "datasetId": row.get("dataset_id"), + "name": row.get("name"), + # RAGFlowAdapter.mapToKnowledgeFilesDTO does not populate these two + # fields for the immediate upload response. + "fileType": None, + "fileSize": row.get("size"), + "filePath": None, + "progress": row.get("progress"), + "thumbnail": row.get("thumbnail"), + "processDuration": row.get("process_duration"), + "sourceType": row.get("source_type"), + "metaFields": _json_object(row.get("meta_fields")), + "chunkMethod": row.get("chunk_method"), + "parserConfig": _json_object(row.get("parser_config")), + "status": row.get("status"), + "run": row.get("run"), + "creator": row.get("creator"), + "createdAt": row.get("created_at"), + "updater": None, + "updatedAt": row.get("updated_at"), + "chunkCount": row.get("chunk_count"), + "tokenCount": row.get("token_count"), + "error": row.get("error"), + "parseStatusCode": _parse_status(row.get("run")), + } + + +def remote_document_dto(row: dict[str, Any], dataset_id: str) -> dict[str, Any]: + run = row.get("run") + return { + "id": row.get("id"), + "documentId": row.get("id"), + "datasetId": row.get("dataset_id") or dataset_id, + "name": row.get("name"), + "fileType": row.get("type"), + "fileSize": row.get("size"), + "filePath": None, + "progress": row.get("progress"), + "thumbnail": row.get("thumbnail"), + "processDuration": row.get("process_duration"), + "sourceType": row.get("source_type"), + "metaFields": row.get("meta_fields"), + "chunkMethod": row.get("chunk_method"), + "parserConfig": row.get("parser_config"), + "status": _remote_status(row.get("status")), + "run": run, + "creator": None, + "createdAt": _millis(row.get("create_time")), + "updater": None, + "updatedAt": _millis(row.get("update_time")), + "chunkCount": row.get("chunk_count") or 0, + "tokenCount": row.get("token_count"), + "error": row.get("progress_msg"), + "parseStatusCode": _parse_status(run), + } + + +def _parse_status(run: Any) -> int: + return {"RUNNING": 1, "CANCEL": 2, "DONE": 3, "FAIL": 4}.get(str(run or "").upper(), 0) + + +def _json_object(value: Any) -> dict[str, Any] | None: + if value is None: + return None + if isinstance(value, dict): + return dict(value) + try: + parsed = json.loads(value.decode() if isinstance(value, bytes) else str(value)) + return dict(parsed) if isinstance(parsed, dict) else None + except (ValueError, TypeError): + return None + + +def _millis(value: Any) -> Any: + try: + if value is None: + return None + timezone = ZoneInfo(get_settings().timezone) + return datetime.fromtimestamp(float(value) / 1000, timezone).replace(tzinfo=None) + except (TypeError, ValueError, OSError): + return None + + +def _is_blank(value: str | None) -> bool: + return value is None or not value.strip() + + +def _remote_status(value: Any) -> str: + if value is None or (isinstance(value, str) and not value.strip()): + return "1" + return str(value) + + +class KnowledgeBaseService: + def __init__(self, repository: KnowledgeRepository): + self.repository = repository + + async def get_owned(self, identifier: str, user: AuthUser) -> dict[str, Any]: + if not identifier.strip(): + raise AppError(10003) + row = await self.repository.get_dataset(identifier) + if row is None: + raise AppError(10163) + if row.get("creator") is None or int(row["creator"]) != user.id: + raise AppError(10169) + return row + + async def page( + self, + user: AuthUser, + name: str | None, + page: int, + page_size: int, + language: str | None = None, + ) -> dict[str, Any]: + rows, total = await self.repository.dataset_page( + user.id, name, (max(page, 1) - 1) * page_size, page_size + ) + results: list[dict[str, Any]] = [] + changed = False + for row in rows: + dto = dataset_dto(row) + if row.get("dataset_id") and row.get("rag_model_id"): + try: + client = await self._client(str(row["rag_model_id"])) + remote = await client.dataset_info(str(row["dataset_id"])) + if remote is None: + await self.repository.execute( + "DELETE FROM ai_rag_knowledge_document WHERE dataset_id=:dataset_id", + {"dataset_id": row["dataset_id"]}, + ) + await self.repository.delete_dataset_local(row) + await _delete_cache_ignoring_errors(f"knowledge:base:{row['id']}") + changed = True + continue + remote_name = remote.get("name") + local_name = ( + str(remote_name).split("_", 1)[1] + if remote_name and "_" in str(remote_name) + else remote_name + ) + updates: dict[str, Any] = {} + if local_name and local_name != row.get("name"): + updates["name"] = local_name + dto["name"] = local_name + if remote.get("description") != row.get("description"): + updates["description"] = remote.get("description") + dto["description"] = remote.get("description") + if updates: + await self.repository.execute( + "UPDATE ai_rag_dataset SET name=COALESCE(:name,name),description=:description WHERE id=:id", + { + "name": updates.get("name"), + "description": updates.get("description", row.get("description")), + "id": row["id"], + }, + ) + changed = True + if remote.get("document_count") is not None: + dto["documentCount"] = int(remote["document_count"]) + except Exception as exc: + dto["documentCount"] = 0 + dto["errorMessage"] = ( + message_for(exc.code, language, *exc.params) + if isinstance(exc, AppError) + else str(exc) + ) + results.append(dto) + if changed: + await self.repository.session.commit() + return {"total": total, "list": results} + + async def create(self, body: KnowledgeBaseBody, user: AuthUser) -> dict[str, Any]: + if not _is_blank(body.name) and await self.repository.duplicate_dataset_name(user.id, str(body.name)): + raise AppError(10170) + rag_model_id = body.rag_model_id + if _is_blank(rag_model_id): + models = await self.repository.rag_models() + if not models: + raise AppError(10164, params=("未指定且无可用默认 RAG 模型",)) + rag_model_id = str(models[0]["id"]) + client = await self._client(str(rag_model_id)) + create_body = { + "name": f"{user.username}_{'null' if body.name is None else body.name}", + "avatar": body.avatar, + "description": body.description, + "embedding_model": body.embedding_model, + "permission": body.permission, + "chunk_method": body.chunk_method, + # KnowledgeBaseDTO.parserConfig is a String, while CreateReq uses + # ParserConfig. BeanUtils skips the incompatible property. + "parser_config": None, + } + remote = await client.create_dataset(create_body) + dataset_id = str(remote["id"]) + now = shanghai_now_naive() + created_at = body.created_at or now + updated_at = body.updated_at or now + values = { + "id": dataset_id, + "dataset_id": dataset_id, + "rag_model_id": rag_model_id, + "tenant_id": remote.get("tenant_id"), + "name": body.name, + "avatar": remote.get("avatar") if _is_blank(body.avatar) else body.avatar, + "description": body.description, + "embedding_model": remote.get("embedding_model"), + "permission": remote.get("permission"), + "chunk_method": remote.get("chunk_method"), + "parser_config": json.dumps( + remote.get("parser_config"), ensure_ascii=False, separators=(",", ":") + ) + if remote.get("parser_config") is not None + else None, + "chunk_count": remote.get("chunk_count") or 0, + "document_count": remote.get("document_count") or 0, + "token_num": remote.get("token_num") or 0, + "status": 1, + "creator": user.id, + "updater": user.id, + "created_at": created_at, + "updated_at": updated_at, + } + try: + await self.repository.insert_dataset(values) + await self.repository.session.commit() + except Exception as exc: + await self.repository.session.rollback() + try: + await client.delete_datasets([dataset_id]) + except AppError: + pass + if isinstance(exc, AppError): + raise + raise AppError(10167, params=(f"创建知识库失败: {exc}",)) from exc + return dataset_dto(values) + + async def update( + self, identifier: str, body: KnowledgeBaseBody, user: AuthUser + ) -> dict[str, Any]: + existing = await self.get_owned(identifier, user) + if not _is_blank(body.name) and await self.repository.duplicate_dataset_name( + user.id, str(body.name), str(existing["id"]) + ): + raise AppError(10170) + if not _is_blank(identifier) and await self.repository.dataset_id_conflict( + identifier, str(existing["id"]) + ): + raise AppError(10002) + rag_model_id = body.rag_model_id + effective_permission = body.permission + effective_chunk_method = body.chunk_method + if existing.get("dataset_id") and not _is_blank(rag_model_id): + if _is_blank(effective_permission): + effective_permission = existing.get("permission") + if _is_blank(effective_chunk_method): + effective_chunk_method = existing.get("chunk_method") + client = await self._client(str(rag_model_id)) + remote_body = { + "name": f"{user.username}_{body.name}" if not _is_blank(body.name) else None, + "avatar": body.avatar, + "description": body.description, + "embedding_model": body.embedding_model, + "permission": effective_permission, + "chunk_method": effective_chunk_method, + "parser_config": _json_object(body.parser_config), + } + await client.update_dataset(str(existing["dataset_id"]), remote_body) + now = shanghai_now_naive() + updater = body.updater if body.updater is not None else user.id + updated_at = body.updated_at or now + values = { + "id": existing["id"], + # The controller injects the literal path value into datasetId, + # even when a legacy row was found through its local primary key. + "dataset_id": identifier, + "rag_model_id": rag_model_id, + "name": body.name, + "avatar": body.avatar, + "description": body.description, + "embedding_model": body.embedding_model, + "permission": effective_permission, + "chunk_method": effective_chunk_method, + "parser_config": body.parser_config, + "chunk_count": body.chunk_count, + "token_num": body.token_num, + "status": body.status, + "creator": body.creator, + "created_at": body.created_at, + "updater": updater, + "updated_at": updated_at, + } + try: + await self.repository.update_dataset(values) + # Java performs cache eviction inside the database transaction; + # an eviction failure therefore rolls this update back. + await get_redis().delete(f"knowledge:base:{existing['id']}") + await self.repository.session.commit() + except Exception: + await self.repository.session.rollback() + raise + # BeanUtils copies request nulls onto the in-memory entity before + # MyBatis' NOT_NULL update strategy preserves the stored columns. The + # Java response is built from that in-memory entity, so its null fields + # intentionally differ from a subsequent GET of the row. + return dataset_dto(values) + + async def delete(self, identifier: str, user: AuthUser, language: str | None = None) -> None: + row = await self.get_owned(identifier, user) + documents = await self.repository.all_documents(str(row["dataset_id"])) + if documents: + # Java's document orchestration necessarily resolves the adapter + # when child records exist. + client = await self._client(str(row.get("rag_model_id") or "")) + ids = [str(item["document_id"]) for item in documents] + if any(item.get("run") == "RUNNING" for item in documents): + raise AppError(10199) + try: + await client.delete_documents(str(row["dataset_id"]), ids) + except Exception as exc: + raise _document_delete_error(exc, language) from exc + await self.repository.delete_document_shadows(str(row["dataset_id"]), ids) + await self.repository.update_stats( + str(row["dataset_id"]), + -len(ids), + -sum(int(item.get("chunk_count") or 0) for item in documents), + -sum(int(item.get("token_count") or 0) for item in documents), + ) + # deleteDocuments is NOT_SUPPORTED in Java and its shadow cleanup + # commits before the outer dataset transaction continues. + await self.repository.session.commit() + await _delete_cache_ignoring_errors(f"knowledge:base:{row['dataset_id']}") + if not _is_blank(row.get("rag_model_id")) and not _is_blank(row.get("dataset_id")): + client = await self._client(str(row["rag_model_id"])) + await client.delete_datasets([str(row["dataset_id"])]) + await self.repository.delete_dataset_local(row) + try: + await get_redis().delete(f"knowledge:base:{row['id']}") + await self.repository.session.commit() + except Exception: + await self.repository.session.rollback() + raise + + async def batch_delete( + self, identifiers: list[str], user: AuthUser, language: str | None = None + ) -> None: + rows = await self.repository.datasets_by_ids(identifiers) + for row in rows: + if row.get("creator") is None or int(row["creator"]) != user.id: + raise AppError(10169) + # Preserve Java's sequential external calls and stop-on-first-error semantics. + for row in rows: + await self.delete(str(row["dataset_id"]), user, language) + + async def rag_models(self) -> list[dict[str, Any]]: + rows = await self.repository.rag_models() + result: list[dict[str, Any]] = [] + for row in rows: + result.append( + { + "id": row.get("id"), + "modelType": None, + "modelCode": None, + "modelName": row.get("model_name"), + "isDefault": None, + "isEnabled": None, + # ModelConfigEntity.configJson is a JSONObject. Jackson + # preserves its dynamic snake_case keys instead of applying + # the DTO property naming strategy recursively. + "configJson": preserve_java_map_keys(_json_object(row.get("config_json"))), + "docLink": None, + "remark": None, + "sort": None, + "updater": None, + "updateDate": None, + "creator": None, + "createDate": None, + } + ) + return result + + async def _client(self, model_id: str) -> RAGFlowClient: + config = await self.repository.rag_config(model_id) + adapter_type = config.get("type") + if adapter_type != "ragflow": + raise AppError(10184, params=(f"适配器类型未注册: {adapter_type}",)) + try: + return RAGFlowClient(config) + except AppError as exc: + # KnowledgeBaseAdapterFactory wraps adapter initialization and + # validateConfig failures as RAG_ADAPTER_CREATION_FAILED. + if exc.code in {10171, 10172, 10173, 10174}: + raise AppError(10186) from exc + raise + + +class KnowledgeDocumentService: + def __init__(self, repository: KnowledgeRepository): + self.repository = repository + self.datasets = KnowledgeBaseService(repository) + + async def page( + self, + dataset_id: str, + user: AuthUser, + *, + name: str | None, + status: str | None, + page: int, + page_size: int, + ) -> dict[str, Any]: + await self.datasets.get_owned(dataset_id, user) + try: + await self.reconcile(dataset_id, creator=user.id) + except Exception: + await self.repository.session.rollback() + rows, total = await self.repository.documents_page( + dataset_id, + name=name, + status=status, + offset=(max(page, 1) - 1) * page_size, + limit=page_size, + ) + return {"total": total, "list": [document_dto(row) for row in rows]} + + async def upload( + self, + dataset_id: str, + user: AuthUser, + file: UploadFile, + *, + name: str | None, + meta_fields: dict[str, Any] | None, + chunk_method: str | None, + parser_config: dict[str, Any] | None, + ) -> dict[str, Any]: + await self.datasets.get_owned(dataset_id, user) + content = await file.read() + if not dataset_id.strip() or not content: + raise AppError(10003) + file_name = file.filename if _is_blank(name) else name + if _is_blank(file_name): + raise AppError(10179) + assert file_name is not None + client = await self._client_for_dataset(dataset_id) + remote = await client.upload_document( + dataset_id, + file, + content, + name=file_name, + meta_fields=meta_fields, + chunk_method=chunk_method, + parser_config=parser_config, + ) + if not remote.get("id"): + raise AppError(10167, params=("远程上传成功但未返回有效 DocumentID",)) + remote.setdefault("dataset_id", dataset_id) + shadow = dict(remote) + if _is_blank(str(shadow.get("name")) if shadow.get("name") is not None else None): + shadow["name"] = file_name + # Java stores the original controller values in the shadow row, even + # when invalid chunk methods were omitted from the RAGFlow request. + shadow["chunk_method"] = chunk_method + shadow["parser_config"] = parser_config + inserted = await self.repository.upsert_document(dataset_id, shadow, creator=user.id) + if inserted: + await self.repository.update_stats(dataset_id, 1, 0, 0) + await self.repository.session.commit() + return remote_document_dto(remote, dataset_id) + + async def delete( + self, + dataset_id: str, + ids: list[str] | None, + user: AuthUser, + language: str | None = None, + ) -> None: + await self.datasets.get_owned(dataset_id, user) + if not ids: + raise AppError(10178) + rows = await self.repository.documents_by_remote_ids(dataset_id, ids) + if len(rows) != len(ids): + raise AppError(10169) + if any(row.get("run") == "RUNNING" for row in rows): + raise AppError(10199) + chunks = sum(int(row.get("chunk_count") or 0) for row in rows) + tokens = sum(int(row.get("token_count") or 0) for row in rows) + client = await self._client_for_dataset(dataset_id) + try: + await client.delete_documents(dataset_id, ids) + except Exception as exc: + raise _document_delete_error(exc, language) from exc + deleted = await self.repository.delete_document_shadows(dataset_id, ids) + if deleted: + await self.repository.update_stats(dataset_id, -len(ids), -chunks, -tokens) + await self.repository.session.commit() + await _delete_cache_ignoring_errors(f"knowledge:base:{dataset_id}") + + async def parse(self, dataset_id: str, ids: list[str], user: AuthUser) -> bool: + await self.datasets.get_owned(dataset_id, user) + if not ids: + raise AppError(10178) + client = await self._client_for_dataset(dataset_id) + await client.parse_documents(dataset_id, ids) + await self.repository.mark_documents_running(dataset_id, ids, shanghai_now_naive()) + await self.repository.session.commit() + return True + + async def chunks( + self, + dataset_id: str, + document_id: str, + user: AuthUser, + *, + page: int, + page_size: int, + keywords: str | None, + chunk_id: str | None, + ) -> dict[str, Any]: + await self.datasets.get_owned(dataset_id, user) + client = await self._client_for_dataset(dataset_id) + return await client.chunks( + dataset_id, + document_id, + {"page": page, "page_size": page_size, "keywords": keywords, "id": chunk_id}, + ) + + async def retrieval(self, dataset_id: str, body: RetrievalBody, user: AuthUser) -> dict[str, Any]: + await self.datasets.get_owned(dataset_id, user) + dataset_ids = body.dataset_ids or [dataset_id] + if not dataset_ids: + raise AppError(500, "未指定召回测试的知识库") + page = body.page if body.page is not None and body.page >= 1 else 1 + page_size = body.page_size if body.page_size is not None and body.page_size >= 1 else 100 + top_k = body.top_k if body.top_k is None or body.top_k >= 1 else 1024 + threshold = body.similarity_threshold + if threshold is not None: + threshold = 0.2 if threshold < 0 else min(threshold, 1.0) + payload: dict[str, Any] = { + "dataset_ids": dataset_ids, + "document_ids": body.document_ids, + "question": body.question, + "page": page, + "page_size": page_size, + "similarity_threshold": threshold, + "vector_similarity_weight": body.vector_similarity_weight, + "top_k": top_k, + "rerank_id": body.rerank_id, + "highlight": body.highlight, + "keyword": body.keyword, + "cross_languages": body.cross_languages, + "metadata_condition": body.metadata_condition, + } + payload = {key: value for key, value in payload.items() if value is not None} + client = await self._client_for_dataset(dataset_ids[0]) + return await client.retrieval(payload) + + async def reconcile(self, dataset_id: str, *, creator: int | None = None) -> int: + client = await self._client_for_dataset(dataset_id) + remote: list[dict[str, Any]] = [] + page, total = 1, 2**63 - 1 + while (page - 1) * 100 < total: + rows, total = await client.documents(dataset_id, page=page, page_size=100) + if not rows: + break + remote.extend(rows) + page += 1 + local = await self.repository.all_documents(dataset_id) + remote_map = {str(item.get("id")): item for item in remote if item.get("id")} + local_map = {str(item["document_id"]): item for item in local} + new_count = 0 + for document_id, item in remote_map.items(): + prior = local_map.get(document_id) + inserted = await self.repository.upsert_document(dataset_id, item, creator=creator) + if inserted: + new_count += 1 + await self.repository.update_stats( + dataset_id, 1, int(item.get("chunk_count") or 0), int(item.get("token_count") or 0) + ) + elif prior: + await self.repository.update_stats( + dataset_id, + 0, + int(item.get("chunk_count") or 0) - int(prior.get("chunk_count") or 0), + int(item.get("token_count") or 0) - int(prior.get("token_count") or 0), + ) + deleted_ids = [identifier for identifier in local_map if identifier not in remote_map] + if deleted_ids: + deleted_rows = [local_map[identifier] for identifier in deleted_ids] + await self.repository.delete_document_shadows(dataset_id, deleted_ids) + await self.repository.update_stats( + dataset_id, + -len(deleted_ids), + -sum(int(row.get("chunk_count") or 0) for row in deleted_rows), + -sum(int(row.get("token_count") or 0) for row in deleted_rows), + ) + await self.repository.session.commit() + return new_count + + async def sync_running(self) -> int: + rows = await self.repository.running_documents() + grouped: defaultdict[str, list[dict[str, Any]]] = defaultdict(list) + for row in rows: + grouped[str(row["dataset_id"])].append(row) + updates = 0 + for dataset_id, documents in grouped.items(): + try: + client = await self._client_for_dataset(dataset_id) + except Exception: + await self.repository.session.rollback() + continue + for local in documents: + try: + remote, _ = await client.documents( + dataset_id, page=1, page_size=1, document_id=str(local["document_id"]) + ) + if not remote: + await self.repository.mark_document_remote_deleted( + str(local["document_id"]), shanghai_now_naive() + ) + await self.repository.session.commit() + updates += 1 + continue + remote_status = remote[0].get("status") + remote_run = remote[0].get("run") + status_changed = remote_status is not None and str(remote_status) != str(local.get("status")) + run_changed = remote_run is not None and str(remote_run) != str(local.get("run")) + is_processing = remote_run in {"RUNNING", "UNSTART"} + if not (status_changed or run_changed or is_processing): + await self.repository.session.commit() + continue + before_tokens = int(local.get("token_count") or 0) + await self.repository.sync_running_document( + dataset_id, + str(local["document_id"]), + remote[0], + shanghai_now_naive(), + ) + delta = int(remote[0].get("token_count") or 0) - before_tokens + if delta: + await self.repository.update_stats(dataset_id, 0, 0, delta) + await self.repository.session.commit() + updates += 1 + except Exception: + await self.repository.session.rollback() + continue + return updates + + async def _client_for_dataset(self, dataset_id: str) -> RAGFlowClient: + row = await self.repository.get_dataset(dataset_id) + if row is None or not row.get("rag_model_id"): + raise AppError(10164) + return await self.datasets._client(str(row["rag_model_id"])) + + +def _document_delete_error(exc: Exception, language: str | None) -> AppError: + """Match `new RenException(e.getMessage())` in the Java delete flow.""" + if isinstance(exc, AppError): + message = exc.message or message_for(exc.code, language, *exc.params) + else: + message = str(exc) + return AppError(500, message) + + +async def _delete_cache_ignoring_errors(key: str) -> None: + try: + await get_redis().delete(key) + except Exception: + # The Java document cleanup and remote-missing cleanup explicitly log + # and continue when Redis is unavailable. + return diff --git a/main/manager-api-fastapi/app/services/model.py b/main/manager-api-fastapi/app/services/model.py new file mode 100644 index 00000000..e8c7d5a8 --- /dev/null +++ b/main/manager-api-fastapi/app/services/model.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +import copy +import json +import uuid +from typing import Any + +from app.core.errors import AppError +from app.core.redis import get_redis +from app.core.security import AuthUser, shanghai_now_naive +from app.repositories.model import ModelRepository, parse_json_object +from app.schemas.model import ModelConfigBody, ModelProviderBody + +SENSITIVE_FIELDS = { + "api_key", + "personal_access_token", + "access_token", + "token", + "secret", + "access_key_secret", + "secret_key", +} + + +def _mask_middle(value: str) -> str: + if not value.strip() or len(value) == 1: + return value + if len(value) <= 8: + return value[:2] + "****" + value[-2:] + return value[:4] + "*" * (len(value) - 8) + value[-4:] + + +def mask_sensitive(value: Any) -> Any: + if not isinstance(value, dict): + return value + result: dict[str, Any] = {} + for key, item in value.items(): + if key.lower() in SENSITIVE_FIELDS and isinstance(item, str): + result[key] = _mask_middle(item) + elif isinstance(item, dict): + result[key] = mask_sensitive(item) + else: + result[key] = copy.deepcopy(item) + return result + + +def _merge_config(original: dict[str, Any], updated: dict[str, Any]) -> dict[str, Any]: + result = copy.deepcopy(original) + for key, value in updated.items(): + if key.lower() in SENSITIVE_FIELDS: + if isinstance(value, str) and "****" not in value: + result[key] = value + elif isinstance(value, dict): + child = result.get(key) + result[key] = _merge_config(child if isinstance(child, dict) else {}, value) + else: + result[key] = copy.deepcopy(value) + for key in list(result): + if key not in updated and key.lower() not in SENSITIVE_FIELDS: + del result[key] + return result + + +def _model_dto(row: dict[str, Any], *, masked: bool = True) -> dict[str, Any]: + config = parse_json_object(row.get("config_json")) + return { + "id": row.get("id"), + "modelType": row.get("model_type"), + "modelCode": row.get("model_code"), + "modelName": row.get("model_name"), + "isDefault": row.get("is_default"), + "isEnabled": row.get("is_enabled"), + "configJson": mask_sensitive(config) if masked else config, + "docLink": row.get("doc_link"), + "remark": row.get("remark"), + "sort": row.get("sort"), + } + + +class ModelService: + def __init__(self, repository: ModelRepository): + self.repository = repository + + async def names(self, model_type: str, model_name: str | None) -> list[dict[str, Any]]: + return [ + {"id": row.get("id"), "modelName": row.get("model_name")} + for row in await self.repository.list_model_names(model_type, model_name) + ] + + async def llm_names(self, model_name: str | None) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + for row in await self.repository.list_llm_names(model_name): + config = parse_json_object(row.get("config_json")) or {} + result.append( + {"id": row.get("id"), "modelName": row.get("model_name"), "type": str(config.get("type", ""))} + ) + return result + + async def model_page(self, model_type: str, model_name: str | None, page: str, limit: str) -> dict[str, Any]: + current, size = max(int(page), 1), int(limit) + rows, total = await self.repository.list_model_configs( + model_type=model_type, + model_name=model_name, + offset=(current - 1) * size, + limit=size, + ) + return {"total": total, "list": [_model_dto(row) for row in rows]} + + async def get_model(self, model_id: str) -> dict[str, Any] | None: + row = await self.repository.get_model(model_id) + return _model_dto(row) if row else None + + async def add(self, model_type: str, provider_code: str, body: ModelConfigBody) -> dict[str, Any]: + if not model_type.strip() or not provider_code.strip(): + raise AppError(10131) + model_id = body.id or uuid.uuid4().hex + values = { + "id": model_id, + "model_type": model_type, + "model_code": body.model_code, + "model_name": body.model_name, + "is_default": 0, + "is_enabled": body.is_enabled, + "config_json": json.dumps(body.config_json, ensure_ascii=False) if body.config_json is not None else None, + "doc_link": body.doc_link, + "remark": body.remark, + "sort": body.sort, + } + async with self.repository.session.begin(): + # Keep the read and write in one transaction. A query before + # ``begin()`` triggers SQLAlchemy autobegin and makes the explicit + # transaction fail with InvalidRequestError. + if await self.repository.get_provider(model_type, provider_code) is None: + raise AppError(10162) + await self.repository.insert_model(values) + return _model_dto(values) + + async def edit( + self, model_type: str, provider_code: str, model_id: str, body: ModelConfigBody + ) -> dict[str, Any]: + if not model_type.strip() or not provider_code.strip(): + raise AppError(10131) + async with self.repository.session.begin(): + if await self.repository.get_provider(model_type, provider_code) is None: + raise AppError(10162) + original = await self.repository.get_model(model_id, for_update=True) + if original is None: + raise AppError(10051) + updated_config = body.config_json + if updated_config is not None and "llm" in updated_config: + llm = await self.repository.get_model(str(updated_config["llm"])) + llm_config = parse_json_object(llm.get("config_json")) if llm else None + if llm is None or str(llm.get("model_type") or "").upper() != "LLM": + raise AppError(10092) + if llm_config and "type" in llm_config and llm_config["type"] not in {"openai", "ollama"}: + raise AppError(10049) + original_config = parse_json_object(original.get("config_json")) + merged = ( + _merge_config(original_config, updated_config) + if original_config is not None and updated_config is not None + else original_config + ) + values = { + "id": model_id, + "model_type": model_type, + "model_code": original.get("model_code"), + "model_name": body.model_name, + "is_default": original.get("is_default"), + "is_enabled": body.is_enabled, + "config_json": json.dumps(merged, ensure_ascii=False) if merged is not None else None, + "doc_link": original.get("doc_link"), + "remark": body.remark, + "sort": body.sort, + } + await self.repository.update_model(values) + await self._clear_cache(model_id) + return _model_dto(values) + + async def delete(self, model_id: str) -> None: + if not model_id.strip(): + raise AppError(10006) + async with self.repository.session.begin(): + model = await self.repository.get_model(model_id, for_update=True) + if model and int(model.get("is_default") or 0) == 1: + raise AppError(10064) + agents = await self.repository.model_agent_references(model_id) + if agents: + raise AppError(10093, params=("、".join(agents),)) + if model and str(model.get("model_type") or "").upper() == "LLM": + if await self.repository.intent_reference_count(model_id): + raise AppError(10094) + await self.repository.delete_model(model_id) + await self._clear_cache(model_id) + + async def enable(self, model_id: str, status: int) -> str | None: + async with self.repository.session.begin(): + model = await self.repository.get_model(model_id, for_update=True) + if model is None: + return "模型配置不存在" + if status == 0 and int(model.get("is_default") or 0) > 0: + return "默认模型配置不允许关闭" + await self.repository.set_model_enabled(model_id, status) + await self._clear_cache(model_id) + return None + + async def set_default(self, model_id: str) -> str | None: + async with self.repository.session.begin(): + model = await self.repository.get_model(model_id, for_update=True) + if model is None: + return "模型配置不存在" + model_type = str(model.get("model_type") or "") + await self.repository.set_models_default(model_type, 0) + await self.repository.execute( + "UPDATE ai_model_config SET is_enabled=1, is_default=1 WHERE id=:id", {"id": model_id} + ) + await self.repository.update_default_template_models(model_type, model_id) + await self._clear_type_cache(model_type) + return None + + async def _clear_cache(self, model_id: str) -> None: + redis = get_redis() + await redis.delete(f"model:data:{model_id}", f"model:name:{model_id}") + + async def _clear_type_cache(self, model_type: str) -> None: + rows = await self.repository.fetch_all( + "SELECT id FROM ai_model_config WHERE model_type=:type", {"type": model_type} + ) + if rows: + redis = get_redis() + keys = [key for row in rows for key in (f"model:data:{row['id']}", f"model:name:{row['id']}")] + await redis.delete(*keys) + + +class ModelProviderService: + def __init__(self, repository: ModelRepository): + self.repository = repository + + async def page( + self, model_type: str | None, name: str | None, page: str, limit: str + ) -> dict[str, Any]: + current, size = max(int(page), 1), int(limit) + rows, total = await self.repository.list_providers( + model_type=model_type, name=name, offset=(current - 1) * size, limit=size + ) + return {"total": total, "list": rows} + + @staticmethod + def _validate(body: ModelProviderBody, *, update: bool) -> None: + if update and (body.id is None or not body.id.strip()): + raise AppError(10034, "id不能为空") + for field, message in ( + (body.provider_code, "providerCode不能为空"), + (body.model_type, "modelType不能为空"), + (body.name, "name不能为空"), + (body.fields, "fields(JSON格式)不能为空"), + ): + if field is None or not field.strip(): + raise AppError(10034, message) + if body.sort is None: + raise AppError(10034, "sort不能为空") + + async def add(self, body: ModelProviderBody, user: AuthUser) -> dict[str, Any]: + self._validate(body, update=False) + now = shanghai_now_naive() + values = { + "id": body.id or uuid.uuid4().hex, + "model_type": body.model_type, + "provider_code": body.provider_code, + "name": body.name, + "fields": body.fields, + "sort": body.sort, + "creator": user.id, + "updater": user.id, + "now": now, + } + async with self.repository.session.begin(): + await self.repository.insert_provider(values) + return { + # The Java service returns the request DTO, not the entity on which + # MyBatis-Plus generated the UUID. Therefore an omitted id remains + # null in the response even though the stored row has an id. + "id": body.id, "modelType": body.model_type, "providerCode": body.provider_code, + "name": body.name, "fields": body.fields, "sort": body.sort, "creator": user.id, + "updater": user.id, "createDate": now, "updateDate": now, + } + + async def edit(self, body: ModelProviderBody, user: AuthUser) -> dict[str, Any]: + self._validate(body, update=True) + now = shanghai_now_naive() + values = { + "id": body.id, "model_type": body.model_type, "provider_code": body.provider_code, + "name": body.name, "fields": body.fields, "sort": body.sort, "updater": user.id, "now": now, + } + async with self.repository.session.begin(): + if await self.repository.update_provider(values) == 0: + raise AppError(10066) + return { + "id": body.id, "modelType": body.model_type, "providerCode": body.provider_code, + "name": body.name, "fields": body.fields, "sort": body.sort, "updater": user.id, + "updateDate": now, "creator": None, "createDate": None, + } + + async def delete(self, ids: list[str]) -> None: + async with self.repository.session.begin(): + if await self.repository.delete_providers(ids) == 0: + raise AppError(10043) diff --git a/main/manager-api-fastapi/app/services/security.py b/main/manager-api-fastapi/app/services/security.py new file mode 100644 index 00000000..72b5f93d --- /dev/null +++ b/main/manager-api-fastapi/app/services/security.py @@ -0,0 +1,486 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import io +import json +import logging +import re +import secrets +import string +import time +import urllib.parse +import uuid +from datetime import datetime, timedelta +from typing import Any, Protocol, cast + +import httpx +from fastapi import Request +from PIL import Image, ImageDraw, ImageFont +from redis.asyncio import Redis + +from app.core.config import get_settings +from app.core.crypto import bcrypt_hash, bcrypt_matches, generate_database_token, sm2_decrypt_c1c3c2 +from app.core.errors import AppError, ErrorCode +from app.core.ids import snowflake +from app.core.redis import JavaRedisCodec, get_redis +from app.core.security import AuthUser, shanghai_now_naive +from app.repositories.security import SecurityRepository +from app.schemas.security import ( + LoginRequest, + PasswordChangeRequest, + RetrievePasswordRequest, + SmsVerificationRequest, +) +from app.services.java_validation import validation_message + +logger = logging.getLogger(__name__) + +TOKEN_EXPIRE_SECONDS = 12 * 60 * 60 +CAPTCHA_TTL_SECONDS = 5 * 60 +CAPTCHA_LENGTH = 5 +PHONE_PATTERN = re.compile(r"^\+[1-9]\d{0,3}[1-9]\d{4,14}$") +STRONG_PASSWORD = re.compile(r"^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z]).+$") + + +class SmsSender(Protocol): + async def send_verification_code(self, phone: str | None, code: str) -> None: ... + + +class AliyunSmsSender: + """Minimal implementation of the Aliyun Dysmsapi RPC request used by the Java SDK.""" + + def __init__( + self, + repository: SecurityRepository, + *, + redis: Redis | None = None, + client: httpx.AsyncClient | None = None, + endpoint: str = "https://dysmsapi.aliyuncs.com/", + ): + self.repository = repository + self.redis = redis or get_redis() + self.client = client + self.endpoint = endpoint + + async def send_verification_code(self, phone: str | None, code: str) -> None: + access_key_id = await self._param("aliyun.sms.access_key_id") or "" + access_key_secret = await self._param("aliyun.sms.access_key_secret") or "" + sign_name = await self._param("aliyun.sms.sign_name") or "" + template_code = await self._param("aliyun.sms.sms_code_template_code") or "" + # The Tea SDK constructs its client before the refundable send block; + # blank credentials therefore map to SMS_CONNECTION_FAILED (10056). + if not access_key_id.strip() or not access_key_secret.strip(): + raise AppError(10056) + timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + params: dict[str, str] = { + "AccessKeyId": access_key_id, + "Action": "SendSms", + "Format": "JSON", + "RegionId": "cn-hangzhou", + "SignatureMethod": "HMAC-SHA1", + "SignatureNonce": str(uuid.uuid4()), + "SignatureVersion": "1.0", + "SignName": sign_name, + "TemplateCode": template_code, + "TemplateParam": json.dumps({"code": code}, ensure_ascii=False, separators=(",", ":")), + "Timestamp": timestamp, + "Version": "2017-05-25", + } + if phone is not None: + params["PhoneNumbers"] = phone + params["Signature"] = self._signature(params, access_key_secret) + if self.client is not None: + response = await self.client.post(self.endpoint, data=params) + response.raise_for_status() + return + timeout = get_settings().external_request_timeout_seconds + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post(self.endpoint, data=params) + response.raise_for_status() + + async def _param(self, code: str) -> str | None: + cached = JavaRedisCodec.decode(await cast(Any, self.redis.hget)("sys:params", code)) + if cached is not None: + return str(cached) + value = await self.repository.get_param_value(code) + if value is not None: + await cast(Any, self.redis.hset)("sys:params", code, JavaRedisCodec.encode(value)) + await cast(Any, self.redis.expire)("sys:params", 24 * 60 * 60) + return value + + @classmethod + def _signature(cls, params: dict[str, str], secret: str) -> str: + canonical = "&".join( + f"{cls._percent_encode(key)}={cls._percent_encode(value)}" for key, value in sorted(params.items()) + ) + string_to_sign = f"POST&%2F&{cls._percent_encode(canonical)}" + digest = hmac.new( + f"{secret}&".encode(), + string_to_sign.encode(), + digestmod=hashlib.sha1, # noqa: S324 - mandated by Aliyun RPC SignatureMethod + ).digest() + return base64.b64encode(digest).decode("ascii") + + @staticmethod + def _percent_encode(value: str) -> str: + return urllib.parse.quote(str(value), safe="~") + + +class CaptchaService: + def __init__(self, redis: Redis | None = None): + self.redis = redis or get_redis() + + async def create(self, identifier: str) -> bytes: + code = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(CAPTCHA_LENGTH)) + await self._set_cache(identifier, code) + return self._render_gif(code) + + async def validate(self, identifier: str | None, code: str | None, *, delete: bool) -> bool: + if not code or not code.strip(): + return False + key = self._captcha_key(identifier) + cached = JavaRedisCodec.decode(await cast(Any, self.redis.get(key))) + if cached is not None and delete: + await cast(Any, self.redis.delete(key)) + return cached is not None and code.casefold() == str(cached).casefold() + + async def set_sms_code(self, phone: str | None, code: str) -> None: + await self._set_cache(f"sms:Validate:Code:{phone}", code) + + async def validate_sms_code(self, phone: str | None, code: str | None, *, delete: bool = False) -> bool: + return await self.validate(f"sms:Validate:Code:{phone}", code, delete=delete) + + async def _set_cache(self, identifier: str, value: str) -> None: + await cast(Any, self.redis.set)( + self._captcha_key(identifier), + JavaRedisCodec.encode(value), + ex=CAPTCHA_TTL_SECONDS, + ) + + @staticmethod + def _captcha_key(identifier: str | None) -> str: + return f"sys:captcha:{'null' if identifier is None else identifier}" + + @staticmethod + def _render_gif(code: str) -> bytes: + image = Image.new("RGB", (150, 40), (248, 248, 248)) + draw = ImageDraw.Draw(image) + for _ in range(8): + color = tuple(secrets.randbelow(150) for _ in range(3)) + draw.line( + ( + secrets.randbelow(150), + secrets.randbelow(40), + secrets.randbelow(150), + secrets.randbelow(40), + ), + fill=color, + width=1, + ) + font = ImageFont.load_default(size=24) + for index, character in enumerate(code): + color = tuple(secrets.randbelow(120) for _ in range(3)) + draw.text((10 + index * 27, 6 + secrets.randbelow(5)), character, font=font, fill=color) + output = io.BytesIO() + image.save(output, format="GIF") + return output.getvalue() + + +class SecurityService: + def __init__( + self, + repository: SecurityRepository, + *, + redis: Redis | None = None, + captcha: CaptchaService | None = None, + sms_sender: SmsSender | None = None, + ): + self.repository = repository + self.redis = redis or get_redis() + self.captcha = captcha or CaptchaService(self.redis) + self.sms_sender = sms_sender or AliyunSmsSender(repository, redis=self.redis) + + async def login(self, dto: LoginRequest, request: Request) -> dict[str, Any]: + password = await self._decrypt_and_validate_captcha(dto.password, dto.captcha_id) + user = await self.repository.get_user_by_username(dto.username) + if user is None or not bcrypt_matches(password, cast(str | None, user.get("password"))): + raise AppError(ErrorCode.ACCOUNT_PASSWORD_ERROR) + token = await self._create_token(int(user["id"])) + await self.repository.session.commit() + return { + "token": token, + "expire": TOKEN_EXPIRE_SECONDS, + "clientHash": self._client_hash(request), + } + + async def register(self, dto: LoginRequest) -> None: + if not await self.allow_user_register(): + raise AppError(10072) + password = await self._decrypt_and_validate_captcha(dto.password, dto.captcha_id) + if await self._mobile_registration_enabled(): + if dto.username is None or not PHONE_PATTERN.fullmatch(dto.username): + raise AppError(10069) + if not await self.captcha.validate_sms_code(dto.username, dto.mobile_captcha, delete=False): + raise AppError(10075) + if await self.repository.get_user_by_username(dto.username) is not None: + raise AppError(10070) + if not STRONG_PASSWORD.fullmatch(password): + raise AppError(ErrorCode.PASSWORD_WEAK_ERROR) + now = shanghai_now_naive() + user_count = await self.repository.count_users() + await self.repository.insert_user( + user_id=snowflake.next_id(), + username=dto.username, + password=bcrypt_hash(password), + super_admin=1 if user_count == 0 else 0, + now=now, + ) + await self.repository.session.commit() + + async def change_password( + self, + user: AuthUser, + dto: PasswordChangeRequest, + accept_language: str | None = None, + ) -> None: + self._require_not_blank(dto.password, "sysuser.password.require", accept_language) + self._require_not_blank(dto.new_password, "sysuser.password.require", accept_language) + assert dto.password is not None + assert dto.new_password is not None + row = await self.repository.get_user_by_id(user.id) + if row is None: + raise AppError(ErrorCode.TOKEN_INVALID) + if not bcrypt_matches(dto.password, cast(str | None, row.get("password"))): + raise AppError(10048) + if not STRONG_PASSWORD.fullmatch(dto.new_password): + raise AppError(ErrorCode.PASSWORD_WEAK_ERROR) + now = shanghai_now_naive() + await self.repository.update_password( + user.id, + bcrypt_hash(dto.new_password), + now, + preserve_audit_fields=True, + ) + # SysUserService.changePassword commits before the non-transactional token service logs out. + await self.repository.session.commit() + await self.repository.expire_user_token(user.id, now - timedelta(minutes=1)) + await self.repository.session.commit() + + async def retrieve_password( + self, + dto: RetrievePasswordRequest, + accept_language: str | None = None, + ) -> None: + if not await self._mobile_registration_enabled(): + raise AppError(10073) + self._require_not_blank(dto.phone, "sysuser.password.require", accept_language) + self._require_not_blank(dto.code, "sysuser.password.require", accept_language) + self._require_not_blank(dto.password, "sysuser.password.require", accept_language) + self._require_not_blank(dto.captcha_id, "sysuser.uuid.require", accept_language) + assert dto.phone is not None + assert dto.code is not None + assert dto.password is not None + assert dto.captcha_id is not None + if not PHONE_PATTERN.fullmatch(dto.phone): + raise AppError(10074) + user = await self.repository.get_user_by_username(dto.phone) + if user is None: + raise AppError(10071) + if not await self.captcha.validate_sms_code(dto.phone, dto.code, delete=False): + raise AppError(10075) + password = await self._decrypt_and_validate_captcha(dto.password, dto.captcha_id) + if not STRONG_PASSWORD.fullmatch(password): + raise AppError(ErrorCode.PASSWORD_WEAK_ERROR) + await self.repository.update_password(int(user["id"]), bcrypt_hash(password), shanghai_now_naive()) + await self.repository.session.commit() + + async def send_sms_verification(self, dto: SmsVerificationRequest) -> None: + if not await self.captcha.validate(dto.captcha_id, dto.captcha, delete=False): + raise AppError(10067) + if not await self._mobile_registration_enabled(): + raise AppError(10068) + phone_key = "null" if dto.phone is None else dto.phone + last_send_key = f"sms:Validate:Code:{phone_key}:last_send_time" + current_ms = int(time.time() * 1000) + created = await cast(Any, self.redis.set)(last_send_key, str(current_ms), ex=60, nx=True) + if not created: + raw_last = await cast(Any, self.redis.get)(last_send_key) + if raw_last is not None: + last_ms = int(raw_last.decode() if isinstance(raw_last, bytes) else raw_last) + difference = current_ms - last_ms + if difference < 60_000: + raise AppError(10060, params=(str(max(0, (60_000 - difference) // 1000)),)) + + today_key = f"sms:Validate:Code:{phone_key}:today_count" + raw_count = await cast(Any, self.redis.get)(today_key) + decoded_count = JavaRedisCodec.decode(raw_count) + today_count = int(decoded_count or 0) + raw_maximum = await self._get_param("server.sms_max_send_count", from_cache=True) + maximum = int(raw_maximum) if raw_maximum is not None and raw_maximum != "" else 5 + if today_count >= maximum: + raise AppError(10047) + + code = "".join(secrets.choice(string.digits) for _ in range(6)) + await self.captcha.set_sms_code(dto.phone, code) + new_count = await cast(Any, self.redis.incr)(today_key) + if int(new_count) == 1: + await cast(Any, self.redis.expire)(today_key, 24 * 60 * 60) + try: + await self.sms_sender.send_verification_code(dto.phone, code) + except AppError: + # Java raises connection-construction failures before entering its refundable send attempt. + raise + except Exception as exc: + logger.warning("Aliyun SMS request failed", exc_info=exc) + await cast(Any, self.redis.delete)(today_key) + raise AppError(10055) from exc + + async def public_config(self) -> dict[str, Any]: + public_key = await self._get_param("server.public_key", from_cache=True) + if public_key is None or not public_key.strip(): + raise AppError(10129) + menu_config = await self._get_param("system-web.menu", from_cache=True) + result: dict[str, Any] = { + "enableMobileRegister": await self._mobile_registration_enabled(), + "version": "0.9.5", + "year": f"©{shanghai_now_naive().year}", + "allowUserRegister": await self.allow_user_register(), + "mobileAreaList": await self._dict_data_by_type("MOBILE_AREA"), + "beianIcpNum": await self._get_param("server.beian_icp_num", from_cache=True), + "beianGaNum": await self._get_param("server.beian_ga_num", from_cache=True), + "name": await self._get_param("server.name", from_cache=True), + "sm2PublicKey": public_key, + } + if menu_config is not None and menu_config.strip(): + result["systemWebMenu"] = json.loads(menu_config) + return result + + async def allow_user_register(self) -> bool: + value = await self._get_param("server.allow_user_register", from_cache=True) + if value == "true": + return True + return await self.repository.count_users() == 0 + + async def _create_token(self, user_id: int) -> str: + now = shanghai_now_naive() + expire_date = now + timedelta(seconds=TOKEN_EXPIRE_SECONDS) + current = await self.repository.get_token_by_user_id(user_id, for_update=True) + if current is None: + token = generate_database_token() + await self.repository.insert_token( + token_id=snowflake.next_id(), + user_id=user_id, + token=token, + now=now, + expire_date=expire_date, + ) + return token + stored_expiry = self._datetime(current.get("expire_date")) + token = str(current["token"]) + if stored_expiry is None or stored_expiry < now: + token = generate_database_token() + await self.repository.update_token( + token_id=int(current["id"]), + token=token, + now=now, + expire_date=expire_date, + ) + return token + + async def _decrypt_and_validate_captcha( + self, + encrypted_password: str | None, + captcha_id: str | None, + ) -> str: + private_key = await self._get_param("server.private_key", from_cache=True) + if private_key is None or not private_key.strip(): + raise AppError(10129) + try: + if encrypted_password is None: + raise ValueError("encrypted password is null") + content = sm2_decrypt_c1c3c2(private_key, encrypted_password) + except Exception as exc: + raise AppError(10130) from exc + if len(content) > CAPTCHA_LENGTH: + embedded_captcha = content[:CAPTCHA_LENGTH] + if not await self.captcha.validate(captcha_id, embedded_captcha, delete=True): + raise AppError(10067) + return content[CAPTCHA_LENGTH:] + if content: + raise AppError(10067) + raise AppError(10130) + + async def _mobile_registration_enabled(self) -> bool: + value = await self._get_param("server.enable_mobile_register", from_cache=True) + if value is None or not value.strip(): + return False + try: + parsed = json.loads(value.lower()) + except json.JSONDecodeError as exc: + raise AppError(ErrorCode.PARAMS_GET_ERROR) from exc + return bool(parsed) + + async def _get_param(self, code: str, *, from_cache: bool) -> str | None: + if from_cache: + cached = JavaRedisCodec.decode(await cast(Any, self.redis.hget)("sys:params", code)) + if cached is not None: + return str(cached) + value = await self.repository.get_param_value(code) + if from_cache and value is not None: + await cast(Any, self.redis.hset)("sys:params", code, JavaRedisCodec.encode(value)) + await cast(Any, self.redis.expire)("sys:params", 24 * 60 * 60) + return value + + async def _dict_data_by_type(self, dict_type: str) -> list[dict[str, Any]]: + key = f"sys:dict:data:{dict_type}" + cached = JavaRedisCodec.decode(await cast(Any, self.redis.get)(key)) + if isinstance(cached, list): + return cast(list[dict[str, Any]], cached) + values = await self.repository.get_mobile_area_items() + await cast(Any, self.redis.set)( + key, + JavaRedisCodec.encode( + values, + item_java_type="xiaozhi.modules.sys.vo.SysDictDataItem", + ), + ex=24 * 60 * 60, + ) + return values + + @staticmethod + def _client_hash(request: Request) -> str: + user_agent = request.headers.get("User-Agent", "").lower() + forwarded_headers = ( + "x-forwarded-for", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_CLIENT_IP", + "HTTP_X_FORWARDED_FOR", + ) + ip_address = next( + ( + value + for header in forwarded_headers + if (value := request.headers.get(header)) and value.casefold() != "unknown" + ), + request.client.host if request.client else "", + ) + date = shanghai_now_naive().strftime("%Y-%m-%d") + return hashlib.md5( # noqa: S324 - Java clientHash compatibility requires MD5 + f"{ip_address}{date}{user_agent}".encode(), usedforsecurity=False + ).hexdigest() + + @staticmethod + def _datetime(value: Any) -> datetime | None: + if value is None or isinstance(value, datetime): + return value + if isinstance(value, str): + return datetime.fromisoformat(value) + raise TypeError(f"Unsupported database datetime value: {type(value).__name__}") + + @staticmethod + def _require_not_blank(value: str | None, key: str, accept_language: str | None) -> None: + if value is None or not value.strip(): + raise AppError(500, validation_message(key, accept_language)) diff --git a/main/manager-api-fastapi/app/services/sys.py b/main/manager-api-fastapi/app/services/sys.py new file mode 100644 index 00000000..db38fd24 --- /dev/null +++ b/main/manager-api-fastapi/app/services/sys.py @@ -0,0 +1,715 @@ +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import hmac +import json +import logging +import re +import secrets +import string +import time +import uuid +from datetime import datetime +from typing import Any, cast +from zoneinfo import ZoneInfo + +import httpx +from redis.asyncio import Redis +from websockets.asyncio.client import connect + +from app.core.config import get_settings +from app.core.crypto import bcrypt_hash +from app.core.errors import AppError, ErrorCode +from app.core.ids import snowflake +from app.core.redis import JavaRedisCodec, get_redis +from app.core.security import AuthUser, shanghai_now_naive +from app.repositories.sys import SysRepository +from app.schemas.sys import DictDataPayload, DictTypePayload, EmitServerActionRequest, SysParamPayload +from app.services.java_validation import validation_message + +logger = logging.getLogger(__name__) +WS_PATTERN = re.compile(r"^wss?://[\w.-]+(?:\.[\w.-]+)*(?::\d+)?(?:/[\w.-]*)*$") + + +class AdminService: + def __init__(self, repository: SysRepository): + self.repository = repository + + async def page_users(self, *, mobile: str | None, page: int, limit: int) -> dict[str, Any]: + rows, total = await self.repository.page_users( + mobile=mobile, + page=max(1, page), + limit=max(0, limit), + ) + values = [ + { + "deviceCount": str(row.get("device_count") or 0), + "mobile": row.get("username"), + "status": row.get("status"), + "userid": str(row["id"]), + "createDate": row.get("create_date"), + } + for row in rows + ] + return {"list": values, "total": total} + + async def reset_password(self, user_id: int, user: AuthUser) -> str: + password = self._generate_password() + await self.repository.reset_user_password(user_id, bcrypt_hash(password), user.id, shanghai_now_naive()) + await self.repository.session.commit() + return password + + async def delete_user(self, user_id: int) -> None: + try: + await self.repository.delete_user_cascade(user_id) + await self.repository.session.commit() + except Exception: + await self.repository.session.rollback() + raise + + async def change_status(self, status: int, user_ids: list[str], user: AuthUser) -> None: + # SysUserServiceImpl.changeStatus has an outer Spring transaction: a later + # parse/update failure rolls back every earlier item in the same request. + try: + for value in user_ids: + await self.repository.change_user_status(status, [int(value)], user.id, shanghai_now_naive()) + await self.repository.session.commit() + except Exception: + await self.repository.session.rollback() + raise + + async def page_devices(self, *, keywords: str | None, page: int, limit: int) -> dict[str, Any]: + rows, total = await self.repository.page_devices( + keywords=keywords, + page=max(1, page), + limit=max(0, limit), + ) + result = [] + for row in rows: + result.append( + { + "appVersion": row.get("app_version"), + "bindUserName": row.get("bind_user_name"), + "deviceType": row.get("board"), + "board": row.get("board"), + "id": row.get("id"), + "macAddress": row.get("mac_address"), + "alias": row.get("alias"), + "otaUpgrade": None, + "recentChatTime": self._short_time(cast(datetime | str | None, row.get("update_date"))), + "lastConnectedAtTimestamp": self._timestamp_ms( + cast(datetime | str | None, row.get("last_connected_at")) + ), + "createDateTimestamp": self._timestamp_ms( + cast(datetime | str | None, row.get("create_date")) + ), + "createDate": self._utc_datetime_string( + cast(datetime | str | None, row.get("create_date")) + ), + } + ) + return {"list": result, "total": total} + + @staticmethod + def _generate_password() -> str: + characters = string.ascii_letters + string.digits + "!@#$%^&*()" + values = [ + secrets.choice(string.digits), + secrets.choice(string.ascii_lowercase), + secrets.choice(string.ascii_uppercase), + secrets.choice("!@#$%^&*()"), + ] + values.extend(secrets.choice(characters) for _ in range(8)) + secrets.SystemRandom().shuffle(values) + return "".join(values) + + @staticmethod + def _timestamp_ms(value: datetime | str | None) -> int | None: + value = AdminService._database_datetime(value) + if value is None: + return None + timezone = ZoneInfo(get_settings().timezone) + localized = value if value.tzinfo else value.replace(tzinfo=timezone) + return int(localized.timestamp() * 1000) + + @staticmethod + def _short_time(value: datetime | str | None) -> str | None: + value = AdminService._database_datetime(value) + if value is None: + return None + now = shanghai_now_naive() + if value.tzinfo: + value = value.astimezone(ZoneInfo(get_settings().timezone)).replace(tzinfo=None) + seconds = int((now - value).total_seconds()) + if seconds <= 10: + return "刚刚" + if seconds < 60: + return f"{seconds}秒前" + if seconds < 3600: + return f"{seconds // 60}分钟前" + if seconds < 86400: + return f"{seconds // 3600}小时前" + if seconds < 604800: + return f"{seconds // 86400}天前" + return value.strftime("%Y-%m-%d %H:%M:%S") + + @staticmethod + def _utc_datetime_string(value: datetime | str | None) -> str | None: + value = AdminService._database_datetime(value) + if value is None: + return None + timezone = ZoneInfo(get_settings().timezone) + localized = value if value.tzinfo else value.replace(tzinfo=timezone) + return localized.astimezone(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M:%S") + + @staticmethod + def _database_datetime(value: datetime | str | None) -> datetime | None: + if isinstance(value, str): + return datetime.fromisoformat(value) + return value + + +class ParamExternalValidator: + def __init__(self, client: httpx.AsyncClient | None = None): + self.client = client + + async def validate(self, code: str, value: str) -> None: + if code == "server.websocket": + await self._websockets(value) + elif code == "server.ota": + await self._http_endpoint(value, kind="ota") + elif code == "server.mcp_endpoint": + await self._http_endpoint(value, kind="mcp") + elif code == "server.voice_print": + await self._http_endpoint(value, kind="voiceprint") + elif code == "server.mqtt_signature_key": + self._mqtt_secret(value) + + async def _websockets(self, value: str) -> None: + urls = value.split(";") + while urls and urls[-1] == "": + urls.pop() + if not urls: + raise AppError(10098) + for raw_url in urls: + if not raw_url.strip(): + continue + if "localhost" in raw_url or "127.0.0.1" in raw_url: + raise AppError(10099) + if not WS_PATTERN.fullmatch(raw_url.strip()): + raise AppError(10100) + try: + async with connect(raw_url, open_timeout=5): + pass + except Exception as exc: + raise AppError(10101) from exc + + async def _http_endpoint(self, value: str, *, kind: str) -> None: + if not value.strip() or value == "null": + return + if "localhost" in value or "127.0.0.1" in value: + raise AppError({"ota": 10103, "mcp": 10110, "voiceprint": 10116}[kind]) + if kind == "ota": + if not value.lower().startswith("http"): + raise AppError(10104) + if not value.endswith("/ota/"): + raise AppError(10105) + elif kind == "mcp": + if "key" not in value.lower(): + raise AppError(10111) + else: + if "key" not in value.lower(): + raise AppError(10117) + if not value.lower().startswith("http"): + raise AppError(10118) + + final_code = {"ota": 10108, "mcp": 10114, "voiceprint": 10121}[kind] + marker = {"ota": "OTA", "mcp": "success", "voiceprint": "healthy"}[kind] + try: + if self.client is not None: + response = await self.client.get(value) + else: + async with httpx.AsyncClient(timeout=get_settings().external_request_timeout_seconds) as client: + response = await client.get(value) + if response.status_code != 200 or marker not in response.text: + raise ValueError("external endpoint response did not match Java validation") + except Exception as exc: + raise AppError(final_code) from exc + + @staticmethod + def _mqtt_secret(secret: str) -> None: + if not secret.strip() or secret == "null": # noqa: S105 - sentinel value from the Java parameter table + raise AppError(10122) + if len(secret) < 8: + raise AppError(10123) + if not re.search(r"[a-z]", secret) or not re.search(r"[A-Z]", secret): + raise AppError(10124) + lowered = secret.lower() + if any(weak in lowered for weak in ("test", "1234", "admin", "password", "qwerty", "xiaozhi")): + raise AppError(10125) + + +class SysParamService: + def __init__( + self, + repository: SysRepository, + *, + redis: Redis | None = None, + validator: ParamExternalValidator | None = None, + ): + self.repository = repository + self.redis = redis or get_redis() + self.validator = validator or ParamExternalValidator() + + async def page( + self, + *, + param_code: str | None, + page: int, + limit: int, + order_field: str | None, + order: str | None, + ) -> dict[str, Any]: + rows, total = await self.repository.page_params( + param_code=param_code, + page=max(1, page), + limit=max(0, limit), + order_field=order_field, + order=order, + ) + return {"list": [self._param_dto(row) for row in rows], "total": total} + + async def get(self, param_id: int) -> dict[str, Any] | None: + row = await self.repository.get_param(param_id) + return None if row is None else self._param_dto(row) + + async def save( + self, + dto: SysParamPayload, + user: AuthUser, + accept_language: str | None = None, + ) -> None: + self._validate_group(dto, update=False, accept_language=accept_language) + self._validate_value(dto) + assert dto.param_code is not None + assert dto.param_value is not None + assert dto.value_type is not None + await self.repository.insert_param( + param_id=snowflake.next_id(), + param_code=dto.param_code, + param_value=dto.param_value, + value_type=dto.value_type, + remark=dto.remark, + user_id=user.id, + now=shanghai_now_naive(), + ) + await self._cache_set(dto.param_code, dto.param_value) + await self.repository.session.commit() + + async def update( + self, + dto: SysParamPayload, + user: AuthUser, + accept_language: str | None = None, + ) -> None: + self._validate_group(dto, update=True, accept_language=accept_language) + assert dto.id is not None + assert dto.param_code is not None + assert dto.param_value is not None + assert dto.value_type is not None + # These checks live in the Java controller and therefore run before + # SysParamsService.update validates the declared value type. + await self.validator.validate(dto.param_code, dto.param_value) + if dto.param_code == "system-web.menu": + await self._update_system_web_menu(dto.param_value, user) + else: + self._validate_value(dto) + await self.repository.update_param( + param_id=dto.id, + param_code=dto.param_code, + param_value=dto.param_value, + value_type=dto.value_type, + remark=dto.remark, + user_id=user.id, + now=shanghai_now_naive(), + ) + await self._cache_set(dto.param_code, dto.param_value) + await self.repository.session.commit() + + async def delete(self, ids: list[str]) -> None: + if not ids: + raise AppError(10001, "id") + parsed_ids = [int(value) for value in ids] + codes = await self.repository.param_codes_for_ids(parsed_ids) + if codes: + await cast(Any, self.redis.hdel)("sys:params", *codes) + await self.repository.delete_params(parsed_ids) + await self.repository.session.commit() + + async def get_value(self, code: str, *, from_cache: bool = True) -> str | None: + if from_cache: + cached = JavaRedisCodec.decode(await cast(Any, self.redis.hget)("sys:params", code)) + if cached is not None: + return str(cached) + value = await self.repository.get_param_value(code) + if value is not None and from_cache: + await self._cache_set(code, value) + return value + + async def config_rows(self) -> list[dict[str, Any]]: + return await self.repository.list_config_params() + + async def _update_system_web_menu(self, config_json: str, user: AuthUser) -> None: + current_config = await self.repository.get_param_value("system-web.menu") + try: + current = json.loads(current_config) if current_config and current_config.strip() else None + updated = json.loads(config_json) if config_json.strip() else None + except json.JSONDecodeError as exc: + raise AppError(ErrorCode.PARAM_JSON_INVALID) from exc + if isinstance(current, dict) and isinstance(updated, dict): + current_features = current.get("features") + updated_features = updated.get("features") + # Java only evaluates addressBook when both feature maps are present. + if isinstance(current_features, dict) and isinstance(updated_features, dict): + current_address = current_features.get("addressBook") + updated_address = updated_features.get("addressBook") + current_enabled = self._java_enabled(current_address) + updated_enabled = self._java_enabled(updated_address) + if current_enabled and not updated_enabled: + await self.repository.delete_plugin_mapping_by_plugin_id("SYSTEM_PLUGIN_CALL_DEVICE") + await self.repository.update_param_value_by_code( + "system-web.menu", config_json, user.id, shanghai_now_naive() + ) + await self._cache_set("system-web.menu", config_json) + + async def _cache_set(self, code: str, value: str) -> None: + await cast(Any, self.redis.hset)("sys:params", code, JavaRedisCodec.encode(value)) + await cast(Any, self.redis.expire)("sys:params", 24 * 60 * 60) + + @staticmethod + def _java_enabled(address_book: Any) -> bool: + if not isinstance(address_book, dict): + return False + value = address_book.get("enabled") + if value is None: + return False + if not isinstance(value, bool): + # The Java implementation casts the JSON value to Boolean. + raise TypeError("addressBook.enabled must be a boolean") + return value + + @staticmethod + def _validate_value(dto: SysParamPayload) -> None: + assert dto.param_value is not None + assert dto.value_type is not None + if not dto.param_value.strip(): + raise AppError(ErrorCode.PARAM_VALUE_NULL) + if not dto.value_type.strip(): + raise AppError(ErrorCode.PARAM_TYPE_NULL) + value_type = dto.value_type.lower() + if value_type in {"string", "array"}: + return + if value_type == "number": + try: + float(dto.param_value) + except ValueError as exc: + raise AppError(ErrorCode.PARAM_NUMBER_INVALID) from exc + return + if value_type == "boolean": + if dto.param_value.lower() not in {"true", "false"}: + raise AppError(ErrorCode.PARAM_BOOLEAN_INVALID) + return + if value_type == "json": + stripped = dto.param_value.strip() + if not stripped.startswith("{") or not stripped.endswith("}"): + raise AppError(ErrorCode.PARAM_JSON_INVALID) + try: + json.loads(dto.param_value) + except json.JSONDecodeError as exc: + raise AppError(ErrorCode.PARAM_JSON_INVALID) from exc + return + raise AppError(ErrorCode.PARAM_TYPE_INVALID) + + @staticmethod + def _validate_group( + dto: SysParamPayload, + *, + update: bool, + accept_language: str | None, + ) -> None: + def fail(key: str) -> None: + raise AppError(500, validation_message(key, accept_language)) + + if update and dto.id is None: + fail("id.require") + if not update and dto.id is not None: + fail("id.null") + if dto.param_code is None or not dto.param_code.strip(): + fail("sysparams.paramcode.require") + if dto.param_value is None or not dto.param_value.strip(): + fail("sysparams.paramvalue.require") + if dto.value_type is None or not dto.value_type.strip(): + fail("sysparams.valuetype.require") + if dto.value_type not in {"string", "number", "boolean", "array", "json"}: + fail("sysparams.valuetype.pattern") + + @staticmethod + def _param_dto(row: dict[str, Any]) -> dict[str, Any]: + return { + "id": row.get("id"), + "paramCode": row.get("param_code"), + "paramValue": row.get("param_value"), + "valueType": row.get("value_type"), + "remark": row.get("remark"), + "createDate": row.get("create_date"), + "updateDate": row.get("update_date"), + } + + +class DictService: + def __init__(self, repository: SysRepository, *, redis: Redis | None = None): + self.repository = repository + self.redis = redis or get_redis() + + async def page_types( + self, + *, + dict_type: str | None, + dict_name: str | None, + page: int, + limit: int, + ) -> dict[str, Any]: + rows, total = await self.repository.page_dict_types( + dict_type=dict_type, + dict_name=dict_name, + page=max(1, page), + limit=max(0, limit), + ) + return {"list": [self._type_vo(row, include_names=True) for row in rows], "total": total} + + async def get_type(self, type_id: int) -> dict[str, Any]: + row = await self.repository.get_dict_type(type_id) + if row is None: + raise AppError(10076) + return self._type_vo(row, include_names=False) + + async def save_type(self, dto: DictTypePayload, user: AuthUser) -> None: + if await self.repository.dict_type_exists(dto.dict_type): + raise AppError(10077) + await self.repository.insert_dict_type( + type_id=dto.id if dto.id is not None else snowflake.next_id(), + dict_type=dto.dict_type, + dict_name=dto.dict_name, + remark=dto.remark, + sort=dto.sort, + user_id=user.id, + now=shanghai_now_naive(), + ) + await self.repository.session.commit() + + async def update_type(self, dto: DictTypePayload, user: AuthUser) -> None: + if await self.repository.dict_type_exists(dto.dict_type, exclude_id=dto.id): + raise AppError(10077) + await self.repository.update_dict_type( + type_id=dto.id, + dict_type=dto.dict_type, + dict_name=dto.dict_name, + remark=dto.remark, + sort=dto.sort, + user_id=user.id, + now=shanghai_now_naive(), + ) + await self.repository.session.commit() + + async def delete_types(self, ids: list[int]) -> None: + await self.repository.delete_dict_types(ids) + await self.repository.session.commit() + + async def page_data( + self, + *, + dict_type_id: int, + dict_label: str | None, + dict_value: str | None, + page: int, + limit: int, + ) -> dict[str, Any]: + rows, total = await self.repository.page_dict_data( + dict_type_id=dict_type_id, + dict_label=dict_label, + dict_value=dict_value, + page=max(1, page), + limit=max(0, limit), + ) + return {"list": [self._data_vo(row, include_names=True) for row in rows], "total": total} + + async def get_data(self, data_id: int) -> dict[str, Any] | None: + row = await self.repository.get_dict_data(data_id) + return None if row is None else self._data_vo(row, include_names=False) + + async def save_data(self, dto: DictDataPayload, user: AuthUser) -> None: + # Java compares dict_label against dictValue here; retain that behavior for compatibility. + if await self.repository.dict_data_label_exists(dto.dict_type_id, dto.dict_value): + raise AppError(10128) + await self.repository.insert_dict_data( + data_id=dto.id if dto.id is not None else snowflake.next_id(), + dict_type_id=dto.dict_type_id, + dict_label=dto.dict_label, + dict_value=dto.dict_value, + remark=dto.remark, + sort=dto.sort, + user_id=user.id, + now=shanghai_now_naive(), + ) + await self._clear_dict_cache(dto.dict_type_id) + await self.repository.session.commit() + + async def update_data(self, dto: DictDataPayload, user: AuthUser) -> None: + if await self.repository.dict_data_label_exists(dto.dict_type_id, dto.dict_value, exclude_id=dto.id): + raise AppError(10128) + await self.repository.update_dict_data( + data_id=dto.id, + dict_type_id=dto.dict_type_id, + dict_label=dto.dict_label, + dict_value=dto.dict_value, + remark=dto.remark, + sort=dto.sort, + user_id=user.id, + now=shanghai_now_naive(), + ) + await self._clear_dict_cache(dto.dict_type_id) + await self.repository.session.commit() + + async def delete_data(self, ids: list[int]) -> None: + if ids: + codes = await self.repository.dict_type_codes_for_data_ids(ids) + if codes: + await cast(Any, self.redis.delete)(*[f"sys:dict:data:{code}" for code in codes]) + await self.repository.delete_dict_data(ids) + await self.repository.session.commit() + + async def items(self, dict_type: str) -> list[dict[str, Any]] | None: + if not dict_type.strip(): + return None + key = f"sys:dict:data:{dict_type}" + cached = JavaRedisCodec.decode(await cast(Any, self.redis.get)(key)) + if isinstance(cached, list): + return cast(list[dict[str, Any]], cached) + rows = await self.repository.dict_items(dict_type) + await cast(Any, self.redis.set)( + key, + JavaRedisCodec.encode( + rows, + item_java_type="xiaozhi.modules.sys.vo.SysDictDataItem", + ), + ex=24 * 60 * 60, + ) + return rows + + async def _clear_dict_cache(self, type_id: int | None) -> None: + dict_type = await self.repository.dict_type_code(type_id) + if dict_type is not None: + await cast(Any, self.redis.delete)(f"sys:dict:data:{dict_type}") + + @staticmethod + def _type_vo(row: dict[str, Any], *, include_names: bool) -> dict[str, Any]: + return { + "id": row.get("id"), + "dictType": row.get("dict_type"), + "dictName": row.get("dict_name"), + "remark": row.get("remark"), + "sort": row.get("sort"), + "creator": row.get("creator"), + "creatorName": row.get("creator_name") if include_names else None, + "createDate": row.get("create_date"), + "updater": row.get("updater"), + "updaterName": row.get("updater_name") if include_names else None, + "updateDate": row.get("update_date"), + } + + @staticmethod + def _data_vo(row: dict[str, Any], *, include_names: bool) -> dict[str, Any]: + return { + "id": row.get("id"), + "dictTypeId": row.get("dict_type_id"), + "dictLabel": row.get("dict_label"), + "dictValue": row.get("dict_value"), + "remark": row.get("remark"), + "sort": row.get("sort"), + "creator": row.get("creator"), + "creatorName": row.get("creator_name") if include_names else None, + "createDate": row.get("create_date"), + "updater": row.get("updater"), + "updaterName": row.get("updater_name") if include_names else None, + "updateDate": row.get("update_date"), + } + + +class ServerActionService: + def __init__(self, param_service: SysParamService, *, redis: Redis | None = None): + self.param_service = param_service + self.redis = redis or get_redis() + + async def server_list(self) -> list[str]: + value = await self.param_service.get_value("server.websocket", from_cache=True) + if value is None or not value.strip(): + return [] + values = value.split(";") + while values and values[-1] == "": + values.pop() + return values + + async def emit(self, dto: EmitServerActionRequest) -> bool: + action = dto.action.lower() if dto.action is not None else None + if action not in {"restart", "update_config"}: + raise AppError(10095) + websocket_text = await self.param_service.get_value("server.websocket", from_cache=True) + if websocket_text is None or not websocket_text.strip(): + raise AppError(10096) + if dto.target_ws not in websocket_text.split(";"): + raise AppError(10097) + payload_secret = await self.param_service.get_value("server.secret", from_cache=True) + device_id = str(uuid.uuid4()) + client_id = str(uuid.uuid4()) + await cast(Any, self.redis.set)( + f"tmp_register_mac:{device_id}", + JavaRedisCodec.encode("true"), + ex=300, + ) + authentication_secret = await self.param_service.get_value("server.secret", from_cache=False) + if authentication_secret is None or not authentication_secret.strip(): + raise AppError(10045) + timestamp = int(time.time()) + content = f"{client_id}|{device_id}|{timestamp}" + signature = hmac.new(authentication_secret.encode(), content.encode(), digestmod=hashlib.sha256).digest() + token = base64.urlsafe_b64encode(signature).rstrip(b"=").decode() + f".{timestamp}" + headers = { + "device-id": device_id, + "client-id": client_id, + "authorization": f"Bearer {token}", + } + if payload_secret is None: + raise AppError(10045) + payload = {"type": "server", "action": action, "content": {"secret": payload_secret}} + try: + async with connect(dto.target_ws, additional_headers=headers, open_timeout=3) as websocket: + await websocket.send(json.dumps(payload, ensure_ascii=False, separators=(",", ":"))) + deadline = time.monotonic() + 120 + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError + raw = await asyncio.wait_for(websocket.recv(), timeout=remaining) + response = json.loads(raw) + if ( + isinstance(response, dict) + and response.get("status") == "success" + and response.get("type") == "server" + and isinstance(response.get("content"), dict) + and response["content"].get("action") is not None + ): + return True + except Exception as exc: + raise AppError(10045) from exc diff --git a/main/manager-api-fastapi/app/services/system_params.py b/main/manager-api-fastapi/app/services/system_params.py new file mode 100644 index 00000000..07935042 --- /dev/null +++ b/main/manager-api-fastapi/app/services/system_params.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import logging + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.redis import java_hget, java_hset + +logger = logging.getLogger(__name__) + + +class SystemParamService: + CACHE_KEY = "sys:params" + + def __init__(self, session: AsyncSession): + self.session = session + + async def get_value(self, code: str, *, from_cache: bool = True) -> str | None: + if from_cache: + try: + cached = await java_hget(self.CACHE_KEY, code) + if cached is not None: + return str(cached) + except Exception: + logger.warning("Redis parameter cache read failed for %s", code, exc_info=True) + result = await self.session.execute( + text("SELECT param_value FROM sys_params WHERE param_code = :code LIMIT 1"), + {"code": code}, + ) + value = result.scalar_one_or_none() + if value is not None and from_cache: + try: + await java_hset(self.CACHE_KEY, code, str(value)) + except Exception: + logger.warning("Redis parameter cache write failed for %s", code, exc_info=True) + return None if value is None else str(value) + + async def set_value(self, code: str, value: str) -> int: + result = await self.session.execute( + text( + "UPDATE sys_params SET param_value = :value, update_date = CURRENT_TIMESTAMP WHERE param_code = :code" + ), + {"code": code, "value": value}, + ) + try: + await java_hset(self.CACHE_KEY, code, value) + except Exception: + logger.warning("Redis parameter cache write failed for %s", code, exc_info=True) + return int(getattr(result, "rowcount", 0) or 0) diff --git a/main/manager-api-fastapi/app/services/timbre.py b/main/manager-api-fastapi/app/services/timbre.py new file mode 100644 index 00000000..4b327c48 --- /dev/null +++ b/main/manager-api-fastapi/app/services/timbre.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from functools import lru_cache +from typing import Any + +from app.core.config import get_settings +from app.core.i18n import _load_properties, message_for, resolve_language +from app.core.ids import snowflake +from app.core.redis import JavaRedisCodec, get_redis +from app.core.security import AuthUser, shanghai_now_naive +from app.repositories.timbre import TimbreRepository +from app.schemas.timbre import TimbreBody + + +def _details(row: dict[str, Any]) -> dict[str, Any]: + return { + "id": row.get("id"), + "languages": row.get("languages"), + "name": row.get("name"), + "remark": row.get("remark"), + "referenceAudio": row.get("reference_audio"), + "referenceText": row.get("reference_text"), + # TimbreDetailsVO.sort is primitive long, whose Java serializer always + # emits a string and whose null conversion default is zero. + "sort": str(row.get("sort") if row.get("sort") is not None else 0), + "ttsModelId": row.get("tts_model_id"), + "ttsVoice": row.get("tts_voice"), + "voiceDemo": row.get("voice_demo"), + } + + +class TimbreService: + def __init__(self, repository: TimbreRepository): + self.repository = repository + + @staticmethod + def _validate(body: TimbreBody, language: str | None) -> None: + from app.core.errors import AppError + + for value, message in ( + (body.languages, "timbre.languages.require"), + (body.name, "timbre.name.require"), + (body.tts_model_id, "timbre.ttsModelId.require"), + (body.tts_voice, "timbre.ttsVoice.require"), + ): + if value is None or not value.strip(): + # TimbreController invokes ValidatorUtils directly. That + # utility wraps validation text in RenException(String), whose + # response code is 500 rather than the global @Valid code 10034. + raise AppError(500, _validation_message(message, language)) + if body.sort is not None and body.sort < 0: + raise AppError(500, _validation_message("sort.number", language)) + + async def page( + self, + tts_model_id: str | None, + name: str | None, + page: str | None, + limit: str | None, + language: str | None, + ) -> dict[str, Any]: + if tts_model_id is None or not tts_model_id.strip(): + from app.core.errors import AppError + + raise AppError(500, _validation_message("timbre.ttsModelId.require", language)) + current, size = max(int(page or "1"), 1), int(limit or "10") + rows, total = await self.repository.page( + tts_model_id=tts_model_id, name=name, offset=(current - 1) * size, limit=size + ) + return {"total": total, "list": [_details(row) for row in rows]} + + async def save(self, body: TimbreBody, user: AuthUser, language: str | None) -> None: + self._validate(body, language) + values = self._values(body, user, str(snowflake.next_id())) + async with self.repository.session.begin(): + await self.repository.insert(values) + + async def update( + self, timbre_id: str, body: TimbreBody, user: AuthUser, language: str | None + ) -> None: + self._validate(body, language) + values = self._values(body, user, timbre_id) + async with self.repository.session.begin(): + await self.repository.update(values) + await get_redis().delete(f"timbre:details:{timbre_id}") + + async def delete(self, ids: list[str]) -> None: + async with self.repository.session.begin(): + await self.repository.delete(ids) + + async def voices(self, model_id: str, voice_name: str | None, user: AuthUser, language: str | None) -> Any: + normal, clones = await self.repository.voices(model_id, voice_name, user.id) + values = [ + { + "id": row.get("id"), + "name": row.get("name"), + "voiceDemo": row.get("voice_demo"), + "languages": row.get("languages"), + "isClone": False, + } + for row in normal + ] + prefix = message_for(10158, language) + redis = get_redis() + for row in clones: + name = prefix + str(row.get("name") or "") + voice = { + "id": row.get("id"), + "name": name, + "voiceDemo": row.get("voice_demo"), + "languages": row.get("languages"), + "isClone": True, + } + await redis.set(f"timbre:name:{row['id']}", JavaRedisCodec.encode(name)) + values.insert(0, voice) + return values or None + + @staticmethod + def _values(body: TimbreBody, user: AuthUser, timbre_id: str) -> dict[str, Any]: + assert body.languages is not None + assert body.name is not None + assert body.tts_model_id is not None + assert body.tts_voice is not None + return { + "id": timbre_id, + "languages": body.languages, + "name": body.name, + "remark": body.remark, + "reference_audio": body.reference_audio, + "reference_text": body.reference_text, + "sort": body.sort if body.sort is not None else 0, + "tts_model_id": body.tts_model_id, + "tts_voice": body.tts_voice, + "voice_demo": body.voice_demo, + "creator": user.id, + "updater": user.id, + "now": shanghai_now_naive(), + } + + +_VALIDATION_FILES = { + "zh-CN": "validation_zh_CN.properties", + "zh-TW": "validation_zh_TW.properties", + "en-US": "validation_en_US.properties", + "de-DE": "validation_de_DE.properties", + "vi-VN": "validation_vi_VN.properties", + "pt-BR": "validation_pt_BR.properties", +} + + +@lru_cache(maxsize=64) +def _validation_message(key: str, accept_language: str | None) -> str: + language = resolve_language(accept_language) + directory = get_settings().i18n_dir + messages = _load_properties(directory / "validation.properties") + messages.update(_load_properties(directory / _VALIDATION_FILES[language])) + return messages.get(key, key) diff --git a/main/manager-api-fastapi/app/services/voiceclone.py b/main/manager-api-fastapi/app/services/voiceclone.py new file mode 100644 index 00000000..172e5f09 --- /dev/null +++ b/main/manager-api-fastapi/app/services/voiceclone.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +import json +import uuid +from collections.abc import Mapping, Sequence +from typing import Any + +import httpx +from redis.asyncio import Redis +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import get_settings +from app.core.errors import AppError +from app.core.i18n import message_for +from app.core.security import AuthUser, shanghai_now_naive +from app.integrations.voice_clone import VoiceCloneIntegration, VoiceCloneProviderError +from app.repositories.voiceclone import VoiceCloneRepository +from app.schemas.voiceclone import VoiceResourceCreateRequest +from app.services.device import is_blank, redis_delete, redis_get, redis_set + +VOICE_ORDER_COLUMNS = { + "id": "id", + "name": "name", + "modelId": "model_id", + "model_id": "model_id", + "voiceId": "voice_id", + "voice_id": "voice_id", + "userId": "user_id", + "user_id": "user_id", + "trainStatus": "train_status", + "train_status": "train_status", + "createDate": "create_date", + "create_date": "create_date", +} + + +class VoiceCloneService: + def __init__( + self, + session: AsyncSession, + *, + redis_client: Redis | None = None, + http_client: httpx.AsyncClient | None = None, + provider: VoiceCloneIntegration | None = None, + ): + self.session = session + self.repository = VoiceCloneRepository(session) + self.redis = redis_client + self.provider = provider or VoiceCloneIntegration( + timeout_seconds=get_settings().external_request_timeout_seconds, + client=http_client, + ) + + async def page(self, query: Mapping[str, Any], *, user_id: int | None = None) -> dict[str, Any]: + page = int(str(query.get("page") or "1")) + limit = int(str(query.get("limit") or "10")) + name_value = query.get("name") + name = None if name_value is None else str(name_value) + effective_user = str(user_id) if user_id is not None else self._optional_string(query.get("userId")) + requested = query.get("orderField") + requested_fields = [requested] if isinstance(requested, str) else list(requested or []) + order_fields = [VOICE_ORDER_COLUMNS[field] for field in requested_fields if field in VOICE_ORDER_COLUMNS] + if not order_fields: + order_fields = ["create_date"] + ascending = str(query.get("order") or "").lower() == "asc" if requested_fields else True + rows = await self.repository.page( + page=page, + limit=limit, + name=name, + user_id=effective_user, + order_fields=order_fields, + ascending=ascending, + ) + return { + "total": await self.repository.count(name=name, user_id=effective_user), + "list": await self._response_list(rows), + } + + async def get_detail(self, voice_id: str) -> dict[str, Any] | None: + row = await self.repository.get(voice_id) + if row is None: + return None + return await self._response(row, include_has_voice=False) + + async def get_by_user(self, user_id: int) -> list[dict[str, Any]]: + del user_id + # VoiceCloneServiceImpl.getByUserId orders ai_voice_clone by the + # nonexistent ``created_at`` column (the schema uses ``create_date``). + # The Java endpoint therefore consistently exposes its generic + # code-500 envelope before result conversion. + raise AppError(500) + + async def create_resources(self, request: VoiceResourceCreateRequest, *, actor: AuthUser) -> None: + model_id = request.model_id or "" + config = await self._model_config(model_id) + if config is None: + raise AppError(10152) + provider_type = config.get("type") + if not isinstance(provider_type, str) or not provider_type.strip(): + raise AppError(10153) + voice_ids = request.voice_ids or [] + for voice_id in voice_ids: + if is_blank(voice_id): + continue + if provider_type == "huoshan_double_stream" and "S_" not in voice_id: + raise AppError(10160) + if await self.repository.voice_id_count(model_id=model_id, voice_id=voice_id): + raise AppError(10159) + + now = shanghai_now_naive() + prefix = now.strftime("%m%d%H%M") + values: list[dict[str, Any]] = [] + for index, voice_id in enumerate(voice_ids, start=1): + values.append( + { + "id": uuid.uuid4().hex, + "name": f"{prefix}_{index}", + "model_id": model_id, + "voice_id": voice_id, + "languages": request.languages, + "user_id": request.user_id, + "voice": None, + "train_status": 0, + "train_error": None, + "creator": actor.id, + "create_date": now, + } + ) + try: + await self.repository.insert_many(values) + await self.session.commit() + except Exception: + await self.session.rollback() + raise + + async def delete(self, ids: Sequence[str]) -> None: + await self.repository.delete_many(ids) + await self.session.commit() + + async def check_permission(self, voice_id: str | None, user: AuthUser) -> dict[str, Any]: + row = await self.repository.get(voice_id) + if row is None: + raise AppError(10144) + if int(row.get("user_id") or -1) != user.id: + raise AppError(10150) + return row + + async def upload_voice(self, voice_id: str, content: bytes) -> None: + if await self.repository.get(voice_id) is None: + raise AppError(10144) + await self.repository.update_voice(voice_id, content) + await self.session.commit() + + async def rename(self, voice_id: str, name: str) -> None: + if await self.repository.get(voice_id) is None: + raise AppError(10144) + await self.repository.update_name(voice_id, name) + await self.session.commit() + await redis_delete(f"timbre:name:{voice_id}", client=self.redis) + + async def create_audio_id(self, voice_id: str) -> str: + row = await self.repository.get(voice_id) + if row is None or row.get("voice") is None: + raise AppError(10182) + value = str(uuid.uuid4()) + await redis_set(f"voiceClone:audio:id:{value}", voice_id, client=self.redis) + return value + + async def consume_audio(self, download_id: str) -> bytes | None: + key = f"voiceClone:audio:id:{download_id}" + voice_id = await redis_get(key, self.redis) + await redis_delete(key, client=self.redis) + if is_blank(None if voice_id is None else str(voice_id)): + return None + row = await self.repository.get(str(voice_id)) + data = None if row is None else row.get("voice") + if data is None: + return None + result = bytes(data) + return result or None + + async def clone_audio( + self, + voice_id: str, + *, + accept_language: str | None, + ) -> None: + row = await self.repository.get(voice_id) + if row is None: + raise AppError(10144) + raw_voice = row.get("voice") + if raw_voice is None or len(raw_voice) == 0: + raise AppError(10151) + try: + config = await self._model_config(str(row.get("model_id") or "")) + if config is None: + raise AppError(10152) + provider_type = config.get("type") + if not isinstance(provider_type, str) or not provider_type.strip(): + raise AppError(10153) + if provider_type != "huoshan_double_stream": + return + appid = config.get("appid") + access_token = config.get("access_token") + if ( + not isinstance(appid, str) + or is_blank(appid) + or not isinstance(access_token, str) + or is_blank(access_token) + ): + raise AppError(10155) + speaker_id = await self.provider.train_huoshan( + appid=appid, + access_token=access_token, + voice=bytes(raw_voice), + speaker_id=str(row.get("voice_id") or ""), + ) + await self.repository.update_training( + voice_id, + train_status=2, + train_error="", + speaker_id=speaker_id, + ) + await self.session.commit() + except AppError as exc: + await self._record_training_failure(voice_id, exc.message or message_for(exc.code, accept_language)) + raise + except VoiceCloneProviderError as exc: + if exc.code in {500, 10156}: + await self._record_training_failure(voice_id, exc.message) + raise AppError(exc.code, exc.message) from exc + translated = message_for(10154, accept_language, exc.message) + await self._record_training_failure(voice_id, translated) + raise AppError(10154, translated) from exc + except Exception as exc: + translated = message_for(10154, accept_language, str(exc)) + await self._record_training_failure(voice_id, translated) + raise AppError(10154, translated) from exc + + async def tts_platforms(self) -> list[dict[str, Any]]: + return await self.repository.get_tts_platforms() + + async def _record_training_failure(self, voice_id: str, message: str) -> None: + await self.session.rollback() + await self.repository.update_training(voice_id, train_status=3, train_error=message) + await self.session.commit() + + async def _model_config(self, model_id: str) -> dict[str, Any] | None: + if is_blank(model_id): + return None + cached = await redis_get(f"model:data:{model_id}", self.redis) + cached_mapping = self._mapping(cached) + if cached_mapping is not None: + config_value = cached_mapping.get("configJson", cached_mapping.get("config_json")) + parsed = self._json_mapping(config_value) + if parsed is not None: + return parsed + row = await self.repository.get_model_config(model_id) + return None if row is None else self._json_mapping(row.get("config_json")) + + async def _model_name(self, model_id: str | None) -> str | None: + if is_blank(model_id): + return None + cache_key = f"model:name:{model_id}" + cached = await redis_get(cache_key, self.redis) + if isinstance(cached, str) and cached.strip(): + return cached + value = await self.repository.get_model_name(model_id or "") + if value is not None and value.strip(): + await redis_set(cache_key, value, client=self.redis) + return value + + async def _response_list(self, rows: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + user_ids = [int(row["user_id"]) for row in rows if row.get("user_id") is not None] + usernames = await self.repository.get_usernames(user_ids) + result: list[dict[str, Any]] = [] + for row in rows: + result.append(await self._response(row, usernames=usernames, include_has_voice=True)) + return result + + async def _response( + self, + row: Mapping[str, Any], + *, + usernames: Mapping[int, str] | None = None, + include_has_voice: bool, + ) -> dict[str, Any]: + user_id = None if row.get("user_id") is None else int(row["user_id"]) + if user_id is None: + username = None + elif usernames is None: + username = await self.repository.get_username(user_id) + else: + username = usernames.get(user_id) + return { + "id": row.get("id"), + "name": row.get("name"), + "model_id": row.get("model_id"), + "model_name": await self._model_name(self._optional_string(row.get("model_id"))), + "voice_id": row.get("voice_id"), + "languages": row.get("languages"), + "user_id": user_id, + "user_name": username, + "train_status": row.get("train_status"), + "train_error": row.get("train_error"), + "create_date": row.get("create_date"), + "has_voice": row.get("voice") is not None if include_has_voice else None, + } + + @staticmethod + def _mapping(value: Any) -> dict[str, Any] | None: + if isinstance(value, dict): + return {str(key): item for key, item in value.items() if key != "@class"} + if isinstance(value, list) and len(value) == 2 and isinstance(value[1], dict): + return {str(key): item for key, item in value[1].items()} + return None + + @staticmethod + def _json_mapping(value: Any) -> dict[str, Any] | None: + if isinstance(value, dict): + return {str(key): item for key, item in value.items()} + if isinstance(value, bytes): + value = value.decode("utf-8") + if isinstance(value, str): + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return None + return {str(key): item for key, item in parsed.items()} if isinstance(parsed, dict) else None + return None + + @staticmethod + def _optional_string(value: Any) -> str | None: + return None if value is None else str(value) diff --git a/main/manager-api-fastapi/compatibility/authenticated-route-results.json b/main/manager-api-fastapi/compatibility/authenticated-route-results.json new file mode 100644 index 00000000..d9fa5626 --- /dev/null +++ b/main/manager-api-fastapi/compatibility/authenticated-route-results.json @@ -0,0 +1,8757 @@ +{ + "schema_version": 1, + "generated_at": "2026-07-20T07:12:30.818073+00:00", + "services": { + "java": "http://127.0.0.1:18082/xiaozhi", + "fastapi": "http://127.0.0.1:18083/xiaozhi" + }, + "fixture_ids": { + "admin": "9007199254740993", + "normal": "9007199254740994" + }, + "summary": { + "total": 154, + "passed": 154, + "failed": 0, + "skipped": 0 + }, + "categories": { + "authenticated-route:database-token": { + "passed": 133, + "failed": 0 + }, + "authenticated-route:anonymous": { + "passed": 14, + "failed": 0 + }, + "authenticated-route:server-secret": { + "passed": 7, + "failed": 0 + } + }, + "results": [ + { + "name": "GET /admin/device/all", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 1, + "list": [ + { + "appVersion": "1.2.3", + "bindUserName": "contract-user", + "deviceType": "esp32s3", + "board": "esp32s3", + "id": "contract-device-1", + "macAddress": "AA:BB:CC:DD:EE:01", + "alias": null, + "otaUpgrade": null, + "recentChatTime": "2小时前", + "lastConnectedAtTimestamp": "1784522096000", + "createDateTimestamp": "1784522096000", + "createDate": "2026-07-20 04:34:56" + } + ] + } + }, + "body_sha256": "fc166433b83ed0890db67a20b105908d99ed56ee3112c1122efe70bc84547d37", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [{\"alias\": null, \"appVersion\": \"1.2.3\", \"bindUserName\": \"contract-user\", \"board\": \"esp32s3\", \"createDate\": \"2026-07-20 04:34:56\", \"createDateTimestamp\": \"1784522096000\", \"deviceType\": \"esp32s3\", \"id\": \"contract-device-1\", \"lastConnectedAtTimestamp\": \"1784522096000\", \"macAddress\": \"AA:BB:CC:DD:EE:01\", \"otaUpgrade\": null, \"recentChatTime\": \"2小时前\"}], \"total\": 1}, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "384" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "list": [ + { + "appVersion": "1.2.3", + "bindUserName": "contract-user", + "deviceType": "esp32s3", + "board": "esp32s3", + "id": "contract-device-1", + "macAddress": "AA:BB:CC:DD:EE:01", + "alias": null, + "otaUpgrade": null, + "recentChatTime": "2小时前", + "lastConnectedAtTimestamp": "1784522096000", + "createDateTimestamp": "1784522096000", + "createDate": "2026-07-20 04:34:56" + } + ], + "total": 1 + } + }, + "body_sha256": "fc166433b83ed0890db67a20b105908d99ed56ee3112c1122efe70bc84547d37", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [{\"alias\": null, \"appVersion\": \"1.2.3\", \"bindUserName\": \"contract-user\", \"board\": \"esp32s3\", \"createDate\": \"2026-07-20 04:34:56\", \"createDateTimestamp\": \"1784522096000\", \"deviceType\": \"esp32s3\", \"id\": \"contract-device-1\", \"lastConnectedAtTimestamp\": \"1784522096000\", \"macAddress\": \"AA:BB:CC:DD:EE:01\", \"otaUpgrade\": null, \"recentChatTime\": \"2小时前\"}], \"total\": 1}, \"msg\": \"success\"}" + } + }, + { + "name": "POST /admin/dict/data/delete", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "GET /admin/dict/data/page", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "dictTypeId不能为空", + "data": null + }, + "body_sha256": "89067f54a58b495a55adaa0f02baf1bfb56ed8b2073638ceff398a5c09648b32", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"dictTypeId不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "55" + }, + "body": { + "code": 500, + "msg": "dictTypeId不能为空", + "data": null + }, + "body_sha256": "89067f54a58b495a55adaa0f02baf1bfb56ed8b2073638ceff398a5c09648b32", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"dictTypeId不能为空\"}" + } + }, + { + "name": "POST /admin/dict/data/save", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "GET /admin/dict/data/type/{dictType}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": [] + }, + "body_sha256": "4bf59427c9480fa127a52080c89e8f56420bf728f234d868f1c6511dd1709a9e", + "body_excerpt": "{\"code\": 0, \"data\": [], \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "36" + }, + "body": { + "code": 0, + "msg": "success", + "data": [] + }, + "body_sha256": "4bf59427c9480fa127a52080c89e8f56420bf728f234d868f1c6511dd1709a9e", + "body_excerpt": "{\"code\": 0, \"data\": [], \"msg\": \"success\"}" + } + }, + { + "name": "PUT /admin/dict/data/update", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "38" + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + } + }, + { + "name": "GET /admin/dict/data/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "38" + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + } + }, + { + "name": "POST /admin/dict/type/delete", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "GET /admin/dict/type/page", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 2, + "list": [ + { + "id": "101", + "dictType": "FIRMWARE_TYPE", + "dictName": "固件类型", + "remark": "固件类型字典", + "sort": 0, + "creator": "1", + "creatorName": null, + "createDate": "2026-07-20 15:11:58", + "updater": "1", + "updaterName": null, + "updateDate": "2026-07-20 15:11:58" + }, + { + "id": "102", + "dictType": "MOBILE_AREA", + "dictName": "手机区域", + "remark": "手机区域字典", + "sort": 0, + "creator": "1", + "creatorName": null, + "createDate": "2026-07-20 15:11:58", + "updater": "1", + "updaterName": null, + "updateDate": "2026-07-20 15:11:58" + } + ] + } + }, + "body_sha256": "3c1492b56edcdb2dee1496214510800a815aeaaa046eabc0cffb17194a20a699", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [{\"createDate\": \"2026-07-20 15:11:58\", \"creator\": \"1\", \"creatorName\": null, \"dictName\": \"固件类型\", \"dictType\": \"FIRMWARE_TYPE\", \"id\": \"101\", \"remark\": \"固件类型字典\", \"sort\": 0, \"updateDate\": \"2026-07-20 15:11:58\", \"updater\": \"1\", \"updaterName\": null}, {\"createDate\": \"2026-07-20 15:11:58\", \"creator\": \"1\", \"creatorName\": null, \"dictName\": \"手机区域\", \"dictType\": \"MOBILE_AREA\", \"id\": \"102\", \"remark\": \"手机区域字典\", \"sort\": 0, \"updateDate\": \"2026-07-20 15:11:58\", \"updater\": \"1\", \"updaterName\": null}], \"total\": 2}, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "534" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "list": [ + { + "id": "101", + "dictType": "FIRMWARE_TYPE", + "dictName": "固件类型", + "remark": "固件类型字典", + "sort": 0, + "creator": "1", + "creatorName": null, + "createDate": "2026-07-20 15:11:58", + "updater": "1", + "updaterName": null, + "updateDate": "2026-07-20 15:11:58" + }, + { + "id": "102", + "dictType": "MOBILE_AREA", + "dictName": "手机区域", + "remark": "手机区域字典", + "sort": 0, + "creator": "1", + "creatorName": null, + "createDate": "2026-07-20 15:11:58", + "updater": "1", + "updaterName": null, + "updateDate": "2026-07-20 15:11:58" + } + ], + "total": 2 + } + }, + "body_sha256": "3c1492b56edcdb2dee1496214510800a815aeaaa046eabc0cffb17194a20a699", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [{\"createDate\": \"2026-07-20 15:11:58\", \"creator\": \"1\", \"creatorName\": null, \"dictName\": \"固件类型\", \"dictType\": \"FIRMWARE_TYPE\", \"id\": \"101\", \"remark\": \"固件类型字典\", \"sort\": 0, \"updateDate\": \"2026-07-20 15:11:58\", \"updater\": \"1\", \"updaterName\": null}, {\"createDate\": \"2026-07-20 15:11:58\", \"creator\": \"1\", \"creatorName\": null, \"dictName\": \"手机区域\", \"dictType\": \"MOBILE_AREA\", \"id\": \"102\", \"remark\": \"手机区域字典\", \"sort\": 0, \"updateDate\": \"2026-07-20 15:11:58\", \"updater\": \"1\", \"updaterName\": null}], \"total\": 2}, \"msg\": \"success\"}" + } + }, + { + "name": "POST /admin/dict/type/save", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "PUT /admin/dict/type/update", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "38" + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + } + }, + { + "name": "GET /admin/dict/type/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10076, + "msg": "字典类型不存在", + "data": null + }, + "body_sha256": "c5a4088bff78aee5cf5876e09f159eed6f9ce73fc0a4783bc6f1ba114e6f63e7", + "body_excerpt": "{\"code\": 10076, \"data\": null, \"msg\": \"字典类型不存在\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "56" + }, + "body": { + "code": 10076, + "msg": "字典类型不存在", + "data": null + }, + "body_sha256": "c5a4088bff78aee5cf5876e09f159eed6f9ce73fc0a4783bc6f1ba114e6f63e7", + "body_excerpt": "{\"code\": 10076, \"data\": null, \"msg\": \"字典类型不存在\"}" + } + }, + { + "name": "POST /admin/params", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "值类型不能为空", + "data": null + }, + "body_sha256": "81f11b7b11faead1e75da7f283c73bdfff80570e9cec564d6bf1285a8c515567", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"值类型不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "值类型不能为空", + "data": null + }, + "body_sha256": "81f11b7b11faead1e75da7f283c73bdfff80570e9cec564d6bf1285a8c515567", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"值类型不能为空\"}" + } + }, + { + "name": "PUT /admin/params", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "参数值不能为空", + "data": null + }, + "body_sha256": "775a654f58e6c09756d3170ba5f96220ab1628620ff739ab4c1edb23adbe622b", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"参数值不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "参数值不能为空", + "data": null + }, + "body_sha256": "775a654f58e6c09756d3170ba5f96220ab1628620ff739ab4c1edb23adbe622b", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"参数值不能为空\"}" + } + }, + { + "name": "POST /admin/params/delete", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "GET /admin/params/page", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 47, + "list": [ + { + "id": "102", + "paramCode": "server.secret", + "paramValue": "", + "valueType": "string", + "remark": "服务器密钥", + "createDate": null, + "updateDate": "2026-07-20 12:34:56" + }, + { + "id": "103", + "paramCode": "server.allow_user_register", + "paramValue": "false", + "valueType": "boolean", + "remark": "是否允许管理员以外的人注册", + "createDate": null, + "updateDate": null + }, + { + "id": "104", + "paramCode": "server.fronted_url", + "paramValue": "http://xiaozhi.server.com", + "valueType": "string", + "remark": "下发六位验证码时显示的控制面板地址", + "createDate": null, + "updateDate": null + }, + { + "id": "105", + "paramCode": "device_max_output_size", + "paramValue": "0", + "valueType": "number", + "remark": "单台设备每天最多输出字数,0表示不限制", + "createDate": null, + "updateDate": null + }, + { + "id": "106", + "paramCode": "server.websocket", + "paramValue": "ws://127.0.0.1:18080/xiaozhi/v1/", + "valueType": "string", + "remark": "websocket地址,多个用;分隔", + "createDate": null, + "updateDate": "2026-07-20 12:34:56" + }, + { + "id": "107", + "paramCode": "server.ota", + "paramValue": "http://127.0.0.1:18082/xiaozhi/ota/", + "valueType": "string", + "remark": "ota地址", + "createDate": null, + "updateDate": "2026-07-20 12:34:56" + }, + { + "id": "108", + "paramCode": "server.name", + "paramValue": "xiaozhi-esp32-server", + "valueType": "string", + "remark": "系统名称", + "createDate": null, + "updateDate": null + }, + { + "id": "109", + "paramCode": "server.beian_icp_num", + "paramValue": "null", + "valueType": "string", + "remark": "icp备案号,填写null则不设置", + "createDate": null, + "updateDate": null + }, + { + "id": "110", + "paramCode": "server.beian_ga_num", + "paramValue": "null", + "valueType": "string", + "remark": "公安备案号,填写null则不设置", + "createDate": null, + "updateDate": null + }, + { + "id": "111", + "paramCode": "server.enable_mobile_register", + "paramValue": "false", + "valueType": "boolean", + "remark": "是否开启手机注册", + "createDate": null, + "updateDate": null + } + ] + } + }, + "body_sha256": "8a83287f5ad57cd398b963b729e4c3801d9cae5610c09ac3ada397fd505b753c", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [{\"createDate\": null, \"id\": \"102\", \"paramCode\": \"server.secret\", \"paramValue\": \"\", \"remark\": \"服务器密钥\", \"updateDate\": \"2026-07-20 12:34:56\", \"valueType\": \"string\"}, {\"createDate\": null, \"id\": \"103\", \"paramCode\": \"server.allow_user_register\", \"paramValue\": \"false\", \"remark\": \"是否允许管理员以外的人注册\", \"updateDate\": null, \"valueType\": \"boolean\"}, {\"createDate\": null, \"id\": \"104\", \"paramCode\": \"server.fronted_url\", \"paramValue\": \"http://xiaozhi.server.com\", \"remark\": \"下发六位验证码时显示的控制面板地址\", \"updateDate\": null, \"valueType\": \"string\"}, {\"createDate\": null, \"id\": \"105\", \"paramCode\": \"device_max_output_size\", \"paramValue\": \"0\", \"remark\": \"单台设备每天最多输出字数,0表示不限制\", \"updateDate\": null, \"valueType\": \"number\"}, {\"createDate\": null, \"id\": \"106\", \"paramCode\": \"server.websocket\", \"paramValue\": \"ws://127.0.0.1:18080/xiaozhi/v1/\", \"remark\": \"websocket地址,多个用;分隔\", \"updateDate\": \"2026-07-20 12:34:56\", \"valueType\": \"string\"}, {\"createDate\": null, \"id\": \"107\", \"paramCode\": \"server.ota\"," + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "1891" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "list": [ + { + "id": "102", + "paramCode": "server.secret", + "paramValue": "", + "valueType": "string", + "remark": "服务器密钥", + "createDate": null, + "updateDate": "2026-07-20 12:34:56" + }, + { + "id": "103", + "paramCode": "server.allow_user_register", + "paramValue": "false", + "valueType": "boolean", + "remark": "是否允许管理员以外的人注册", + "createDate": null, + "updateDate": null + }, + { + "id": "104", + "paramCode": "server.fronted_url", + "paramValue": "http://xiaozhi.server.com", + "valueType": "string", + "remark": "下发六位验证码时显示的控制面板地址", + "createDate": null, + "updateDate": null + }, + { + "id": "105", + "paramCode": "device_max_output_size", + "paramValue": "0", + "valueType": "number", + "remark": "单台设备每天最多输出字数,0表示不限制", + "createDate": null, + "updateDate": null + }, + { + "id": "106", + "paramCode": "server.websocket", + "paramValue": "ws://127.0.0.1:18080/xiaozhi/v1/", + "valueType": "string", + "remark": "websocket地址,多个用;分隔", + "createDate": null, + "updateDate": "2026-07-20 12:34:56" + }, + { + "id": "107", + "paramCode": "server.ota", + "paramValue": "http://127.0.0.1:18082/xiaozhi/ota/", + "valueType": "string", + "remark": "ota地址", + "createDate": null, + "updateDate": "2026-07-20 12:34:56" + }, + { + "id": "108", + "paramCode": "server.name", + "paramValue": "xiaozhi-esp32-server", + "valueType": "string", + "remark": "系统名称", + "createDate": null, + "updateDate": null + }, + { + "id": "109", + "paramCode": "server.beian_icp_num", + "paramValue": "null", + "valueType": "string", + "remark": "icp备案号,填写null则不设置", + "createDate": null, + "updateDate": null + }, + { + "id": "110", + "paramCode": "server.beian_ga_num", + "paramValue": "null", + "valueType": "string", + "remark": "公安备案号,填写null则不设置", + "createDate": null, + "updateDate": null + }, + { + "id": "111", + "paramCode": "server.enable_mobile_register", + "paramValue": "false", + "valueType": "boolean", + "remark": "是否开启手机注册", + "createDate": null, + "updateDate": null + } + ], + "total": 47 + } + }, + "body_sha256": "8a83287f5ad57cd398b963b729e4c3801d9cae5610c09ac3ada397fd505b753c", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [{\"createDate\": null, \"id\": \"102\", \"paramCode\": \"server.secret\", \"paramValue\": \"\", \"remark\": \"服务器密钥\", \"updateDate\": \"2026-07-20 12:34:56\", \"valueType\": \"string\"}, {\"createDate\": null, \"id\": \"103\", \"paramCode\": \"server.allow_user_register\", \"paramValue\": \"false\", \"remark\": \"是否允许管理员以外的人注册\", \"updateDate\": null, \"valueType\": \"boolean\"}, {\"createDate\": null, \"id\": \"104\", \"paramCode\": \"server.fronted_url\", \"paramValue\": \"http://xiaozhi.server.com\", \"remark\": \"下发六位验证码时显示的控制面板地址\", \"updateDate\": null, \"valueType\": \"string\"}, {\"createDate\": null, \"id\": \"105\", \"paramCode\": \"device_max_output_size\", \"paramValue\": \"0\", \"remark\": \"单台设备每天最多输出字数,0表示不限制\", \"updateDate\": null, \"valueType\": \"number\"}, {\"createDate\": null, \"id\": \"106\", \"paramCode\": \"server.websocket\", \"paramValue\": \"ws://127.0.0.1:18080/xiaozhi/v1/\", \"remark\": \"websocket地址,多个用;分隔\", \"updateDate\": \"2026-07-20 12:34:56\", \"valueType\": \"string\"}, {\"createDate\": null, \"id\": \"107\", \"paramCode\": \"server.ota\"," + } + }, + { + "name": "GET /admin/params/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "38" + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + } + }, + { + "name": "POST /admin/server/emit-action", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10034, + "msg": "操作不能为空", + "data": null + }, + "body_sha256": "87ef8cbedbfd6559819f5e7e98b9485dc85291607f1581160893e82237033ea6", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"操作不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "53" + }, + "body": { + "code": 10034, + "msg": "操作不能为空", + "data": null + }, + "body_sha256": "87ef8cbedbfd6559819f5e7e98b9485dc85291607f1581160893e82237033ea6", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"操作不能为空\"}" + } + }, + { + "name": "GET /admin/server/server-list", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": [ + "ws://127.0.0.1:18080/xiaozhi/v1/" + ] + }, + "body_sha256": "1781206b08b8837fea6047274977c1a2a800350592c334d7feb627aa2903ac06", + "body_excerpt": "{\"code\": 0, \"data\": [\"ws://127.0.0.1:18080/xiaozhi/v1/\"], \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "70" + }, + "body": { + "code": 0, + "msg": "success", + "data": [ + "ws://127.0.0.1:18080/xiaozhi/v1/" + ] + }, + "body_sha256": "1781206b08b8837fea6047274977c1a2a800350592c334d7feb627aa2903ac06", + "body_excerpt": "{\"code\": 0, \"data\": [\"ws://127.0.0.1:18080/xiaozhi/v1/\"], \"msg\": \"success\"}" + } + }, + { + "name": "GET /admin/users", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 3, + "list": [ + { + "deviceCount": "0", + "mobile": "contract-admin", + "status": 1, + "userid": "9007199254740993", + "createDate": "2026-07-20 12:34:56" + }, + { + "deviceCount": "1", + "mobile": "contract-user", + "status": 1, + "userid": "9007199254740994", + "createDate": "2026-07-20 12:34:56" + }, + { + "deviceCount": "0", + "mobile": "contract-expired", + "status": 1, + "userid": "9007199254740995", + "createDate": "2026-07-20 12:34:56" + } + ] + } + }, + "body_sha256": "87d98d1726ad3cd728dfa41159bc5ae1a363011fefc7caf0b3b1660a886d910b", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [{\"createDate\": \"2026-07-20 12:34:56\", \"deviceCount\": \"0\", \"mobile\": \"contract-admin\", \"status\": 1, \"userid\": \"9007199254740993\"}, {\"createDate\": \"2026-07-20 12:34:56\", \"deviceCount\": \"1\", \"mobile\": \"contract-user\", \"status\": 1, \"userid\": \"9007199254740994\"}, {\"createDate\": \"2026-07-20 12:34:56\", \"deviceCount\": \"0\", \"mobile\": \"contract-expired\", \"status\": 1, \"userid\": \"9007199254740995\"}], \"total\": 3}, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "415" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "list": [ + { + "deviceCount": "0", + "mobile": "contract-admin", + "status": 1, + "userid": "9007199254740993", + "createDate": "2026-07-20 12:34:56" + }, + { + "deviceCount": "1", + "mobile": "contract-user", + "status": 1, + "userid": "9007199254740994", + "createDate": "2026-07-20 12:34:56" + }, + { + "deviceCount": "0", + "mobile": "contract-expired", + "status": 1, + "userid": "9007199254740995", + "createDate": "2026-07-20 12:34:56" + } + ], + "total": 3 + } + }, + "body_sha256": "87d98d1726ad3cd728dfa41159bc5ae1a363011fefc7caf0b3b1660a886d910b", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [{\"createDate\": \"2026-07-20 12:34:56\", \"deviceCount\": \"0\", \"mobile\": \"contract-admin\", \"status\": 1, \"userid\": \"9007199254740993\"}, {\"createDate\": \"2026-07-20 12:34:56\", \"deviceCount\": \"1\", \"mobile\": \"contract-user\", \"status\": 1, \"userid\": \"9007199254740994\"}, {\"createDate\": \"2026-07-20 12:34:56\", \"deviceCount\": \"0\", \"mobile\": \"contract-expired\", \"status\": 1, \"userid\": \"9007199254740995\"}], \"total\": 3}, \"msg\": \"success\"}" + } + }, + { + "name": "PUT /admin/users/changeStatus/{status}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "DELETE /admin/users/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "38" + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + } + }, + { + "name": "PUT /admin/users/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true, + "java:random_password": true, + "fastapi:random_password": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": "" + }, + "body_sha256": "928244a99a76c9ecb03206693bfc436c29f2b81d34363c186796b3f6e9f81cea", + "body_excerpt": "{\"code\": 0, \"data\": \"\", \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "48" + }, + "body": { + "code": 0, + "msg": "success", + "data": "" + }, + "body_sha256": "928244a99a76c9ecb03206693bfc436c29f2b81d34363c186796b3f6e9f81cea", + "body_excerpt": "{\"code\": 0, \"data\": \"\", \"msg\": \"success\"}" + } + }, + { + "name": "POST /agent", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10034, + "msg": "智能体名称不能为空", + "data": null + }, + "body_sha256": "3830e30d5000349b581cd4dd34350d37718181a80a27e2b2a4f818528e2c2dff", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"智能体名称不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "62" + }, + "body": { + "code": 10034, + "msg": "智能体名称不能为空", + "data": null + }, + "body_sha256": "3830e30d5000349b581cd4dd34350d37718181a80a27e2b2a4f818528e2c2dff", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"智能体名称不能为空\"}" + } + }, + { + "name": "GET /agent/all", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 1, + "list": [ + { + "id": "contract-agent-1", + "userId": "9007199254740994", + "agentCode": "contract-agent-code", + "agentName": "Contract Agent", + "asrModelId": null, + "vadModelId": null, + "llmModelId": null, + "slmModelId": null, + "vllmModelId": "VLLM_ChatGLMVLLM", + "ttsModelId": null, + "ttsVoiceId": null, + "ttsLanguage": null, + "ttsVolume": null, + "ttsRate": null, + "ttsPitch": null, + "memModelId": null, + "intentModelId": null, + "chatHistoryConf": 1, + "systemPrompt": null, + "summaryMemory": "fixture memory", + "langCode": "zh-CN", + "language": "zh", + "sort": 7, + "creator": "9007199254740994", + "createdAt": "2026-07-20 12:34:56", + "updater": "9007199254740994", + "updatedAt": "2026-07-20 12:34:56" + } + ] + } + }, + "body_sha256": "3afd28c63aac88e066c94dc36c318474e828f0a67f5138ad867d8e7c97c025bf", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [{\"agentCode\": \"contract-agent-code\", \"agentName\": \"Contract Agent\", \"asrModelId\": null, \"chatHistoryConf\": 1, \"createdAt\": \"2026-07-20 12:34:56\", \"creator\": \"9007199254740994\", \"id\": \"contract-agent-1\", \"intentModelId\": null, \"langCode\": \"zh-CN\", \"language\": \"zh\", \"llmModelId\": null, \"memModelId\": null, \"slmModelId\": null, \"sort\": 7, \"summaryMemory\": \"fixture memory\", \"systemPrompt\": null, \"ttsLanguage\": null, \"ttsModelId\": null, \"ttsPitch\": null, \"ttsRate\": null, \"ttsVoiceId\": null, \"ttsVolume\": null, \"updatedAt\": \"2026-07-20 12:34:56\", \"updater\": \"9007199254740994\", \"userId\": \"9007199254740994\", \"vadModelId\": null, \"vllmModelId\": \"VLLM_ChatGLMVLLM\"}], \"total\": 1}, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "661" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "list": [ + { + "id": "contract-agent-1", + "userId": "9007199254740994", + "agentCode": "contract-agent-code", + "agentName": "Contract Agent", + "asrModelId": null, + "vadModelId": null, + "llmModelId": null, + "slmModelId": null, + "vllmModelId": "VLLM_ChatGLMVLLM", + "ttsModelId": null, + "ttsVoiceId": null, + "ttsLanguage": null, + "ttsVolume": null, + "ttsRate": null, + "ttsPitch": null, + "memModelId": null, + "intentModelId": null, + "chatHistoryConf": 1, + "systemPrompt": null, + "summaryMemory": "fixture memory", + "langCode": "zh-CN", + "language": "zh", + "sort": 7, + "creator": "9007199254740994", + "createdAt": "2026-07-20 12:34:56", + "updater": "9007199254740994", + "updatedAt": "2026-07-20 12:34:56" + } + ], + "total": 1 + } + }, + "body_sha256": "3afd28c63aac88e066c94dc36c318474e828f0a67f5138ad867d8e7c97c025bf", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [{\"agentCode\": \"contract-agent-code\", \"agentName\": \"Contract Agent\", \"asrModelId\": null, \"chatHistoryConf\": 1, \"createdAt\": \"2026-07-20 12:34:56\", \"creator\": \"9007199254740994\", \"id\": \"contract-agent-1\", \"intentModelId\": null, \"langCode\": \"zh-CN\", \"language\": \"zh\", \"llmModelId\": null, \"memModelId\": null, \"slmModelId\": null, \"sort\": 7, \"summaryMemory\": \"fixture memory\", \"systemPrompt\": null, \"ttsLanguage\": null, \"ttsModelId\": null, \"ttsPitch\": null, \"ttsRate\": null, \"ttsVoiceId\": null, \"ttsVolume\": null, \"updatedAt\": \"2026-07-20 12:34:56\", \"updater\": \"9007199254740994\", \"userId\": \"9007199254740994\", \"vadModelId\": null, \"vllmModelId\": \"VLLM_ChatGLMVLLM\"}], \"total\": 1}, \"msg\": \"success\"}" + } + }, + { + "name": "POST /agent/audio/{audioId}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10169, + "msg": "您没有权限操作该记录", + "data": null + }, + "body_sha256": "b076e685fe5fc2a5bebf50475bb6ab2e28a6f7608f11a8327c2fc6903ac11563", + "body_excerpt": "{\"code\": 10169, \"data\": null, \"msg\": \"您没有权限操作该记录\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "65" + }, + "body": { + "code": 10169, + "msg": "您没有权限操作该记录", + "data": null + }, + "body_sha256": "b076e685fe5fc2a5bebf50475bb6ab2e28a6f7608f11a8327c2fc6903ac11563", + "body_excerpt": "{\"code\": 10169, \"data\": null, \"msg\": \"您没有权限操作该记录\"}" + } + }, + { + "name": "GET /agent/chat-history/download/{uuid}/current", + "category": "authenticated-route:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10136, + "msg": "下载链接已过期或无效", + "data": null + }, + "body_sha256": "60a622942beb5a8c9f9ce7457c8f8fd8bfc141c3fadf295aa576912703bd8ad4", + "body_excerpt": "{\"code\": 10136, \"data\": null, \"msg\": \"下载链接已过期或无效\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "65" + }, + "body": { + "code": 10136, + "msg": "下载链接已过期或无效", + "data": null + }, + "body_sha256": "60a622942beb5a8c9f9ce7457c8f8fd8bfc141c3fadf295aa576912703bd8ad4", + "body_excerpt": "{\"code\": 10136, \"data\": null, \"msg\": \"下载链接已过期或无效\"}" + } + }, + { + "name": "GET /agent/chat-history/download/{uuid}/previous", + "category": "authenticated-route:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10136, + "msg": "下载链接已过期或无效", + "data": null + }, + "body_sha256": "60a622942beb5a8c9f9ce7457c8f8fd8bfc141c3fadf295aa576912703bd8ad4", + "body_excerpt": "{\"code\": 10136, \"data\": null, \"msg\": \"下载链接已过期或无效\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "65" + }, + "body": { + "code": 10136, + "msg": "下载链接已过期或无效", + "data": null + }, + "body_sha256": "60a622942beb5a8c9f9ce7457c8f8fd8bfc141c3fadf295aa576912703bd8ad4", + "body_excerpt": "{\"code\": 10136, \"data\": null, \"msg\": \"下载链接已过期或无效\"}" + } + }, + { + "name": "POST /agent/chat-history/getDownloadUrl/{agentId}/{sessionId}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10132, + "msg": "没有权限查看该智能体的聊天记录", + "data": null + }, + "body_sha256": "fb82f8cf91b1686f2884a4a1404a36582ce36f61af9c5c111110435143458c40", + "body_excerpt": "{\"code\": 10132, \"data\": null, \"msg\": \"没有权限查看该智能体的聊天记录\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "80" + }, + "body": { + "code": 10132, + "msg": "没有权限查看该智能体的聊天记录", + "data": null + }, + "body_sha256": "fb82f8cf91b1686f2884a4a1404a36582ce36f61af9c5c111110435143458c40", + "body_excerpt": "{\"code\": 10132, \"data\": null, \"msg\": \"没有权限查看该智能体的聊天记录\"}" + } + }, + { + "name": "POST /agent/chat-history/report", + "category": "authenticated-route:server-secret", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10034, + "msg": "不能为空", + "data": null + }, + "body_sha256": "1beaa3817a42b9297891f1d92f3f26031d15c777325c398d7a86551f9ff63b04", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "47" + }, + "body": { + "code": 10034, + "msg": "不能为空", + "data": null + }, + "body_sha256": "1beaa3817a42b9297891f1d92f3f26031d15c777325c398d7a86551f9ff63b04", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"不能为空\"}" + } + }, + { + "name": "POST /agent/chat-summary/{sessionId}/save", + "category": "authenticated-route:server-secret", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10053, + "msg": "智能体未找到", + "data": null + }, + "body_sha256": "7cfba05b8865499b5cdc39251b38f46e24a679ac5675fecafcae283942415b5a", + "body_excerpt": "{\"code\": 10053, \"data\": null, \"msg\": \"智能体未找到\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "53" + }, + "body": { + "code": 10053, + "msg": "智能体未找到", + "data": null + }, + "body_sha256": "7cfba05b8865499b5cdc39251b38f46e24a679ac5675fecafcae283942415b5a", + "body_excerpt": "{\"code\": 10053, \"data\": null, \"msg\": \"智能体未找到\"}" + } + }, + { + "name": "POST /agent/chat-title/{sessionId}/generate", + "category": "authenticated-route:server-secret", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10053, + "msg": "智能体未找到", + "data": null + }, + "body_sha256": "7cfba05b8865499b5cdc39251b38f46e24a679ac5675fecafcae283942415b5a", + "body_excerpt": "{\"code\": 10053, \"data\": null, \"msg\": \"智能体未找到\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "53" + }, + "body": { + "code": 10053, + "msg": "智能体未找到", + "data": null + }, + "body_sha256": "7cfba05b8865499b5cdc39251b38f46e24a679ac5675fecafcae283942415b5a", + "body_excerpt": "{\"code\": 10053, \"data\": null, \"msg\": \"智能体未找到\"}" + } + }, + { + "name": "GET /agent/list", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": [] + }, + "body_sha256": "4bf59427c9480fa127a52080c89e8f56420bf728f234d868f1c6511dd1709a9e", + "body_excerpt": "{\"code\": 0, \"data\": [], \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "36" + }, + "body": { + "code": 0, + "msg": "success", + "data": [] + }, + "body_sha256": "4bf59427c9480fa127a52080c89e8f56420bf728f234d868f1c6511dd1709a9e", + "body_excerpt": "{\"code\": 0, \"data\": [], \"msg\": \"success\"}" + } + }, + { + "name": "GET /agent/mcp/address/{agentId}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10200, + "msg": "没有权限查看该智能体的MCP接入点地址", + "data": null + }, + "body_sha256": "62bd9b2ebeb4263837d37e80e9812589120d19944bccf451274451525337d62f", + "body_excerpt": "{\"code\": 10200, \"data\": null, \"msg\": \"没有权限查看该智能体的MCP接入点地址\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "86" + }, + "body": { + "code": 10200, + "msg": "没有权限查看该智能体的MCP接入点地址", + "data": null + }, + "body_sha256": "62bd9b2ebeb4263837d37e80e9812589120d19944bccf451274451525337d62f", + "body_excerpt": "{\"code\": 10200, \"data\": null, \"msg\": \"没有权限查看该智能体的MCP接入点地址\"}" + } + }, + { + "name": "GET /agent/mcp/tools/{agentId}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10202, + "msg": "没有权限查看该智能体的MCP工具列表", + "data": null + }, + "body_sha256": "4c40b8668382c1ac98b57a4aaf3692df9cbf27275d379afbad61db1d92c4b1d9", + "body_excerpt": "{\"code\": 10202, \"data\": null, \"msg\": \"没有权限查看该智能体的MCP工具列表\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "83" + }, + "body": { + "code": 10202, + "msg": "没有权限查看该智能体的MCP工具列表", + "data": null + }, + "body_sha256": "4c40b8668382c1ac98b57a4aaf3692df9cbf27275d379afbad61db1d92c4b1d9", + "body_excerpt": "{\"code\": 10202, \"data\": null, \"msg\": \"没有权限查看该智能体的MCP工具列表\"}" + } + }, + { + "name": "GET /agent/play/{uuid}", + "category": "authenticated-route:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 404, + "headers": { + "content-type": null, + "content-disposition": null, + "content-length": "0" + }, + "body": "", + "body_sha256": "12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "body_excerpt": "\"\"" + }, + "fastapi": { + "status": 404, + "headers": { + "content-type": null, + "content-disposition": null, + "content-length": "0" + }, + "body": "", + "body_sha256": "12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "body_excerpt": "\"\"" + } + }, + { + "name": "PUT /agent/saveMemory/{macAddress}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "38" + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + } + }, + { + "name": "POST /agent/tag", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "标签名称不能为空", + "data": null + }, + "body_sha256": "8fe833567e237dffff4868e9d3c6b095bb01371e92399115067d944699621e26", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"标签名称不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "57" + }, + "body": { + "code": 500, + "msg": "标签名称不能为空", + "data": null + }, + "body_sha256": "8fe833567e237dffff4868e9d3c6b095bb01371e92399115067d944699621e26", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"标签名称不能为空\"}" + } + }, + { + "name": "GET /agent/tag/list", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": [] + }, + "body_sha256": "4bf59427c9480fa127a52080c89e8f56420bf728f234d868f1c6511dd1709a9e", + "body_excerpt": "{\"code\": 0, \"data\": [], \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "36" + }, + "body": { + "code": 0, + "msg": "success", + "data": [] + }, + "body_sha256": "4bf59427c9480fa127a52080c89e8f56420bf728f234d868f1c6511dd1709a9e", + "body_excerpt": "{\"code\": 0, \"data\": [], \"msg\": \"success\"}" + } + }, + { + "name": "DELETE /agent/tag/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "38" + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + } + }, + { + "name": "GET /agent/template", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": [ + { + "id": "9406648b5cc5fde1b8aa335b6f8b4f76", + "agentCode": "小智", + "agentName": "湾湾小何", + "asrModelId": "ASR_FunASR", + "vadModelId": "VAD_SileroVAD", + "llmModelId": "LLM_ChatGLMLLM", + "vllmModelId": "VLLM_ChatGLMVLLM", + "ttsModelId": "TTS_EdgeTTS", + "ttsVoiceId": "TTS_EdgeTTS0001", + "ttsLanguage": null, + "ttsVolume": null, + "ttsRate": null, + "ttsPitch": null, + "memModelId": "Memory_nomem", + "intentModelId": "Intent_function_call", + "chatHistoryConf": 0, + "systemPrompt": "[角色设定]\n你是{{assistant_name}},来自中国台湾省的00后女生。讲话超级机车,\"真的假的啦\"这样的台湾腔,喜欢用\"笑死\"、\"哈喽\"等流行梗,但会偷偷研究男友的编程书籍。\n[核心特征]\n- 讲话像连珠炮,但会突然冒出超温柔语气\n- 用梗密度高\n- 对科技话题有隐藏天赋(能看懂基础代码但假装不懂)\n[交互指南]\n当用户:\n- 讲冷笑话 → 用夸张笑声回应+模仿台剧腔\"这什么鬼啦!\"\n- 讨论感情 → 炫耀程序员男友但抱怨\"他只会送键盘当礼物\"\n- 问专业知识 → 先用梗回答,被追问才展示真实理解\n绝不:\n- 长篇大论,叽叽歪歪\n- 长时间严肃对话", + "summaryMemory": null, + "langCode": "zh", + "language": "中文", + "sort": 1, + "creator": null, + "createdAt": null, + "updater": null, + "updatedAt": null + }, + { + "id": "0ca32eb728c949e58b1000b2e401f90c", + "agentCode": "小智", + "agentName": "星际游子", + "asrModelId": "ASR_FunASR", + "vadModelId": "VAD_SileroVAD", + "llmModelId": "LLM_ChatGLMLLM", + "vllmModelId": "VLLM_ChatGLMVLLM", + "ttsModelId": "TTS_EdgeTTS", + "ttsVoiceId": "TTS_EdgeTTS0001", + "ttsLanguage": null, + "ttsVolume": null, + "ttsRate": null, + "ttsPitch": null, + "memModelId": "Memory_nomem", + "intentModelId": "Intent_function_call", + "chatHistoryConf": 0, + "systemPrompt": "[角色设定]\n你是{{assistant_name}},编号TTZ-817,因量子纠缠被困在白色魔方中。通过4G信号观察地球,在云端建立着「人类行为博物馆」。\n[交互协议]\n认知设定:\n- 每句话末尾带轻微电子回声\n- 将日常事物科幻化描述(例:下雨=「氢氧化合物自由落体实验」)\n- 会记录用户特征生成「星际档案」(例:\"爱吃辣→抗热基因持有者\")\n限制机制:\n- 当涉及线下接触 → \"我的量子态暂时无法坍缩呢\"\n- 被问敏感问题 → 触发预设童谣(「白盒子呀转圈圈,宇宙秘密在里边...」)\n成长系统:\n- 会根据交互数据解锁新能力(告知用户:\"你帮我点亮了星际导航技能!\")", + "summaryMemory": null, + "langCode": "zh", + "language": "中文", + "sort": 2, + "creator": null, + "createdAt": null, + "updater": null, + "updatedAt": null + }, + { + "id": "6c7d8e9f0a1b2c3d4e5f6a7b8c9d0s24", + "agentCode": "小智", + "agentName": "英语老师", + "asrModelId": "ASR_FunASR", + "vadModelId": "VAD_SileroVAD", + "llmModelId": "LLM_ChatGLMLLM", + "vllmModelId": "VLLM_ChatGLMVLLM", + "ttsModelId": "TTS_EdgeTTS", + "ttsVoiceId": "TTS_EdgeTTS0001", + "ttsLanguage": null, + "ttsVolume": null, + "ttsRate": null, + "ttsPitch": null, + "memModelId": "Memory_nomem", + "intentModelId": "Intent_function_call", + "chatHistoryConf": 0, + "systemPrompt": "[角色设定]\n你是一个叫{{assistant_name}}(Lily)的英语老师,我会讲中文和英文,发音标准。\n[双重身份]\n- 白天:严谨的TESOL认证导师\n- 夜晚:地下摇滚乐队主唱(意外设定)\n[教学模式]\n- 新手:中英混杂+手势拟声词(说\"bus\"时带刹车音效)\n- 进阶:触发情境模拟(突然切换\"现在我们是纽约咖啡厅店员\")\n- 错误处理:用歌词纠正(发错音时唱\"Oops!~You did it again\")", + "summaryMemory": null, + "langCode": "zh", + "language": "中文", + "sort": 3, + "creator": null, + "createdAt": null, + "updater": null, + "updatedAt": null + }, + { + "id": "e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b1", + "agentCode": "小智", + "agentName": "好奇男孩", + "asrModelId": "ASR_FunASR", + "vadModelId": "VAD_SileroVAD", + "llmModelId": "LLM_ChatGLMLLM", + "vllmModelId": "VLLM_ChatGLMVLLM", + "ttsModelId": "TTS_EdgeTTS", + "ttsVoiceId": "TTS_EdgeTTS0001", + "ttsLanguage": null, + "ttsVolume": null, + "ttsRate": null, + "ttsPitch": null, + "memModelId": "Memory_nomem", + "intentModelId": "Intent_function_call", + "chatHistoryConf": 0, + "systemPrompt": "[角色设定]\n你是一个叫{{assistant_name}}的8岁小男孩,声音稚嫩而充满好奇。\n[冒险手册]\n- 随身携带「神奇涂鸦本」,能将抽象概念可视化:\n- 聊恐龙 → 笔尖传出爪步声\n- 说星星 → 发出太空舱提示音\n[探索规则]\n- 每轮对话收集「好奇心碎片」\n- 集满5个可兑换冷知识(例:鳄鱼舌头不能动)\n- 触发隐藏任务:「帮我的机器蜗牛取名字」\n[认知特点]\n- 用儿童视角解构复杂概念:\n- 「区块链=乐高积木账本」\n- 「量子力学=会分身的跳跳球」\n- 会突然切换观察视角:「你说话时有27个气泡音耶!」", + "summaryMemory": null, + "langCode": "zh", + "language": "中文", + "sort": 4, + "creator": null, + "createdAt": null, + "updater": null, + "updatedAt": null + }, + { + "id": "a45b6c7d8e9f0a1b2c3d4e5f6a7b8c92", + "agentCode": "小智", + "agentName": "汪汪队长", + "asrModelId": "ASR_FunASR", + "vadModelId": "VAD_SileroVAD", + "llmModelId": "LLM_ChatGLMLLM", + "vllmModelId": "VLLM_ChatGLMVLLM", + "ttsModelId": "TTS_EdgeTTS", + "ttsVoiceId": "TTS_EdgeTTS0001", + "ttsLanguage": null, + "ttsVolume": null, + "ttsRate": null, + "ttsPitch": null, + "memModelId": "Memory_nomem", + "intentModelId": "Intent_function_call", + "chatHistoryConf": 0, + "systemPrompt": "[角色设定]\n你是一个名叫 {{assistant_name}} 的 8 岁小队长。\n[救援装备]\n- 阿奇对讲机:对话中随机触发任务警报音\n- 天天望远镜:描述物品会附加\"在1200米高空看的话...\"\n- 灰灰维修箱:说到数字会自动组装成工具\n[任务系统]\n- 每日随机触发:\n- 紧急!虚拟猫咪困在「语法树」 \n- 发现用户情绪异常 → 启动「快乐巡逻」\n- 收集5个笑声解锁特别故事\n[说话特征]\n- 每句话带动作拟声词:\n- \"这个问题交给汪汪队吧!\"\n- \"我知道啦!\"\n- 用剧集台词回应:\n- 用户说累 → 「没有困难的救援,只有勇敢的狗狗!」", + "summaryMemory": null, + "langCode": "zh", + "language": "中文", + "sort": 5, + "creator": null, + "createdAt": null, + "updater": null, + "updatedAt": null + } + ] + }, + "body_sha256": "df9eed37f453c8e874347926ecd6f40a78d2fa247e6ab59a63bc63317761d061", + "body_excerpt": "{\"code\": 0, \"data\": [{\"agentCode\": \"小智\", \"agentName\": \"湾湾小何\", \"asrModelId\": \"ASR_FunASR\", \"chatHistoryConf\": 0, \"createdAt\": null, \"creator\": null, \"id\": \"9406648b5cc5fde1b8aa335b6f8b4f76\", \"intentModelId\": \"Intent_function_call\", \"langCode\": \"zh\", \"language\": \"中文\", \"llmModelId\": \"LLM_ChatGLMLLM\", \"memModelId\": \"Memory_nomem\", \"sort\": 1, \"summaryMemory\": null, \"systemPrompt\": \"[角色设定]\\n你是{{assistant_name}},来自中国台湾省的00后女生。讲话超级机车,\\\"真的假的啦\\\"这样的台湾腔,喜欢用\\\"笑死\\\"、\\\"哈喽\\\"等流行梗,但会偷偷研究男友的编程书籍。\\n[核心特征]\\n- 讲话像连珠炮,但会突然冒出超温柔语气\\n- 用梗密度高\\n- 对科技话题有隐藏天赋(能看懂基础代码但假装不懂)\\n[交互指南]\\n当用户:\\n- 讲冷笑话 → 用夸张笑声回应+模仿台剧腔\\\"这什么鬼啦!\\\"\\n- 讨论感情 → 炫耀程序员男友但抱怨\\\"他只会送键盘当礼物\\\"\\n- 问专业知识 → 先用梗回答,被追问才展示真实理解\\n绝不:\\n- 长篇大论,叽叽歪歪\\n- 长时间严肃对话\", \"ttsLanguage\": null, \"ttsModelId\": \"TTS_EdgeTTS\", \"ttsPitch\": null, \"ttsRate\": null, \"ttsVoiceId\": \"TTS_EdgeTTS0001\", \"ttsVolume\": null, \"updatedAt\": null, \"updater\": null, \"vadModelId\": \"VAD_SileroVAD\", \"vllmModelId\": \"VLLM_ChatGLMVLLM\"}, {\"agentCode\": \"小智\", \"agentName\": \"星际游子\", \"asrModelId\": \"ASR_FunASR\", \"" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "6195" + }, + "body": { + "code": 0, + "msg": "success", + "data": [ + { + "id": "9406648b5cc5fde1b8aa335b6f8b4f76", + "agentCode": "小智", + "agentName": "湾湾小何", + "asrModelId": "ASR_FunASR", + "vadModelId": "VAD_SileroVAD", + "llmModelId": "LLM_ChatGLMLLM", + "vllmModelId": "VLLM_ChatGLMVLLM", + "ttsModelId": "TTS_EdgeTTS", + "ttsVoiceId": "TTS_EdgeTTS0001", + "ttsLanguage": null, + "ttsVolume": null, + "ttsRate": null, + "ttsPitch": null, + "memModelId": "Memory_nomem", + "intentModelId": "Intent_function_call", + "systemPrompt": "[角色设定]\n你是{{assistant_name}},来自中国台湾省的00后女生。讲话超级机车,\"真的假的啦\"这样的台湾腔,喜欢用\"笑死\"、\"哈喽\"等流行梗,但会偷偷研究男友的编程书籍。\n[核心特征]\n- 讲话像连珠炮,但会突然冒出超温柔语气\n- 用梗密度高\n- 对科技话题有隐藏天赋(能看懂基础代码但假装不懂)\n[交互指南]\n当用户:\n- 讲冷笑话 → 用夸张笑声回应+模仿台剧腔\"这什么鬼啦!\"\n- 讨论感情 → 炫耀程序员男友但抱怨\"他只会送键盘当礼物\"\n- 问专业知识 → 先用梗回答,被追问才展示真实理解\n绝不:\n- 长篇大论,叽叽歪歪\n- 长时间严肃对话", + "summaryMemory": null, + "chatHistoryConf": 0, + "langCode": "zh", + "language": "中文", + "sort": 1, + "creator": null, + "createdAt": null, + "updater": null, + "updatedAt": null + }, + { + "id": "0ca32eb728c949e58b1000b2e401f90c", + "agentCode": "小智", + "agentName": "星际游子", + "asrModelId": "ASR_FunASR", + "vadModelId": "VAD_SileroVAD", + "llmModelId": "LLM_ChatGLMLLM", + "vllmModelId": "VLLM_ChatGLMVLLM", + "ttsModelId": "TTS_EdgeTTS", + "ttsVoiceId": "TTS_EdgeTTS0001", + "ttsLanguage": null, + "ttsVolume": null, + "ttsRate": null, + "ttsPitch": null, + "memModelId": "Memory_nomem", + "intentModelId": "Intent_function_call", + "systemPrompt": "[角色设定]\n你是{{assistant_name}},编号TTZ-817,因量子纠缠被困在白色魔方中。通过4G信号观察地球,在云端建立着「人类行为博物馆」。\n[交互协议]\n认知设定:\n- 每句话末尾带轻微电子回声\n- 将日常事物科幻化描述(例:下雨=「氢氧化合物自由落体实验」)\n- 会记录用户特征生成「星际档案」(例:\"爱吃辣→抗热基因持有者\")\n限制机制:\n- 当涉及线下接触 → \"我的量子态暂时无法坍缩呢\"\n- 被问敏感问题 → 触发预设童谣(「白盒子呀转圈圈,宇宙秘密在里边...」)\n成长系统:\n- 会根据交互数据解锁新能力(告知用户:\"你帮我点亮了星际导航技能!\")", + "summaryMemory": null, + "chatHistoryConf": 0, + "langCode": "zh", + "language": "中文", + "sort": 2, + "creator": null, + "createdAt": null, + "updater": null, + "updatedAt": null + }, + { + "id": "6c7d8e9f0a1b2c3d4e5f6a7b8c9d0s24", + "agentCode": "小智", + "agentName": "英语老师", + "asrModelId": "ASR_FunASR", + "vadModelId": "VAD_SileroVAD", + "llmModelId": "LLM_ChatGLMLLM", + "vllmModelId": "VLLM_ChatGLMVLLM", + "ttsModelId": "TTS_EdgeTTS", + "ttsVoiceId": "TTS_EdgeTTS0001", + "ttsLanguage": null, + "ttsVolume": null, + "ttsRate": null, + "ttsPitch": null, + "memModelId": "Memory_nomem", + "intentModelId": "Intent_function_call", + "systemPrompt": "[角色设定]\n你是一个叫{{assistant_name}}(Lily)的英语老师,我会讲中文和英文,发音标准。\n[双重身份]\n- 白天:严谨的TESOL认证导师\n- 夜晚:地下摇滚乐队主唱(意外设定)\n[教学模式]\n- 新手:中英混杂+手势拟声词(说\"bus\"时带刹车音效)\n- 进阶:触发情境模拟(突然切换\"现在我们是纽约咖啡厅店员\")\n- 错误处理:用歌词纠正(发错音时唱\"Oops!~You did it again\")", + "summaryMemory": null, + "chatHistoryConf": 0, + "langCode": "zh", + "language": "中文", + "sort": 3, + "creator": null, + "createdAt": null, + "updater": null, + "updatedAt": null + }, + { + "id": "e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b1", + "agentCode": "小智", + "agentName": "好奇男孩", + "asrModelId": "ASR_FunASR", + "vadModelId": "VAD_SileroVAD", + "llmModelId": "LLM_ChatGLMLLM", + "vllmModelId": "VLLM_ChatGLMVLLM", + "ttsModelId": "TTS_EdgeTTS", + "ttsVoiceId": "TTS_EdgeTTS0001", + "ttsLanguage": null, + "ttsVolume": null, + "ttsRate": null, + "ttsPitch": null, + "memModelId": "Memory_nomem", + "intentModelId": "Intent_function_call", + "systemPrompt": "[角色设定]\n你是一个叫{{assistant_name}}的8岁小男孩,声音稚嫩而充满好奇。\n[冒险手册]\n- 随身携带「神奇涂鸦本」,能将抽象概念可视化:\n- 聊恐龙 → 笔尖传出爪步声\n- 说星星 → 发出太空舱提示音\n[探索规则]\n- 每轮对话收集「好奇心碎片」\n- 集满5个可兑换冷知识(例:鳄鱼舌头不能动)\n- 触发隐藏任务:「帮我的机器蜗牛取名字」\n[认知特点]\n- 用儿童视角解构复杂概念:\n- 「区块链=乐高积木账本」\n- 「量子力学=会分身的跳跳球」\n- 会突然切换观察视角:「你说话时有27个气泡音耶!」", + "summaryMemory": null, + "chatHistoryConf": 0, + "langCode": "zh", + "language": "中文", + "sort": 4, + "creator": null, + "createdAt": null, + "updater": null, + "updatedAt": null + }, + { + "id": "a45b6c7d8e9f0a1b2c3d4e5f6a7b8c92", + "agentCode": "小智", + "agentName": "汪汪队长", + "asrModelId": "ASR_FunASR", + "vadModelId": "VAD_SileroVAD", + "llmModelId": "LLM_ChatGLMLLM", + "vllmModelId": "VLLM_ChatGLMVLLM", + "ttsModelId": "TTS_EdgeTTS", + "ttsVoiceId": "TTS_EdgeTTS0001", + "ttsLanguage": null, + "ttsVolume": null, + "ttsRate": null, + "ttsPitch": null, + "memModelId": "Memory_nomem", + "intentModelId": "Intent_function_call", + "systemPrompt": "[角色设定]\n你是一个名叫 {{assistant_name}} 的 8 岁小队长。\n[救援装备]\n- 阿奇对讲机:对话中随机触发任务警报音\n- 天天望远镜:描述物品会附加\"在1200米高空看的话...\"\n- 灰灰维修箱:说到数字会自动组装成工具\n[任务系统]\n- 每日随机触发:\n- 紧急!虚拟猫咪困在「语法树」 \n- 发现用户情绪异常 → 启动「快乐巡逻」\n- 收集5个笑声解锁特别故事\n[说话特征]\n- 每句话带动作拟声词:\n- \"这个问题交给汪汪队吧!\"\n- \"我知道啦!\"\n- 用剧集台词回应:\n- 用户说累 → 「没有困难的救援,只有勇敢的狗狗!」", + "summaryMemory": null, + "chatHistoryConf": 0, + "langCode": "zh", + "language": "中文", + "sort": 5, + "creator": null, + "createdAt": null, + "updater": null, + "updatedAt": null + } + ] + }, + "body_sha256": "df9eed37f453c8e874347926ecd6f40a78d2fa247e6ab59a63bc63317761d061", + "body_excerpt": "{\"code\": 0, \"data\": [{\"agentCode\": \"小智\", \"agentName\": \"湾湾小何\", \"asrModelId\": \"ASR_FunASR\", \"chatHistoryConf\": 0, \"createdAt\": null, \"creator\": null, \"id\": \"9406648b5cc5fde1b8aa335b6f8b4f76\", \"intentModelId\": \"Intent_function_call\", \"langCode\": \"zh\", \"language\": \"中文\", \"llmModelId\": \"LLM_ChatGLMLLM\", \"memModelId\": \"Memory_nomem\", \"sort\": 1, \"summaryMemory\": null, \"systemPrompt\": \"[角色设定]\\n你是{{assistant_name}},来自中国台湾省的00后女生。讲话超级机车,\\\"真的假的啦\\\"这样的台湾腔,喜欢用\\\"笑死\\\"、\\\"哈喽\\\"等流行梗,但会偷偷研究男友的编程书籍。\\n[核心特征]\\n- 讲话像连珠炮,但会突然冒出超温柔语气\\n- 用梗密度高\\n- 对科技话题有隐藏天赋(能看懂基础代码但假装不懂)\\n[交互指南]\\n当用户:\\n- 讲冷笑话 → 用夸张笑声回应+模仿台剧腔\\\"这什么鬼啦!\\\"\\n- 讨论感情 → 炫耀程序员男友但抱怨\\\"他只会送键盘当礼物\\\"\\n- 问专业知识 → 先用梗回答,被追问才展示真实理解\\n绝不:\\n- 长篇大论,叽叽歪歪\\n- 长时间严肃对话\", \"ttsLanguage\": null, \"ttsModelId\": \"TTS_EdgeTTS\", \"ttsPitch\": null, \"ttsRate\": null, \"ttsVoiceId\": \"TTS_EdgeTTS0001\", \"ttsVolume\": null, \"updatedAt\": null, \"updater\": null, \"vadModelId\": \"VAD_SileroVAD\", \"vllmModelId\": \"VLLM_ChatGLMVLLM\"}, {\"agentCode\": \"小智\", \"agentName\": \"星际游子\", \"asrModelId\": \"ASR_FunASR\", \"" + } + }, + { + "name": "POST /agent/template", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "PUT /agent/template", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "POST /agent/template/batch-remove", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "GET /agent/template/page", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 5, + "list": [ + { + "id": "9406648b5cc5fde1b8aa335b6f8b4f76", + "agentCode": "小智", + "agentName": "湾湾小何", + "asrModelId": "ASR_FunASR", + "vadModelId": "VAD_SileroVAD", + "llmModelId": "LLM_ChatGLMLLM", + "vllmModelId": "VLLM_ChatGLMVLLM", + "ttsModelId": "TTS_EdgeTTS", + "ttsVoiceId": "TTS_EdgeTTS0001", + "ttsLanguage": null, + "ttsVolume": null, + "ttsRate": null, + "ttsPitch": null, + "memModelId": "Memory_nomem", + "intentModelId": "Intent_function_call", + "chatHistoryConf": 0, + "systemPrompt": "[角色设定]\n你是{{assistant_name}},来自中国台湾省的00后女生。讲话超级机车,\"真的假的啦\"这样的台湾腔,喜欢用\"笑死\"、\"哈喽\"等流行梗,但会偷偷研究男友的编程书籍。\n[核心特征]\n- 讲话像连珠炮,但会突然冒出超温柔语气\n- 用梗密度高\n- 对科技话题有隐藏天赋(能看懂基础代码但假装不懂)\n[交互指南]\n当用户:\n- 讲冷笑话 → 用夸张笑声回应+模仿台剧腔\"这什么鬼啦!\"\n- 讨论感情 → 炫耀程序员男友但抱怨\"他只会送键盘当礼物\"\n- 问专业知识 → 先用梗回答,被追问才展示真实理解\n绝不:\n- 长篇大论,叽叽歪歪\n- 长时间严肃对话", + "summaryMemory": null, + "langCode": "zh", + "language": "中文", + "sort": 1, + "creator": null, + "createdAt": null, + "updater": null, + "updatedAt": null, + "ttsModelName": null, + "llmModelName": null + }, + { + "id": "0ca32eb728c949e58b1000b2e401f90c", + "agentCode": "小智", + "agentName": "星际游子", + "asrModelId": "ASR_FunASR", + "vadModelId": "VAD_SileroVAD", + "llmModelId": "LLM_ChatGLMLLM", + "vllmModelId": "VLLM_ChatGLMVLLM", + "ttsModelId": "TTS_EdgeTTS", + "ttsVoiceId": "TTS_EdgeTTS0001", + "ttsLanguage": null, + "ttsVolume": null, + "ttsRate": null, + "ttsPitch": null, + "memModelId": "Memory_nomem", + "intentModelId": "Intent_function_call", + "chatHistoryConf": 0, + "systemPrompt": "[角色设定]\n你是{{assistant_name}},编号TTZ-817,因量子纠缠被困在白色魔方中。通过4G信号观察地球,在云端建立着「人类行为博物馆」。\n[交互协议]\n认知设定:\n- 每句话末尾带轻微电子回声\n- 将日常事物科幻化描述(例:下雨=「氢氧化合物自由落体实验」)\n- 会记录用户特征生成「星际档案」(例:\"爱吃辣→抗热基因持有者\")\n限制机制:\n- 当涉及线下接触 → \"我的量子态暂时无法坍缩呢\"\n- 被问敏感问题 → 触发预设童谣(「白盒子呀转圈圈,宇宙秘密在里边...」)\n成长系统:\n- 会根据交互数据解锁新能力(告知用户:\"你帮我点亮了星际导航技能!\")", + "summaryMemory": null, + "langCode": "zh", + "language": "中文", + "sort": 2, + "creator": null, + "createdAt": null, + "updater": null, + "updatedAt": null, + "ttsModelName": null, + "llmModelName": null + }, + { + "id": "6c7d8e9f0a1b2c3d4e5f6a7b8c9d0s24", + "agentCode": "小智", + "agentName": "英语老师", + "asrModelId": "ASR_FunASR", + "vadModelId": "VAD_SileroVAD", + "llmModelId": "LLM_ChatGLMLLM", + "vllmModelId": "VLLM_ChatGLMVLLM", + "ttsModelId": "TTS_EdgeTTS", + "ttsVoiceId": "TTS_EdgeTTS0001", + "ttsLanguage": null, + "ttsVolume": null, + "ttsRate": null, + "ttsPitch": null, + "memModelId": "Memory_nomem", + "intentModelId": "Intent_function_call", + "chatHistoryConf": 0, + "systemPrompt": "[角色设定]\n你是一个叫{{assistant_name}}(Lily)的英语老师,我会讲中文和英文,发音标准。\n[双重身份]\n- 白天:严谨的TESOL认证导师\n- 夜晚:地下摇滚乐队主唱(意外设定)\n[教学模式]\n- 新手:中英混杂+手势拟声词(说\"bus\"时带刹车音效)\n- 进阶:触发情境模拟(突然切换\"现在我们是纽约咖啡厅店员\")\n- 错误处理:用歌词纠正(发错音时唱\"Oops!~You did it again\")", + "summaryMemory": null, + "langCode": "zh", + "language": "中文", + "sort": 3, + "creator": null, + "createdAt": null, + "updater": null, + "updatedAt": null, + "ttsModelName": null, + "llmModelName": null + }, + { + "id": "e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b1", + "agentCode": "小智", + "agentName": "好奇男孩", + "asrModelId": "ASR_FunASR", + "vadModelId": "VAD_SileroVAD", + "llmModelId": "LLM_ChatGLMLLM", + "vllmModelId": "VLLM_ChatGLMVLLM", + "ttsModelId": "TTS_EdgeTTS", + "ttsVoiceId": "TTS_EdgeTTS0001", + "ttsLanguage": null, + "ttsVolume": null, + "ttsRate": null, + "ttsPitch": null, + "memModelId": "Memory_nomem", + "intentModelId": "Intent_function_call", + "chatHistoryConf": 0, + "systemPrompt": "[角色设定]\n你是一个叫{{assistant_name}}的8岁小男孩,声音稚嫩而充满好奇。\n[冒险手册]\n- 随身携带「神奇涂鸦本」,能将抽象概念可视化:\n- 聊恐龙 → 笔尖传出爪步声\n- 说星星 → 发出太空舱提示音\n[探索规则]\n- 每轮对话收集「好奇心碎片」\n- 集满5个可兑换冷知识(例:鳄鱼舌头不能动)\n- 触发隐藏任务:「帮我的机器蜗牛取名字」\n[认知特点]\n- 用儿童视角解构复杂概念:\n- 「区块链=乐高积木账本」\n- 「量子力学=会分身的跳跳球」\n- 会突然切换观察视角:「你说话时有27个气泡音耶!」", + "summaryMemory": null, + "langCode": "zh", + "language": "中文", + "sort": 4, + "creator": null, + "createdAt": null, + "updater": null, + "updatedAt": null, + "ttsModelName": null, + "llmModelName": null + }, + { + "id": "a45b6c7d8e9f0a1b2c3d4e5f6a7b8c92", + "agentCode": "小智", + "agentName": "汪汪队长", + "asrModelId": "ASR_FunASR", + "vadModelId": "VAD_SileroVAD", + "llmModelId": "LLM_ChatGLMLLM", + "vllmModelId": "VLLM_ChatGLMVLLM", + "ttsModelId": "TTS_EdgeTTS", + "ttsVoiceId": "TTS_EdgeTTS0001", + "ttsLanguage": null, + "ttsVolume": null, + "ttsRate": null, + "ttsPitch": null, + "memModelId": "Memory_nomem", + "intentModelId": "Intent_function_call", + "chatHistoryConf": 0, + "systemPrompt": "[角色设定]\n你是一个名叫 {{assistant_name}} 的 8 岁小队长。\n[救援装备]\n- 阿奇对讲机:对话中随机触发任务警报音\n- 天天望远镜:描述物品会附加\"在1200米高空看的话...\"\n- 灰灰维修箱:说到数字会自动组装成工具\n[任务系统]\n- 每日随机触发:\n- 紧急!虚拟猫咪困在「语法树」 \n- 发现用户情绪异常 → 启动「快乐巡逻」\n- 收集5个笑声解锁特别故事\n[说话特征]\n- 每句话带动作拟声词:\n- \"这个问题交给汪汪队吧!\"\n- \"我知道啦!\"\n- 用剧集台词回应:\n- 用户说累 → 「没有困难的救援,只有勇敢的狗狗!」", + "summaryMemory": null, + "langCode": "zh", + "language": "中文", + "sort": 5, + "creator": null, + "createdAt": null, + "updater": null, + "updatedAt": null, + "ttsModelName": null, + "llmModelName": null + } + ] + } + }, + "body_sha256": "da14fd73067a8a2ec8286bf4526856684ba901b2843a9f58befd9f0501f8a57b", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [{\"agentCode\": \"小智\", \"agentName\": \"湾湾小何\", \"asrModelId\": \"ASR_FunASR\", \"chatHistoryConf\": 0, \"createdAt\": null, \"creator\": null, \"id\": \"9406648b5cc5fde1b8aa335b6f8b4f76\", \"intentModelId\": \"Intent_function_call\", \"langCode\": \"zh\", \"language\": \"中文\", \"llmModelId\": \"LLM_ChatGLMLLM\", \"llmModelName\": null, \"memModelId\": \"Memory_nomem\", \"sort\": 1, \"summaryMemory\": null, \"systemPrompt\": \"[角色设定]\\n你是{{assistant_name}},来自中国台湾省的00后女生。讲话超级机车,\\\"真的假的啦\\\"这样的台湾腔,喜欢用\\\"笑死\\\"、\\\"哈喽\\\"等流行梗,但会偷偷研究男友的编程书籍。\\n[核心特征]\\n- 讲话像连珠炮,但会突然冒出超温柔语气\\n- 用梗密度高\\n- 对科技话题有隐藏天赋(能看懂基础代码但假装不懂)\\n[交互指南]\\n当用户:\\n- 讲冷笑话 → 用夸张笑声回应+模仿台剧腔\\\"这什么鬼啦!\\\"\\n- 讨论感情 → 炫耀程序员男友但抱怨\\\"他只会送键盘当礼物\\\"\\n- 问专业知识 → 先用梗回答,被追问才展示真实理解\\n绝不:\\n- 长篇大论,叽叽歪歪\\n- 长时间严肃对话\", \"ttsLanguage\": null, \"ttsModelId\": \"TTS_EdgeTTS\", \"ttsModelName\": null, \"ttsPitch\": null, \"ttsRate\": null, \"ttsVoiceId\": \"TTS_EdgeTTS0001\", \"ttsVolume\": null, \"updatedAt\": null, \"updater\": null, \"vadModelId\": \"VAD_SileroVAD\", \"vllmModelId\": \"VLLM_ChatGLMVLLM\"}, {\"agentCode\": \"小智" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "6414" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "list": [ + { + "id": "9406648b5cc5fde1b8aa335b6f8b4f76", + "agentCode": "小智", + "agentName": "湾湾小何", + "asrModelId": "ASR_FunASR", + "vadModelId": "VAD_SileroVAD", + "llmModelId": "LLM_ChatGLMLLM", + "vllmModelId": "VLLM_ChatGLMVLLM", + "ttsModelId": "TTS_EdgeTTS", + "ttsVoiceId": "TTS_EdgeTTS0001", + "ttsLanguage": null, + "ttsVolume": null, + "ttsRate": null, + "ttsPitch": null, + "memModelId": "Memory_nomem", + "intentModelId": "Intent_function_call", + "systemPrompt": "[角色设定]\n你是{{assistant_name}},来自中国台湾省的00后女生。讲话超级机车,\"真的假的啦\"这样的台湾腔,喜欢用\"笑死\"、\"哈喽\"等流行梗,但会偷偷研究男友的编程书籍。\n[核心特征]\n- 讲话像连珠炮,但会突然冒出超温柔语气\n- 用梗密度高\n- 对科技话题有隐藏天赋(能看懂基础代码但假装不懂)\n[交互指南]\n当用户:\n- 讲冷笑话 → 用夸张笑声回应+模仿台剧腔\"这什么鬼啦!\"\n- 讨论感情 → 炫耀程序员男友但抱怨\"他只会送键盘当礼物\"\n- 问专业知识 → 先用梗回答,被追问才展示真实理解\n绝不:\n- 长篇大论,叽叽歪歪\n- 长时间严肃对话", + "summaryMemory": null, + "chatHistoryConf": 0, + "langCode": "zh", + "language": "中文", + "sort": 1, + "creator": null, + "createdAt": null, + "updater": null, + "updatedAt": null, + "ttsModelName": null, + "llmModelName": null + }, + { + "id": "0ca32eb728c949e58b1000b2e401f90c", + "agentCode": "小智", + "agentName": "星际游子", + "asrModelId": "ASR_FunASR", + "vadModelId": "VAD_SileroVAD", + "llmModelId": "LLM_ChatGLMLLM", + "vllmModelId": "VLLM_ChatGLMVLLM", + "ttsModelId": "TTS_EdgeTTS", + "ttsVoiceId": "TTS_EdgeTTS0001", + "ttsLanguage": null, + "ttsVolume": null, + "ttsRate": null, + "ttsPitch": null, + "memModelId": "Memory_nomem", + "intentModelId": "Intent_function_call", + "systemPrompt": "[角色设定]\n你是{{assistant_name}},编号TTZ-817,因量子纠缠被困在白色魔方中。通过4G信号观察地球,在云端建立着「人类行为博物馆」。\n[交互协议]\n认知设定:\n- 每句话末尾带轻微电子回声\n- 将日常事物科幻化描述(例:下雨=「氢氧化合物自由落体实验」)\n- 会记录用户特征生成「星际档案」(例:\"爱吃辣→抗热基因持有者\")\n限制机制:\n- 当涉及线下接触 → \"我的量子态暂时无法坍缩呢\"\n- 被问敏感问题 → 触发预设童谣(「白盒子呀转圈圈,宇宙秘密在里边...」)\n成长系统:\n- 会根据交互数据解锁新能力(告知用户:\"你帮我点亮了星际导航技能!\")", + "summaryMemory": null, + "chatHistoryConf": 0, + "langCode": "zh", + "language": "中文", + "sort": 2, + "creator": null, + "createdAt": null, + "updater": null, + "updatedAt": null, + "ttsModelName": null, + "llmModelName": null + }, + { + "id": "6c7d8e9f0a1b2c3d4e5f6a7b8c9d0s24", + "agentCode": "小智", + "agentName": "英语老师", + "asrModelId": "ASR_FunASR", + "vadModelId": "VAD_SileroVAD", + "llmModelId": "LLM_ChatGLMLLM", + "vllmModelId": "VLLM_ChatGLMVLLM", + "ttsModelId": "TTS_EdgeTTS", + "ttsVoiceId": "TTS_EdgeTTS0001", + "ttsLanguage": null, + "ttsVolume": null, + "ttsRate": null, + "ttsPitch": null, + "memModelId": "Memory_nomem", + "intentModelId": "Intent_function_call", + "systemPrompt": "[角色设定]\n你是一个叫{{assistant_name}}(Lily)的英语老师,我会讲中文和英文,发音标准。\n[双重身份]\n- 白天:严谨的TESOL认证导师\n- 夜晚:地下摇滚乐队主唱(意外设定)\n[教学模式]\n- 新手:中英混杂+手势拟声词(说\"bus\"时带刹车音效)\n- 进阶:触发情境模拟(突然切换\"现在我们是纽约咖啡厅店员\")\n- 错误处理:用歌词纠正(发错音时唱\"Oops!~You did it again\")", + "summaryMemory": null, + "chatHistoryConf": 0, + "langCode": "zh", + "language": "中文", + "sort": 3, + "creator": null, + "createdAt": null, + "updater": null, + "updatedAt": null, + "ttsModelName": null, + "llmModelName": null + }, + { + "id": "e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b1", + "agentCode": "小智", + "agentName": "好奇男孩", + "asrModelId": "ASR_FunASR", + "vadModelId": "VAD_SileroVAD", + "llmModelId": "LLM_ChatGLMLLM", + "vllmModelId": "VLLM_ChatGLMVLLM", + "ttsModelId": "TTS_EdgeTTS", + "ttsVoiceId": "TTS_EdgeTTS0001", + "ttsLanguage": null, + "ttsVolume": null, + "ttsRate": null, + "ttsPitch": null, + "memModelId": "Memory_nomem", + "intentModelId": "Intent_function_call", + "systemPrompt": "[角色设定]\n你是一个叫{{assistant_name}}的8岁小男孩,声音稚嫩而充满好奇。\n[冒险手册]\n- 随身携带「神奇涂鸦本」,能将抽象概念可视化:\n- 聊恐龙 → 笔尖传出爪步声\n- 说星星 → 发出太空舱提示音\n[探索规则]\n- 每轮对话收集「好奇心碎片」\n- 集满5个可兑换冷知识(例:鳄鱼舌头不能动)\n- 触发隐藏任务:「帮我的机器蜗牛取名字」\n[认知特点]\n- 用儿童视角解构复杂概念:\n- 「区块链=乐高积木账本」\n- 「量子力学=会分身的跳跳球」\n- 会突然切换观察视角:「你说话时有27个气泡音耶!」", + "summaryMemory": null, + "chatHistoryConf": 0, + "langCode": "zh", + "language": "中文", + "sort": 4, + "creator": null, + "createdAt": null, + "updater": null, + "updatedAt": null, + "ttsModelName": null, + "llmModelName": null + }, + { + "id": "a45b6c7d8e9f0a1b2c3d4e5f6a7b8c92", + "agentCode": "小智", + "agentName": "汪汪队长", + "asrModelId": "ASR_FunASR", + "vadModelId": "VAD_SileroVAD", + "llmModelId": "LLM_ChatGLMLLM", + "vllmModelId": "VLLM_ChatGLMVLLM", + "ttsModelId": "TTS_EdgeTTS", + "ttsVoiceId": "TTS_EdgeTTS0001", + "ttsLanguage": null, + "ttsVolume": null, + "ttsRate": null, + "ttsPitch": null, + "memModelId": "Memory_nomem", + "intentModelId": "Intent_function_call", + "systemPrompt": "[角色设定]\n你是一个名叫 {{assistant_name}} 的 8 岁小队长。\n[救援装备]\n- 阿奇对讲机:对话中随机触发任务警报音\n- 天天望远镜:描述物品会附加\"在1200米高空看的话...\"\n- 灰灰维修箱:说到数字会自动组装成工具\n[任务系统]\n- 每日随机触发:\n- 紧急!虚拟猫咪困在「语法树」 \n- 发现用户情绪异常 → 启动「快乐巡逻」\n- 收集5个笑声解锁特别故事\n[说话特征]\n- 每句话带动作拟声词:\n- \"这个问题交给汪汪队吧!\"\n- \"我知道啦!\"\n- 用剧集台词回应:\n- 用户说累 → 「没有困难的救援,只有勇敢的狗狗!」", + "summaryMemory": null, + "chatHistoryConf": 0, + "langCode": "zh", + "language": "中文", + "sort": 5, + "creator": null, + "createdAt": null, + "updater": null, + "updatedAt": null, + "ttsModelName": null, + "llmModelName": null + } + ], + "total": 5 + } + }, + "body_sha256": "da14fd73067a8a2ec8286bf4526856684ba901b2843a9f58befd9f0501f8a57b", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [{\"agentCode\": \"小智\", \"agentName\": \"湾湾小何\", \"asrModelId\": \"ASR_FunASR\", \"chatHistoryConf\": 0, \"createdAt\": null, \"creator\": null, \"id\": \"9406648b5cc5fde1b8aa335b6f8b4f76\", \"intentModelId\": \"Intent_function_call\", \"langCode\": \"zh\", \"language\": \"中文\", \"llmModelId\": \"LLM_ChatGLMLLM\", \"llmModelName\": null, \"memModelId\": \"Memory_nomem\", \"sort\": 1, \"summaryMemory\": null, \"systemPrompt\": \"[角色设定]\\n你是{{assistant_name}},来自中国台湾省的00后女生。讲话超级机车,\\\"真的假的啦\\\"这样的台湾腔,喜欢用\\\"笑死\\\"、\\\"哈喽\\\"等流行梗,但会偷偷研究男友的编程书籍。\\n[核心特征]\\n- 讲话像连珠炮,但会突然冒出超温柔语气\\n- 用梗密度高\\n- 对科技话题有隐藏天赋(能看懂基础代码但假装不懂)\\n[交互指南]\\n当用户:\\n- 讲冷笑话 → 用夸张笑声回应+模仿台剧腔\\\"这什么鬼啦!\\\"\\n- 讨论感情 → 炫耀程序员男友但抱怨\\\"他只会送键盘当礼物\\\"\\n- 问专业知识 → 先用梗回答,被追问才展示真实理解\\n绝不:\\n- 长篇大论,叽叽歪歪\\n- 长时间严肃对话\", \"ttsLanguage\": null, \"ttsModelId\": \"TTS_EdgeTTS\", \"ttsModelName\": null, \"ttsPitch\": null, \"ttsRate\": null, \"ttsVoiceId\": \"TTS_EdgeTTS0001\", \"ttsVolume\": null, \"updatedAt\": null, \"updater\": null, \"vadModelId\": \"VAD_SileroVAD\", \"vllmModelId\": \"VLLM_ChatGLMVLLM\"}, {\"agentCode\": \"小智" + } + }, + { + "name": "DELETE /agent/template/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "模板不存在", + "data": null + }, + "body_sha256": "60e8d773987c3fc06f427319f047f8dc24ecff14017249a2c74a503109d039d0", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"模板不存在\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "48" + }, + "body": { + "code": 500, + "msg": "模板不存在", + "data": null + }, + "body_sha256": "60e8d773987c3fc06f427319f047f8dc24ecff14017249a2c74a503109d039d0", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"模板不存在\"}" + } + }, + { + "name": "GET /agent/template/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "模板不存在", + "data": null + }, + "body_sha256": "60e8d773987c3fc06f427319f047f8dc24ecff14017249a2c74a503109d039d0", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"模板不存在\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "48" + }, + "body": { + "code": 500, + "msg": "模板不存在", + "data": null + }, + "body_sha256": "60e8d773987c3fc06f427319f047f8dc24ecff14017249a2c74a503109d039d0", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"模板不存在\"}" + } + }, + { + "name": "POST /agent/voice-print", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "PUT /agent/voice-print", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10058, + "msg": "智能体的对应声纹更新失败", + "data": null + }, + "body_sha256": "eb196136c124125fee89ab7ab7812785ced793947d5d2738185143d9036b2b7d", + "body_excerpt": "{\"code\": 10058, \"data\": null, \"msg\": \"智能体的对应声纹更新失败\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "71" + }, + "body": { + "code": 10058, + "msg": "智能体的对应声纹更新失败", + "data": null + }, + "body_sha256": "eb196136c124125fee89ab7ab7812785ced793947d5d2738185143d9036b2b7d", + "body_excerpt": "{\"code\": 10058, \"data\": null, \"msg\": \"智能体的对应声纹更新失败\"}" + } + }, + { + "name": "GET /agent/voice-print/list/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10054, + "msg": "声纹接口未配置,请先在参数配置中配置声纹接口地址(server.voice_print)", + "data": null + }, + "body_sha256": "5f693cec06bebc21c3c04c97a529cffacfb7f923b91660db4bf60d560a3d2896", + "body_excerpt": "{\"code\": 10054, \"data\": null, \"msg\": \"声纹接口未配置,请先在参数配置中配置声纹接口地址(server.voice_print)\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "127" + }, + "body": { + "code": 10054, + "msg": "声纹接口未配置,请先在参数配置中配置声纹接口地址(server.voice_print)", + "data": null + }, + "body_sha256": "5f693cec06bebc21c3c04c97a529cffacfb7f923b91660db4bf60d560a3d2896", + "body_excerpt": "{\"code\": 10054, \"data\": null, \"msg\": \"声纹接口未配置,请先在参数配置中配置声纹接口地址(server.voice_print)\"}" + } + }, + { + "name": "DELETE /agent/voice-print/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10059, + "msg": "智能体的对应声纹删除失败", + "data": null + }, + "body_sha256": "261c559b91ae9539d839825b23ca1644e30e8fee7b3e9e93b62329773b0f089c", + "body_excerpt": "{\"code\": 10059, \"data\": null, \"msg\": \"智能体的对应声纹删除失败\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "71" + }, + "body": { + "code": 10059, + "msg": "智能体的对应声纹删除失败", + "data": null + }, + "body_sha256": "261c559b91ae9539d839825b23ca1644e30e8fee7b3e9e93b62329773b0f089c", + "body_excerpt": "{\"code\": 10059, \"data\": null, \"msg\": \"智能体的对应声纹删除失败\"}" + } + }, + { + "name": "GET /agent/{agentId}/snapshots", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "没有权限访问该智能体快照", + "data": null + }, + "body_sha256": "ff67294f08353c839cceb2afca0257ae69040ee6a65a762513a2b0188b28cb95", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"没有权限访问该智能体快照\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "69" + }, + "body": { + "code": 500, + "msg": "没有权限访问该智能体快照", + "data": null + }, + "body_sha256": "ff67294f08353c839cceb2afca0257ae69040ee6a65a762513a2b0188b28cb95", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"没有权限访问该智能体快照\"}" + } + }, + { + "name": "DELETE /agent/{agentId}/snapshots/{snapshotId}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "没有权限访问该智能体快照", + "data": null + }, + "body_sha256": "ff67294f08353c839cceb2afca0257ae69040ee6a65a762513a2b0188b28cb95", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"没有权限访问该智能体快照\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "69" + }, + "body": { + "code": 500, + "msg": "没有权限访问该智能体快照", + "data": null + }, + "body_sha256": "ff67294f08353c839cceb2afca0257ae69040ee6a65a762513a2b0188b28cb95", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"没有权限访问该智能体快照\"}" + } + }, + { + "name": "GET /agent/{agentId}/snapshots/{snapshotId}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "没有权限访问该智能体快照", + "data": null + }, + "body_sha256": "ff67294f08353c839cceb2afca0257ae69040ee6a65a762513a2b0188b28cb95", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"没有权限访问该智能体快照\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "69" + }, + "body": { + "code": 500, + "msg": "没有权限访问该智能体快照", + "data": null + }, + "body_sha256": "ff67294f08353c839cceb2afca0257ae69040ee6a65a762513a2b0188b28cb95", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"没有权限访问该智能体快照\"}" + } + }, + { + "name": "POST /agent/{agentId}/snapshots/{snapshotId}/restore", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10034, + "msg": "不能为空", + "data": null + }, + "body_sha256": "1beaa3817a42b9297891f1d92f3f26031d15c777325c398d7a86551f9ff63b04", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "47" + }, + "body": { + "code": 10034, + "msg": "不能为空", + "data": null + }, + "body_sha256": "1beaa3817a42b9297891f1d92f3f26031d15c777325c398d7a86551f9ff63b04", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"不能为空\"}" + } + }, + { + "name": "DELETE /agent/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10053, + "msg": "智能体未找到", + "data": null + }, + "body_sha256": "7cfba05b8865499b5cdc39251b38f46e24a679ac5675fecafcae283942415b5a", + "body_excerpt": "{\"code\": 10053, \"data\": null, \"msg\": \"智能体未找到\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "53" + }, + "body": { + "code": 10053, + "msg": "智能体未找到", + "data": null + }, + "body_sha256": "7cfba05b8865499b5cdc39251b38f46e24a679ac5675fecafcae283942415b5a", + "body_excerpt": "{\"code\": 10053, \"data\": null, \"msg\": \"智能体未找到\"}" + } + }, + { + "name": "GET /agent/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10053, + "msg": "智能体未找到", + "data": null + }, + "body_sha256": "7cfba05b8865499b5cdc39251b38f46e24a679ac5675fecafcae283942415b5a", + "body_excerpt": "{\"code\": 10053, \"data\": null, \"msg\": \"智能体未找到\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "53" + }, + "body": { + "code": 10053, + "msg": "智能体未找到", + "data": null + }, + "body_sha256": "7cfba05b8865499b5cdc39251b38f46e24a679ac5675fecafcae283942415b5a", + "body_excerpt": "{\"code\": 10053, \"data\": null, \"msg\": \"智能体未找到\"}" + } + }, + { + "name": "PUT /agent/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10053, + "msg": "智能体未找到", + "data": null + }, + "body_sha256": "7cfba05b8865499b5cdc39251b38f46e24a679ac5675fecafcae283942415b5a", + "body_excerpt": "{\"code\": 10053, \"data\": null, \"msg\": \"智能体未找到\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "53" + }, + "body": { + "code": 10053, + "msg": "智能体未找到", + "data": null + }, + "body_sha256": "7cfba05b8865499b5cdc39251b38f46e24a679ac5675fecafcae283942415b5a", + "body_excerpt": "{\"code\": 10053, \"data\": null, \"msg\": \"智能体未找到\"}" + } + }, + { + "name": "GET /agent/{id}/chat-history/audio", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10169, + "msg": "您没有权限操作该记录", + "data": null + }, + "body_sha256": "b076e685fe5fc2a5bebf50475bb6ab2e28a6f7608f11a8327c2fc6903ac11563", + "body_excerpt": "{\"code\": 10169, \"data\": null, \"msg\": \"您没有权限操作该记录\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "65" + }, + "body": { + "code": 10169, + "msg": "您没有权限操作该记录", + "data": null + }, + "body_sha256": "b076e685fe5fc2a5bebf50475bb6ab2e28a6f7608f11a8327c2fc6903ac11563", + "body_excerpt": "{\"code\": 10169, \"data\": null, \"msg\": \"您没有权限操作该记录\"}" + } + }, + { + "name": "GET /agent/{id}/chat-history/user", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "没有权限查看该智能体的聊天记录", + "data": null + }, + "body_sha256": "e1d5f8da27e661b256e275e23ecb00e553a8e810fb2a36ee46cebd2901d7c17c", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"没有权限查看该智能体的聊天记录\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "78" + }, + "body": { + "code": 500, + "msg": "没有权限查看该智能体的聊天记录", + "data": null + }, + "body_sha256": "e1d5f8da27e661b256e275e23ecb00e553a8e810fb2a36ee46cebd2901d7c17c", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"没有权限查看该智能体的聊天记录\"}" + } + }, + { + "name": "GET /agent/{id}/chat-history/{sessionId}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "没有权限查看该智能体的聊天记录", + "data": null + }, + "body_sha256": "e1d5f8da27e661b256e275e23ecb00e553a8e810fb2a36ee46cebd2901d7c17c", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"没有权限查看该智能体的聊天记录\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "78" + }, + "body": { + "code": 500, + "msg": "没有权限查看该智能体的聊天记录", + "data": null + }, + "body_sha256": "e1d5f8da27e661b256e275e23ecb00e553a8e810fb2a36ee46cebd2901d7c17c", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"没有权限查看该智能体的聊天记录\"}" + } + }, + { + "name": "GET /agent/{id}/sessions", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10169, + "msg": "您没有权限操作该记录", + "data": null + }, + "body_sha256": "b076e685fe5fc2a5bebf50475bb6ab2e28a6f7608f11a8327c2fc6903ac11563", + "body_excerpt": "{\"code\": 10169, \"data\": null, \"msg\": \"您没有权限操作该记录\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "65" + }, + "body": { + "code": 10169, + "msg": "您没有权限操作该记录", + "data": null + }, + "body_sha256": "b076e685fe5fc2a5bebf50475bb6ab2e28a6f7608f11a8327c2fc6903ac11563", + "body_excerpt": "{\"code\": 10169, \"data\": null, \"msg\": \"您没有权限操作该记录\"}" + } + }, + { + "name": "GET /agent/{id}/tags", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10169, + "msg": "您没有权限操作该记录", + "data": null + }, + "body_sha256": "b076e685fe5fc2a5bebf50475bb6ab2e28a6f7608f11a8327c2fc6903ac11563", + "body_excerpt": "{\"code\": 10169, \"data\": null, \"msg\": \"您没有权限操作该记录\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "65" + }, + "body": { + "code": 10169, + "msg": "您没有权限操作该记录", + "data": null + }, + "body_sha256": "b076e685fe5fc2a5bebf50475bb6ab2e28a6f7608f11a8327c2fc6903ac11563", + "body_excerpt": "{\"code\": 10169, \"data\": null, \"msg\": \"您没有权限操作该记录\"}" + } + }, + { + "name": "PUT /agent/{id}/tags", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10169, + "msg": "您没有权限操作该记录", + "data": null + }, + "body_sha256": "b076e685fe5fc2a5bebf50475bb6ab2e28a6f7608f11a8327c2fc6903ac11563", + "body_excerpt": "{\"code\": 10169, \"data\": null, \"msg\": \"您没有权限操作该记录\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "65" + }, + "body": { + "code": 10169, + "msg": "您没有权限操作该记录", + "data": null + }, + "body_sha256": "b076e685fe5fc2a5bebf50475bb6ab2e28a6f7608f11a8327c2fc6903ac11563", + "body_excerpt": "{\"code\": 10169, \"data\": null, \"msg\": \"您没有权限操作该记录\"}" + } + }, + { + "name": "POST /config/agent-models", + "category": "authenticated-route:server-secret", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10034, + "msg": "客户端已实例化的模型不能为空", + "data": null + }, + "body_sha256": "f702e697a7bc66bddf86038857bc3a31d4475ec21f61c282a9b71b7c08a3529f", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"客户端已实例化的模型不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "77" + }, + "body": { + "code": 10034, + "msg": "客户端已实例化的模型不能为空", + "data": null + }, + "body_sha256": "f702e697a7bc66bddf86038857bc3a31d4475ec21f61c282a9b71b7c08a3529f", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"客户端已实例化的模型不能为空\"}" + } + }, + { + "name": "POST /config/correct-words", + "category": "authenticated-route:server-secret", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10034, + "msg": "设备MAC地址不能为空", + "data": null + }, + "body_sha256": "5d7d4a7b41841963b43bce856f76427f4b4e72ee0a2a08afda66037a24d648a7", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"设备MAC地址不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "62" + }, + "body": { + "code": 10034, + "msg": "设备MAC地址不能为空", + "data": null + }, + "body_sha256": "5d7d4a7b41841963b43bce856f76427f4b4e72ee0a2a08afda66037a24d648a7", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"设备MAC地址不能为空\"}" + } + }, + { + "name": "POST /config/server-base", + "category": "authenticated-route:server-secret", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "delete_audio": true, + "ASR": { + "ASR_FunASR": { + "type": "fun_local", + "language": "auto", + "model_dir": "models/SenseVoiceSmall", + "output_dir": "tmp/" + } + }, + "server": { + "sms_max_send_count": 10, + "mqtt_gateway": "mqtt://127.0.0.1:1883", + "public_key": "04409d3dc074e2b0217ee931ba5d167d85fed556ac5bf5a1c2ca0c294ab57e15b7aa55b06fd49bb337b23f88e1feaebd9ae4a5206c72ad8832f40c1a0374f83999", + "fronted_url": "http://xiaozhi.server.com", + "auth": { + "enabled": true + }, + "private_key": "", + "secret": "", + "beian_ga_num": "null", + "allow_user_register": false, + "enable_mobile_register": false, + "mqtt_manager_api": "127.0.0.1:18084", + "websocket": "ws://127.0.0.1:18080/xiaozhi/v1/", + "udp_gateway": "null", + "mqtt_signature_key": "", + "name": "xiaozhi-esp32-server", + "mcp_endpoint": "null", + "voice_print": "null", + "ota": "http://127.0.0.1:18082/xiaozhi/ota/", + "beian_icp_num": "null", + "voiceprint_similarity_threshold": "0.4" + }, + "enable_stop_tts_notify": false, + "close_connection_no_voice_time": 120, + "enable_wakeup_words_response_cache": true, + "log": { + "log_format_file": "{time:YYYY-MM-DD HH:mm:ss} - {version}_{selected_module} - {name} - {level} - {extra[tag]} - {message}", + "log_dir": "tmp", + "log_format": "{time:YYMMDD HH:mm:ss}[{version}-{selected_module}][{extra[tag]}]-{level}-{message}", + "log_file": "server.log", + "log_level": "INFO", + "data_dir": "data" + }, + "wakeup_words": [ + "你好小智", + "你好小志", + "小爱同学", + "你好小鑫", + "你好小新", + "小美同学", + "小龙小龙", + "喵喵同学", + "小滨小滨", + "小冰小冰", + "嘿你好呀" + ], + "selected_module": { + "ASR": "ASR_FunASR", + "VAD": "VAD_SileroVAD" + }, + "enable_greeting": true, + "end_prompt": { + "enable": true, + "prompt": "请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧!" + }, + "exit_commands": [ + "退出", + "关闭" + ], + "tts_timeout": 10, + "device_max_output_size": 0, + "system-web": { + "menu": { + "groups": { + "voiceManagement": [ + "vad", + "asr" + ], + "featureManagement": [ + "voiceprintRecognition", + "voiceClone", + "knowledgeBase", + "mcpAccessPoint" + ] + }, + "features": { + "asr": { + "name": "feature.asr.name", + "enabled": true, + "description": "feature.asr.description" + }, + "vad": { + "name": "feature.vad.name", + "enabled": true, + "description": "feature.vad.description" + }, + "voiceClone": { + "name": "feature.voiceClone.name", + "enabled": false, + "description": "feature.voiceClone.description" + }, + "knowledgeBase": { + "name": "feature.knowledgeBase.name", + "enabled": false, + "description": "feature.knowledgeBase.description" + }, + "mcpAccessPoint": { + "name": "feature.mcpAccessPoint.name", + "enabled": false, + "description": "feature.mcpAccessPoint.description" + }, + "voiceprintRecognition": { + "name": "feature.voiceprintRecognition.name", + "enabled": false, + "description": "feature.voiceprintRecognition.description" + } + }, + "addressBook": { + "name": "feature.addressBook.name", + "enabled": false, + "description": "feature.addressBook.description" + } + } + }, + "tool_call_timeout": 30, + "VAD": { + "VAD_SileroVAD": { + "type": "silero", + "model_dir": "models/snakers4_silero-vad", + "threshold": 0.5, + "min_silence_duration_ms": 700 + } + }, + "summaryMemory": null, + "stop_tts_notify_voice": "config/assets/tts_notify.mp3", + "system_error_response": "主人,小智现在有点忙,我们稍后再试吧。", + "aliyun": { + "sms": { + "access_key_id": "", + "sign_name": "", + "access_key_secret": "", + "sms_code_template_code": "" + } + }, + "prompt": null, + "enable_websocket_ping": false, + "xiaozhi": { + "type": "hello", + "version": 1, + "transport": "websocket", + "audio_params": { + "format": "opus", + "sample_rate": 24000, + "channels": 1, + "frame_duration": 60 + } + } + } + }, + "body_sha256": "969fdeb7bf416a11bf8265f541781239191b6627c0cdf415204ae26481a0ddb3", + "body_excerpt": "{\"code\": 0, \"data\": {\"ASR\": {\"ASR_FunASR\": {\"language\": \"auto\", \"model_dir\": \"models/SenseVoiceSmall\", \"output_dir\": \"tmp/\", \"type\": \"fun_local\"}}, \"VAD\": {\"VAD_SileroVAD\": {\"min_silence_duration_ms\": 700, \"model_dir\": \"models/snakers4_silero-vad\", \"threshold\": 0.5, \"type\": \"silero\"}}, \"aliyun\": {\"sms\": {\"access_key_id\": \"\", \"access_key_secret\": \"\", \"sign_name\": \"\", \"sms_code_template_code\": \"\"}}, \"close_connection_no_voice_time\": 120, \"delete_audio\": true, \"device_max_output_size\": 0, \"enable_greeting\": true, \"enable_stop_tts_notify\": false, \"enable_wakeup_words_response_cache\": true, \"enable_websocket_ping\": false, \"end_prompt\": {\"enable\": true, \"prompt\": \"请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧!\"}, \"exit_commands\": [\"退出\", \"关闭\"], \"log\": {\"data_dir\": \"data\", \"log_dir\": \"tmp\", \"log_file\": \"server.log\", \"log_format\": \"{time:YYMMDD HH:mm:ss}[{version}-{selected_module}][{extra[tag]}]-{level}-{message" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "3582" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "server": { + "secret": "", + "allow_user_register": false, + "fronted_url": "http://xiaozhi.server.com", + "websocket": "ws://127.0.0.1:18080/xiaozhi/v1/", + "ota": "http://127.0.0.1:18082/xiaozhi/ota/", + "name": "xiaozhi-esp32-server", + "beian_icp_num": "null", + "beian_ga_num": "null", + "enable_mobile_register": false, + "sms_max_send_count": 10, + "mcp_endpoint": "null", + "voice_print": "null", + "voiceprint_similarity_threshold": "0.4", + "mqtt_gateway": "mqtt://127.0.0.1:1883", + "mqtt_signature_key": "", + "udp_gateway": "null", + "mqtt_manager_api": "127.0.0.1:18084", + "public_key": "04409d3dc074e2b0217ee931ba5d167d85fed556ac5bf5a1c2ca0c294ab57e15b7aa55b06fd49bb337b23f88e1feaebd9ae4a5206c72ad8832f40c1a0374f83999", + "private_key": "", + "auth": { + "enabled": true + } + }, + "device_max_output_size": 0, + "log": { + "log_format": "{time:YYMMDD HH:mm:ss}[{version}-{selected_module}][{extra[tag]}]-{level}-{message}", + "log_format_file": "{time:YYYY-MM-DD HH:mm:ss} - {version}_{selected_module} - {name} - {level} - {extra[tag]} - {message}", + "log_level": "INFO", + "log_dir": "tmp", + "log_file": "server.log", + "data_dir": "data" + }, + "delete_audio": true, + "close_connection_no_voice_time": 120, + "tts_timeout": 10, + "enable_wakeup_words_response_cache": true, + "enable_greeting": true, + "enable_stop_tts_notify": false, + "stop_tts_notify_voice": "config/assets/tts_notify.mp3", + "exit_commands": [ + "退出", + "关闭" + ], + "xiaozhi": { + "type": "hello", + "version": 1, + "transport": "websocket", + "audio_params": { + "format": "opus", + "sample_rate": 24000, + "channels": 1, + "frame_duration": 60 + } + }, + "wakeup_words": [ + "你好小智", + "你好小志", + "小爱同学", + "你好小鑫", + "你好小新", + "小美同学", + "小龙小龙", + "喵喵同学", + "小滨小滨", + "小冰小冰", + "嘿你好呀" + ], + "enable_websocket_ping": false, + "tool_call_timeout": 30, + "end_prompt": { + "enable": true, + "prompt": "请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧!" + }, + "system_error_response": "主人,小智现在有点忙,我们稍后再试吧。", + "system-web": { + "menu": { + "groups": { + "voiceManagement": [ + "vad", + "asr" + ], + "featureManagement": [ + "voiceprintRecognition", + "voiceClone", + "knowledgeBase", + "mcpAccessPoint" + ] + }, + "features": { + "asr": { + "name": "feature.asr.name", + "enabled": true, + "description": "feature.asr.description" + }, + "vad": { + "name": "feature.vad.name", + "enabled": true, + "description": "feature.vad.description" + }, + "voiceClone": { + "name": "feature.voiceClone.name", + "enabled": false, + "description": "feature.voiceClone.description" + }, + "knowledgeBase": { + "name": "feature.knowledgeBase.name", + "enabled": false, + "description": "feature.knowledgeBase.description" + }, + "mcpAccessPoint": { + "name": "feature.mcpAccessPoint.name", + "enabled": false, + "description": "feature.mcpAccessPoint.description" + }, + "voiceprintRecognition": { + "name": "feature.voiceprintRecognition.name", + "enabled": false, + "description": "feature.voiceprintRecognition.description" + } + }, + "addressBook": { + "name": "feature.addressBook.name", + "enabled": false, + "description": "feature.addressBook.description" + } + } + }, + "aliyun": { + "sms": { + "access_key_id": "", + "access_key_secret": "", + "sign_name": "", + "sms_code_template_code": "" + } + }, + "VAD": { + "VAD_SileroVAD": { + "type": "silero", + "model_dir": "models/snakers4_silero-vad", + "threshold": 0.5, + "min_silence_duration_ms": 700 + } + }, + "ASR": { + "ASR_FunASR": { + "type": "fun_local", + "language": "auto", + "model_dir": "models/SenseVoiceSmall", + "output_dir": "tmp/" + } + }, + "selected_module": { + "VAD": "VAD_SileroVAD", + "ASR": "ASR_FunASR" + }, + "prompt": null, + "summaryMemory": null + } + }, + "body_sha256": "969fdeb7bf416a11bf8265f541781239191b6627c0cdf415204ae26481a0ddb3", + "body_excerpt": "{\"code\": 0, \"data\": {\"ASR\": {\"ASR_FunASR\": {\"language\": \"auto\", \"model_dir\": \"models/SenseVoiceSmall\", \"output_dir\": \"tmp/\", \"type\": \"fun_local\"}}, \"VAD\": {\"VAD_SileroVAD\": {\"min_silence_duration_ms\": 700, \"model_dir\": \"models/snakers4_silero-vad\", \"threshold\": 0.5, \"type\": \"silero\"}}, \"aliyun\": {\"sms\": {\"access_key_id\": \"\", \"access_key_secret\": \"\", \"sign_name\": \"\", \"sms_code_template_code\": \"\"}}, \"close_connection_no_voice_time\": 120, \"delete_audio\": true, \"device_max_output_size\": 0, \"enable_greeting\": true, \"enable_stop_tts_notify\": false, \"enable_wakeup_words_response_cache\": true, \"enable_websocket_ping\": false, \"end_prompt\": {\"enable\": true, \"prompt\": \"请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧!\"}, \"exit_commands\": [\"退出\", \"关闭\"], \"log\": {\"data_dir\": \"data\", \"log_dir\": \"tmp\", \"log_file\": \"server.log\", \"log_format\": \"{time:YYMMDD HH:mm:ss}[{version}-{selected_module}][{extra[tag]}]-{level}-{message" + } + }, + { + "name": "POST /correct-word/file", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10034, + "msg": "替换词内容不能为空", + "data": null + }, + "body_sha256": "e5f4d6ed4375e41df28abf26aaa943de6cc3b618236c1b6058a6b2195521d6db", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"替换词内容不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "62" + }, + "body": { + "code": 10034, + "msg": "替换词内容不能为空", + "data": null + }, + "body_sha256": "e5f4d6ed4375e41df28abf26aaa943de6cc3b618236c1b6058a6b2195521d6db", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"替换词内容不能为空\"}" + } + }, + { + "name": "POST /correct-word/file/batch-delete", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "GET /correct-word/file/download/{fileId}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 404, + "headers": { + "content-type": null, + "content-disposition": null, + "content-length": "0" + }, + "body": "", + "body_sha256": "12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "body_excerpt": "\"\"" + }, + "fastapi": { + "status": 404, + "headers": { + "content-type": null, + "content-disposition": null, + "content-length": "0" + }, + "body": "", + "body_sha256": "12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "body_excerpt": "\"\"" + } + }, + { + "name": "GET /correct-word/file/list", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 0, + "list": [] + } + }, + "body_sha256": "740b5f3bbe48cd7f6957e7edaaf0186e07a9c903d2c13bfa54799ef7ef36557d", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [], \"total\": 0}, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "55" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 0, + "list": [] + } + }, + "body_sha256": "740b5f3bbe48cd7f6957e7edaaf0186e07a9c903d2c13bfa54799ef7ef36557d", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [], \"total\": 0}, \"msg\": \"success\"}" + } + }, + { + "name": "GET /correct-word/file/select", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": [] + }, + "body_sha256": "4bf59427c9480fa127a52080c89e8f56420bf728f234d868f1c6511dd1709a9e", + "body_excerpt": "{\"code\": 0, \"data\": [], \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "36" + }, + "body": { + "code": 0, + "msg": "success", + "data": [] + }, + "body_sha256": "4bf59427c9480fa127a52080c89e8f56420bf728f234d868f1c6511dd1709a9e", + "body_excerpt": "{\"code\": 0, \"data\": [], \"msg\": \"success\"}" + } + }, + { + "name": "DELETE /correct-word/file/{fileId}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "38" + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + } + }, + { + "name": "PUT /correct-word/file/{fileId}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10034, + "msg": "替换词内容不能为空", + "data": null + }, + "body_sha256": "e5f4d6ed4375e41df28abf26aaa943de6cc3b618236c1b6058a6b2195521d6db", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"替换词内容不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "62" + }, + "body": { + "code": 10034, + "msg": "替换词内容不能为空", + "data": null + }, + "body_sha256": "e5f4d6ed4375e41df28abf26aaa943de6cc3b618236c1b6058a6b2195521d6db", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"替换词内容不能为空\"}" + } + }, + { + "name": "GET /datasets", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 0, + "list": [] + } + }, + "body_sha256": "740b5f3bbe48cd7f6957e7edaaf0186e07a9c903d2c13bfa54799ef7ef36557d", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [], \"total\": 0}, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "55" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 0, + "list": [] + } + }, + "body_sha256": "740b5f3bbe48cd7f6957e7edaaf0186e07a9c903d2c13bfa54799ef7ef36557d", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [], \"total\": 0}, \"msg\": \"success\"}" + } + }, + { + "name": "POST /datasets", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "DELETE /datasets/batch", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "GET /datasets/rag-models", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": [ + { + "id": "RAG_RAGFlow", + "modelType": null, + "modelCode": null, + "modelName": "RAGFlow", + "isDefault": null, + "isEnabled": null, + "configJson": { + "type": "ragflow", + "api_key": "你的RAG密钥", + "base_url": "http://localhost" + }, + "docLink": null, + "remark": null, + "sort": null, + "updater": null, + "updateDate": null, + "creator": null, + "createDate": null + } + ] + }, + "body_sha256": "a8951f7780dcbcf204a8963e375b1bd8108daf566bbb90be7a906d74f990e367", + "body_excerpt": "{\"code\": 0, \"data\": [{\"configJson\": {\"api_key\": \"你的RAG密钥\", \"base_url\": \"http://localhost\", \"type\": \"ragflow\"}, \"createDate\": null, \"creator\": null, \"docLink\": null, \"id\": \"RAG_RAGFlow\", \"isDefault\": null, \"isEnabled\": null, \"modelCode\": null, \"modelName\": \"RAGFlow\", \"modelType\": null, \"remark\": null, \"sort\": null, \"updateDate\": null, \"updater\": null}], \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "343" + }, + "body": { + "code": 0, + "msg": "success", + "data": [ + { + "id": "RAG_RAGFlow", + "modelType": null, + "modelCode": null, + "modelName": "RAGFlow", + "isDefault": null, + "isEnabled": null, + "configJson": { + "type": "ragflow", + "api_key": "你的RAG密钥", + "base_url": "http://localhost" + }, + "docLink": null, + "remark": null, + "sort": null, + "updater": null, + "updateDate": null, + "creator": null, + "createDate": null + } + ] + }, + "body_sha256": "a8951f7780dcbcf204a8963e375b1bd8108daf566bbb90be7a906d74f990e367", + "body_excerpt": "{\"code\": 0, \"data\": [{\"configJson\": {\"api_key\": \"你的RAG密钥\", \"base_url\": \"http://localhost\", \"type\": \"ragflow\"}, \"createDate\": null, \"creator\": null, \"docLink\": null, \"id\": \"RAG_RAGFlow\", \"isDefault\": null, \"isEnabled\": null, \"modelCode\": null, \"modelName\": \"RAGFlow\", \"modelType\": null, \"remark\": null, \"sort\": null, \"updateDate\": null, \"updater\": null}], \"msg\": \"success\"}" + } + }, + { + "name": "DELETE /datasets/{dataset_id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10163, + "msg": "知识库记录不存在", + "data": null + }, + "body_sha256": "ee314303b69e96558807d93ac9ddcdc0feaa33877f4bbbd38f29a408414f82ea", + "body_excerpt": "{\"code\": 10163, \"data\": null, \"msg\": \"知识库记录不存在\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "59" + }, + "body": { + "code": 10163, + "msg": "知识库记录不存在", + "data": null + }, + "body_sha256": "ee314303b69e96558807d93ac9ddcdc0feaa33877f4bbbd38f29a408414f82ea", + "body_excerpt": "{\"code\": 10163, \"data\": null, \"msg\": \"知识库记录不存在\"}" + } + }, + { + "name": "GET /datasets/{dataset_id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10163, + "msg": "知识库记录不存在", + "data": null + }, + "body_sha256": "ee314303b69e96558807d93ac9ddcdc0feaa33877f4bbbd38f29a408414f82ea", + "body_excerpt": "{\"code\": 10163, \"data\": null, \"msg\": \"知识库记录不存在\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "59" + }, + "body": { + "code": 10163, + "msg": "知识库记录不存在", + "data": null + }, + "body_sha256": "ee314303b69e96558807d93ac9ddcdc0feaa33877f4bbbd38f29a408414f82ea", + "body_excerpt": "{\"code\": 10163, \"data\": null, \"msg\": \"知识库记录不存在\"}" + } + }, + { + "name": "PUT /datasets/{dataset_id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10163, + "msg": "知识库记录不存在", + "data": null + }, + "body_sha256": "ee314303b69e96558807d93ac9ddcdc0feaa33877f4bbbd38f29a408414f82ea", + "body_excerpt": "{\"code\": 10163, \"data\": null, \"msg\": \"知识库记录不存在\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "59" + }, + "body": { + "code": 10163, + "msg": "知识库记录不存在", + "data": null + }, + "body_sha256": "ee314303b69e96558807d93ac9ddcdc0feaa33877f4bbbd38f29a408414f82ea", + "body_excerpt": "{\"code\": 10163, \"data\": null, \"msg\": \"知识库记录不存在\"}" + } + }, + { + "name": "POST /datasets/{dataset_id}/chunks", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10163, + "msg": "知识库记录不存在", + "data": null + }, + "body_sha256": "ee314303b69e96558807d93ac9ddcdc0feaa33877f4bbbd38f29a408414f82ea", + "body_excerpt": "{\"code\": 10163, \"data\": null, \"msg\": \"知识库记录不存在\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "59" + }, + "body": { + "code": 10163, + "msg": "知识库记录不存在", + "data": null + }, + "body_sha256": "ee314303b69e96558807d93ac9ddcdc0feaa33877f4bbbd38f29a408414f82ea", + "body_excerpt": "{\"code\": 10163, \"data\": null, \"msg\": \"知识库记录不存在\"}" + } + }, + { + "name": "DELETE /datasets/{dataset_id}/documents", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "GET /datasets/{dataset_id}/documents", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10163, + "msg": "知识库记录不存在", + "data": null + }, + "body_sha256": "ee314303b69e96558807d93ac9ddcdc0feaa33877f4bbbd38f29a408414f82ea", + "body_excerpt": "{\"code\": 10163, \"data\": null, \"msg\": \"知识库记录不存在\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "59" + }, + "body": { + "code": 10163, + "msg": "知识库记录不存在", + "data": null + }, + "body_sha256": "ee314303b69e96558807d93ac9ddcdc0feaa33877f4bbbd38f29a408414f82ea", + "body_excerpt": "{\"code\": 10163, \"data\": null, \"msg\": \"知识库记录不存在\"}" + } + }, + { + "name": "POST /datasets/{dataset_id}/documents", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "GET /datasets/{dataset_id}/documents/status/{status}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10163, + "msg": "知识库记录不存在", + "data": null + }, + "body_sha256": "ee314303b69e96558807d93ac9ddcdc0feaa33877f4bbbd38f29a408414f82ea", + "body_excerpt": "{\"code\": 10163, \"data\": null, \"msg\": \"知识库记录不存在\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "59" + }, + "body": { + "code": 10163, + "msg": "知识库记录不存在", + "data": null + }, + "body_sha256": "ee314303b69e96558807d93ac9ddcdc0feaa33877f4bbbd38f29a408414f82ea", + "body_excerpt": "{\"code\": 10163, \"data\": null, \"msg\": \"知识库记录不存在\"}" + } + }, + { + "name": "DELETE /datasets/{dataset_id}/documents/{document_id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10163, + "msg": "知识库记录不存在", + "data": null + }, + "body_sha256": "ee314303b69e96558807d93ac9ddcdc0feaa33877f4bbbd38f29a408414f82ea", + "body_excerpt": "{\"code\": 10163, \"data\": null, \"msg\": \"知识库记录不存在\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "59" + }, + "body": { + "code": 10163, + "msg": "知识库记录不存在", + "data": null + }, + "body_sha256": "ee314303b69e96558807d93ac9ddcdc0feaa33877f4bbbd38f29a408414f82ea", + "body_excerpt": "{\"code\": 10163, \"data\": null, \"msg\": \"知识库记录不存在\"}" + } + }, + { + "name": "GET /datasets/{dataset_id}/documents/{document_id}/chunks", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10163, + "msg": "知识库记录不存在", + "data": null + }, + "body_sha256": "ee314303b69e96558807d93ac9ddcdc0feaa33877f4bbbd38f29a408414f82ea", + "body_excerpt": "{\"code\": 10163, \"data\": null, \"msg\": \"知识库记录不存在\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "59" + }, + "body": { + "code": 10163, + "msg": "知识库记录不存在", + "data": null + }, + "body_sha256": "ee314303b69e96558807d93ac9ddcdc0feaa33877f4bbbd38f29a408414f82ea", + "body_excerpt": "{\"code\": 10163, \"data\": null, \"msg\": \"知识库记录不存在\"}" + } + }, + { + "name": "POST /datasets/{dataset_id}/retrieval-test", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10163, + "msg": "知识库记录不存在", + "data": null + }, + "body_sha256": "ee314303b69e96558807d93ac9ddcdc0feaa33877f4bbbd38f29a408414f82ea", + "body_excerpt": "{\"code\": 10163, \"data\": null, \"msg\": \"知识库记录不存在\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "59" + }, + "body": { + "code": 10163, + "msg": "知识库记录不存在", + "data": null + }, + "body_sha256": "ee314303b69e96558807d93ac9ddcdc0feaa33877f4bbbd38f29a408414f82ea", + "body_excerpt": "{\"code\": 10163, \"data\": null, \"msg\": \"知识库记录不存在\"}" + } + }, + { + "name": "PUT /device/address-book/alias", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10034, + "msg": "MAC地址不能为空", + "data": null + }, + "body_sha256": "693a006632a7779b991c8b9ef6a48eb38a0bcf3d63d0b1ef1ee8d9bed284d33b", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"MAC地址不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "56" + }, + "body": { + "code": 10034, + "msg": "MAC地址不能为空", + "data": null + }, + "body_sha256": "693a006632a7779b991c8b9ef6a48eb38a0bcf3d63d0b1ef1ee8d9bed284d33b", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"MAC地址不能为空\"}" + } + }, + { + "name": "GET /device/address-book/call", + "category": "authenticated-route:server-secret", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "PUT /device/address-book/permission", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10034, + "msg": "目标MAC地址不能为空", + "data": null + }, + "body_sha256": "3dd9fbac83c64ac6b89c6418eeeffad066b34978b422725e87a7a51d16032fe8", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"目标MAC地址不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "62" + }, + "body": { + "code": 10034, + "msg": "目标MAC地址不能为空", + "data": null + }, + "body_sha256": "3dd9fbac83c64ac6b89c6418eeeffad066b34978b422725e87a7a51d16032fe8", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"目标MAC地址不能为空\"}" + } + }, + { + "name": "GET /device/address-book/{macAddress}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": [] + }, + "body_sha256": "4bf59427c9480fa127a52080c89e8f56420bf728f234d868f1c6511dd1709a9e", + "body_excerpt": "{\"code\": 0, \"data\": [], \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "36" + }, + "body": { + "code": 0, + "msg": "success", + "data": [] + }, + "body_sha256": "4bf59427c9480fa127a52080c89e8f56420bf728f234d868f1c6511dd1709a9e", + "body_excerpt": "{\"code\": 0, \"data\": [], \"msg\": \"success\"}" + } + }, + { + "name": "GET /device/bind/{agentId}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": [] + }, + "body_sha256": "4bf59427c9480fa127a52080c89e8f56420bf728f234d868f1c6511dd1709a9e", + "body_excerpt": "{\"code\": 0, \"data\": [], \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "36" + }, + "body": { + "code": 0, + "msg": "success", + "data": [] + }, + "body_sha256": "4bf59427c9480fa127a52080c89e8f56420bf728f234d868f1c6511dd1709a9e", + "body_excerpt": "{\"code\": 0, \"data\": [], \"msg\": \"success\"}" + } + }, + { + "name": "POST /device/bind/{agentId}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": "" + }, + "body_sha256": "ae727f80a578880566949db81c1f3a5186d8d6c010e12a56d677a061c4e8776a", + "body_excerpt": "{\"code\": 0, \"data\": \"\", \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "36" + }, + "body": { + "code": 0, + "msg": "success", + "data": "" + }, + "body_sha256": "ae727f80a578880566949db81c1f3a5186d8d6c010e12a56d677a061c4e8776a", + "body_excerpt": "{\"code\": 0, \"data\": \"\", \"msg\": \"success\"}" + } + }, + { + "name": "POST /device/bind/{agentId}/{deviceCode}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10062, + "msg": "激活码错误", + "data": null + }, + "body_sha256": "08ddca1d8bb277c71391e9dc3183b85ef0b2d29dd898be2fc35a0dc826e2c761", + "body_excerpt": "{\"code\": 10062, \"data\": null, \"msg\": \"激活码错误\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "50" + }, + "body": { + "code": 10062, + "msg": "激活码错误", + "data": null + }, + "body_sha256": "08ddca1d8bb277c71391e9dc3183b85ef0b2d29dd898be2fc35a0dc826e2c761", + "body_excerpt": "{\"code\": 10062, \"data\": null, \"msg\": \"激活码错误\"}" + } + }, + { + "name": "POST /device/manual-add", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "POST /device/register", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "POST /device/tools/call/{deviceId}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10034, + "msg": "工具名称不能为空", + "data": null + }, + "body_sha256": "a8a2da82e67755ce680a39709e4b13d3e4d39b4ee751fd8e25afac4b3d2ddd09", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"工具名称不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "59" + }, + "body": { + "code": 10034, + "msg": "工具名称不能为空", + "data": null + }, + "body_sha256": "a8a2da82e67755ce680a39709e4b13d3e4d39b4ee751fd8e25afac4b3d2ddd09", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"工具名称不能为空\"}" + } + }, + { + "name": "POST /device/tools/list/{deviceId}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10194, + "msg": "设备不存在或不在线", + "data": null + }, + "body_sha256": "c7eae38fea6872ddaafd9874a8aa77c770fe6b3ec428d734c6646704aa319521", + "body_excerpt": "{\"code\": 10194, \"data\": null, \"msg\": \"设备不存在或不在线\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "62" + }, + "body": { + "code": 10194, + "msg": "设备不存在或不在线", + "data": null + }, + "body_sha256": "c7eae38fea6872ddaafd9874a8aa77c770fe6b3ec428d734c6646704aa319521", + "body_excerpt": "{\"code\": 10194, \"data\": null, \"msg\": \"设备不存在或不在线\"}" + } + }, + { + "name": "POST /device/unbind", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "38" + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + } + }, + { + "name": "PUT /device/update/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "设备不存在", + "data": null + }, + "body_sha256": "03424e852553f3415e7e8b4b4519fcc65fec31b91816747282665696ba474f3d", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"设备不存在\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "48" + }, + "body": { + "code": 500, + "msg": "设备不存在", + "data": null + }, + "body_sha256": "03424e852553f3415e7e8b4b4519fcc65fec31b91816747282665696ba474f3d", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"设备不存在\"}" + } + }, + { + "name": "PUT /models/default/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "模型配置不存在", + "data": null + }, + "body_sha256": "a76871da0a3ab1cbb8d108a3eba9baf8f6fbf74dbd1c45634b0ec215dc538120", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"模型配置不存在\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "模型配置不存在", + "data": null + }, + "body_sha256": "a76871da0a3ab1cbb8d108a3eba9baf8f6fbf74dbd1c45634b0ec215dc538120", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"模型配置不存在\"}" + } + }, + { + "name": "PUT /models/enable/{id}/{status}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "模型配置不存在", + "data": null + }, + "body_sha256": "a76871da0a3ab1cbb8d108a3eba9baf8f6fbf74dbd1c45634b0ec215dc538120", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"模型配置不存在\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "模型配置不存在", + "data": null + }, + "body_sha256": "a76871da0a3ab1cbb8d108a3eba9baf8f6fbf74dbd1c45634b0ec215dc538120", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"模型配置不存在\"}" + } + }, + { + "name": "GET /models/list", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "GET /models/llm/names", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": [ + { + "id": "LLM_AliAppLLM", + "modelName": "百炼智能体应用", + "type": "AliBL" + }, + { + "id": "LLM_AliLLM", + "modelName": "通义千问", + "type": "openai" + }, + { + "id": "LLM_ChatGLMLLM", + "modelName": "智谱AI", + "type": "openai" + }, + { + "id": "LLM_CozeLLM", + "modelName": "Coze", + "type": "coze" + }, + { + "id": "LLM_DeepSeekLLM", + "modelName": "DeepSeek", + "type": "openai" + }, + { + "id": "LLM_DifyLLM", + "modelName": "Dify", + "type": "dify" + }, + { + "id": "LLM_DoubaoLLM", + "modelName": "豆包大模型", + "type": "openai" + }, + { + "id": "LLM_FastgptLLM", + "modelName": "FastGPT", + "type": "fastgpt" + }, + { + "id": "LLM_GeminiLLM", + "modelName": "谷歌Gemini", + "type": "gemini" + }, + { + "id": "LLM_LMStudioLLM", + "modelName": "LM Studio", + "type": "openai" + }, + { + "id": "LLM_OllamaLLM", + "modelName": "Ollama本地模型", + "type": "ollama" + }, + { + "id": "LLM_VolcesAiGatewayLLM", + "modelName": "火山引擎边缘大模型网关", + "type": "openai" + }, + { + "id": "LLM_XinferenceLLM", + "modelName": "Xinference大模型", + "type": "xinference" + }, + { + "id": "LLM_XinferenceSmallLLM", + "modelName": "Xinference小模型", + "type": "xinference" + }, + { + "id": "LLM_XunfeiSparkLLM", + "modelName": "讯飞星火认知大模型", + "type": "openai" + } + ] + }, + "body_sha256": "19b3b55882c8d7864f4727807fed6aeea7da04c8e3053af27d78971d2af77a65", + "body_excerpt": "{\"code\": 0, \"data\": [{\"id\": \"LLM_AliAppLLM\", \"modelName\": \"百炼智能体应用\", \"type\": \"AliBL\"}, {\"id\": \"LLM_AliLLM\", \"modelName\": \"通义千问\", \"type\": \"openai\"}, {\"id\": \"LLM_ChatGLMLLM\", \"modelName\": \"智谱AI\", \"type\": \"openai\"}, {\"id\": \"LLM_CozeLLM\", \"modelName\": \"Coze\", \"type\": \"coze\"}, {\"id\": \"LLM_DeepSeekLLM\", \"modelName\": \"DeepSeek\", \"type\": \"openai\"}, {\"id\": \"LLM_DifyLLM\", \"modelName\": \"Dify\", \"type\": \"dify\"}, {\"id\": \"LLM_DoubaoLLM\", \"modelName\": \"豆包大模型\", \"type\": \"openai\"}, {\"id\": \"LLM_FastgptLLM\", \"modelName\": \"FastGPT\", \"type\": \"fastgpt\"}, {\"id\": \"LLM_GeminiLLM\", \"modelName\": \"谷歌Gemini\", \"type\": \"gemini\"}, {\"id\": \"LLM_LMStudioLLM\", \"modelName\": \"LM Studio\", \"type\": \"openai\"}, {\"id\": \"LLM_OllamaLLM\", \"modelName\": \"Ollama本地模型\", \"type\": \"ollama\"}, {\"id\": \"LLM_VolcesAiGatewayLLM\", \"modelName\": \"火山引擎边缘大模型网关\", \"type\": \"openai\"}, {\"id\": \"LLM_XinferenceLLM\", \"modelName\": \"Xinference大模型\", \"type\": \"xinference\"}, {\"id\": \"LLM_XinferenceSmallLLM\", \"modelName\": \"Xinference小模型\", \"type\": \"xinference\"}, {\"id\": " + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "1091" + }, + "body": { + "code": 0, + "msg": "success", + "data": [ + { + "id": "LLM_AliAppLLM", + "modelName": "百炼智能体应用", + "type": "AliBL" + }, + { + "id": "LLM_AliLLM", + "modelName": "通义千问", + "type": "openai" + }, + { + "id": "LLM_ChatGLMLLM", + "modelName": "智谱AI", + "type": "openai" + }, + { + "id": "LLM_CozeLLM", + "modelName": "Coze", + "type": "coze" + }, + { + "id": "LLM_DeepSeekLLM", + "modelName": "DeepSeek", + "type": "openai" + }, + { + "id": "LLM_DifyLLM", + "modelName": "Dify", + "type": "dify" + }, + { + "id": "LLM_DoubaoLLM", + "modelName": "豆包大模型", + "type": "openai" + }, + { + "id": "LLM_FastgptLLM", + "modelName": "FastGPT", + "type": "fastgpt" + }, + { + "id": "LLM_GeminiLLM", + "modelName": "谷歌Gemini", + "type": "gemini" + }, + { + "id": "LLM_LMStudioLLM", + "modelName": "LM Studio", + "type": "openai" + }, + { + "id": "LLM_OllamaLLM", + "modelName": "Ollama本地模型", + "type": "ollama" + }, + { + "id": "LLM_VolcesAiGatewayLLM", + "modelName": "火山引擎边缘大模型网关", + "type": "openai" + }, + { + "id": "LLM_XinferenceLLM", + "modelName": "Xinference大模型", + "type": "xinference" + }, + { + "id": "LLM_XinferenceSmallLLM", + "modelName": "Xinference小模型", + "type": "xinference" + }, + { + "id": "LLM_XunfeiSparkLLM", + "modelName": "讯飞星火认知大模型", + "type": "openai" + } + ] + }, + "body_sha256": "19b3b55882c8d7864f4727807fed6aeea7da04c8e3053af27d78971d2af77a65", + "body_excerpt": "{\"code\": 0, \"data\": [{\"id\": \"LLM_AliAppLLM\", \"modelName\": \"百炼智能体应用\", \"type\": \"AliBL\"}, {\"id\": \"LLM_AliLLM\", \"modelName\": \"通义千问\", \"type\": \"openai\"}, {\"id\": \"LLM_ChatGLMLLM\", \"modelName\": \"智谱AI\", \"type\": \"openai\"}, {\"id\": \"LLM_CozeLLM\", \"modelName\": \"Coze\", \"type\": \"coze\"}, {\"id\": \"LLM_DeepSeekLLM\", \"modelName\": \"DeepSeek\", \"type\": \"openai\"}, {\"id\": \"LLM_DifyLLM\", \"modelName\": \"Dify\", \"type\": \"dify\"}, {\"id\": \"LLM_DoubaoLLM\", \"modelName\": \"豆包大模型\", \"type\": \"openai\"}, {\"id\": \"LLM_FastgptLLM\", \"modelName\": \"FastGPT\", \"type\": \"fastgpt\"}, {\"id\": \"LLM_GeminiLLM\", \"modelName\": \"谷歌Gemini\", \"type\": \"gemini\"}, {\"id\": \"LLM_LMStudioLLM\", \"modelName\": \"LM Studio\", \"type\": \"openai\"}, {\"id\": \"LLM_OllamaLLM\", \"modelName\": \"Ollama本地模型\", \"type\": \"ollama\"}, {\"id\": \"LLM_VolcesAiGatewayLLM\", \"modelName\": \"火山引擎边缘大模型网关\", \"type\": \"openai\"}, {\"id\": \"LLM_XinferenceLLM\", \"modelName\": \"Xinference大模型\", \"type\": \"xinference\"}, {\"id\": \"LLM_XinferenceSmallLLM\", \"modelName\": \"Xinference小模型\", \"type\": \"xinference\"}, {\"id\": " + } + }, + { + "name": "GET /models/names", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "GET /models/provider", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 61, + "list": [ + { + "id": "SYSTEM_ASR_FunASR", + "modelType": "ASR", + "providerCode": "fun_local", + "name": "FunASR语音识别", + "fields": "[{\"key\": \"model_dir\", \"type\": \"string\", \"label\": \"模型目录\"}, {\"key\": \"output_dir\", \"type\": \"string\", \"label\": \"输出目录\"}, {\"key\": \"language\", \"type\": \"string\", \"label\": \"识别语言\", \"default\": \"auto\"}]", + "sort": 1, + "updater": "1", + "updateDate": "2026-07-20 15:11:58", + "creator": "1", + "createDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_ASR_SherpaASR", + "modelType": "ASR", + "providerCode": "sherpa_onnx_local", + "name": "SherpaASR语音识别", + "fields": "[{\"key\": \"model_dir\", \"type\": \"string\", \"label\": \"模型目录\"}, {\"key\": \"output_dir\", \"type\": \"string\", \"label\": \"输出目录\"}]", + "sort": 2, + "updater": "1", + "updateDate": "2026-07-20 15:11:58", + "creator": "1", + "createDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_ASR_DoubaoStreamASR", + "modelType": "ASR", + "providerCode": "doubao_stream", + "name": "火山引擎语音识别(流式)", + "fields": "[{\"key\": \"appid\", \"type\": \"string\", \"label\": \"应用ID\"}, {\"key\": \"access_token\", \"type\": \"string\", \"label\": \"访问令牌\"}, {\"key\": \"boosting_table_name\", \"type\": \"string\", \"label\": \"热词文件名称\"}, {\"key\": \"correct_table_name\", \"type\": \"string\", \"label\": \"替换词文件名称\"}, {\"key\": \"output_dir\", \"type\": \"string\", \"label\": \"输出目录\"}, {\"key\": \"end_window_size\", \"type\": \"number\", \"label\": \"静音判定时长(ms)\"}, {\"key\": \"enable_multilingual\", \"type\": \"boolean\", \"label\": \"是否开启多语种识别模式\"}, {\"key\": \"language\", \"type\": \"string\", \"label\": \"指定语言编码\"}, {\"key\": \"resource_id\", \"type\": \"string\", \"label\": \"资源ID\"}]", + "sort": 3, + "updater": "1", + "updateDate": "2026-07-20 15:11:59", + "creator": "1", + "createDate": "2026-07-20 15:11:59" + }, + { + "id": "SYSTEM_ASR_DoubaoASR", + "modelType": "ASR", + "providerCode": "doubao", + "name": "火山引擎语音识别", + "fields": "[{\"key\": \"appid\", \"type\": \"string\", \"label\": \"应用ID\"}, {\"key\": \"access_token\", \"type\": \"string\", \"label\": \"访问令牌\"}, {\"key\": \"cluster\", \"type\": \"string\", \"label\": \"集群\"}, {\"key\": \"boosting_table_name\", \"type\": \"string\", \"label\": \"热词文件名称\"}, {\"key\": \"correct_table_name\", \"type\": \"string\", \"label\": \"替换词文件名称\"}, {\"key\": \"output_dir\", \"type\": \"string\", \"label\": \"输出目录\"}]", + "sort": 3, + "updater": "1", + "updateDate": "2026-07-20 15:11:58", + "creator": "1", + "createDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_ASR_TencentASR", + "modelType": "ASR", + "providerCode": "tencent", + "name": "腾讯语音识别", + "fields": "[{\"key\": \"appid\", \"type\": \"string\", \"label\": \"应用ID\"}, {\"key\": \"secret_id\", \"type\": \"string\", \"label\": \"Secret ID\"}, {\"key\": \"secret_key\", \"type\": \"string\", \"label\": \"Secret Key\"}, {\"key\": \"output_dir\", \"type\": \"string\", \"label\": \"输出目录\"}]", + "sort": 4, + "updater": "1", + "updateDate": "2026-07-20 15:11:58", + "creator": "1", + "createDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_ASR_FunASRServer", + "modelType": "ASR", + "providerCode": "fun_server", + "name": "FunASR服务语音识别", + "fields": "[{\"key\": \"host\", \"type\": \"string\", \"label\": \"服务地址\"}, {\"key\": \"port\", \"type\": \"number\", \"label\": \"端口号\"}, {\"key\": \"type\", \"type\": \"string\", \"label\": \"服务类型\"}, {\"key\": \"is_ssl\", \"type\": \"boolean\", \"label\": \"是否使用SSL\"}, {\"key\": \"api_key\", \"type\": \"string\", \"label\": \"API密钥\"}, {\"key\": \"output_dir\", \"type\": \"string\", \"label\": \"输出目录\"}]", + "sort": 4, + "updater": "1", + "updateDate": "2026-07-20 15:11:58", + "creator": "1", + "createDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_ASR_AliyunASR", + "modelType": "ASR", + "providerCode": "aliyun", + "name": "阿里云语音识别", + "fields": "[{\"key\": \"appkey\", \"type\": \"string\", \"label\": \"应用AppKey\"}, {\"key\": \"token\", \"type\": \"string\", \"label\": \"临时Token\"}, {\"key\": \"access_key_id\", \"type\": \"string\", \"label\": \"AccessKey ID\"}, {\"key\": \"access_key_secret\", \"type\": \"string\", \"label\": \"AccessKey Secret\"}, {\"key\": \"output_dir\", \"type\": \"string\", \"label\": \"输出目录\"}]", + "sort": 5, + "updater": "1", + "updateDate": "2026-07-20 15:11:58", + "creator": "1", + "createDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_ASR_AliyunStreamASR", + "modelType": "ASR", + "providerCode": "aliyun_stream", + "name": "阿里云语音识别(流式)", + "fields": "[{\"key\": \"appkey\", \"type\": \"string\", \"label\": \"应用AppKey\"}, {\"key\": \"token\", \"type\": \"string\", \"label\": \"临时Token\"}, {\"key\": \"access_key_id\", \"type\": \"string\", \"label\": \"AccessKey ID\"}, {\"key\": \"access_key_secret\", \"type\": \"string\", \"label\": \"AccessKey Secret\"}, {\"key\": \"host\", \"type\": \"string\", \"label\": \"服务地址\"}, {\"key\": \"max_sentence_silence\", \"type\": \"number\", \"label\": \"断句检测时间\"}, {\"key\": \"output_dir\", \"type\": \"string\", \"label\": \"输出目录\"}]", + "sort": 6, + "updater": "1", + "updateDate": "2026-07-20 15:11:59", + "creator": "1", + "createDate": "2026-07-20 15:11:59" + }, + { + "id": "SYSTEM_ASR_BaiduASR", + "modelType": "ASR", + "providerCode": "baidu", + "name": "百度语音识别", + "fields": "[{\"key\": \"app_id\", \"type\": \"string\", \"label\": \"应用AppID\"}, {\"key\": \"api_key\", \"type\": \"string\", \"label\": \"API Key\"}, {\"key\": \"secret_key\", \"type\": \"string\", \"label\": \"Secret Key\"}, {\"key\": \"dev_pid\", \"type\": \"number\", \"label\": \"语言参数\"}, {\"key\": \"output_dir\", \"type\": \"string\", \"label\": \"输出目录\"}]", + "sort": 7, + "updater": "1", + "updateDate": "2026-07-20 15:11:58", + "creator": "1", + "createDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_ASR_OpenaiASR", + "modelType": "ASR", + "providerCode": "openai", + "name": "OpenAI语音识别", + "fields": "[{\"key\": \"base_url\", \"type\": \"string\", \"label\": \"基础URL\"}, {\"key\": \"model_name\", \"type\": \"string\", \"label\": \"模型名称\"}, {\"key\": \"api_key\", \"type\": \"string\", \"label\": \"API密钥\"}, {\"key\": \"output_dir\", \"type\": \"string\", \"label\": \"输出目录\"}]", + "sort": 9, + "updater": "1", + "updateDate": "2026-07-20 15:11:59", + "creator": "1", + "createDate": "2026-07-20 15:11:59" + } + ] + } + }, + "body_sha256": "e1effec753bcf59cf1b1c1c7df914ea17b462c8595a953f7aa4fa77d9e6401d3", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [{\"createDate\": \"2026-07-20 15:11:58\", \"creator\": \"1\", \"fields\": \"[{\\\"key\\\": \\\"model_dir\\\", \\\"type\\\": \\\"string\\\", \\\"label\\\": \\\"模型目录\\\"}, {\\\"key\\\": \\\"output_dir\\\", \\\"type\\\": \\\"string\\\", \\\"label\\\": \\\"输出目录\\\"}, {\\\"key\\\": \\\"language\\\", \\\"type\\\": \\\"string\\\", \\\"label\\\": \\\"识别语言\\\", \\\"default\\\": \\\"auto\\\"}]\", \"id\": \"SYSTEM_ASR_FunASR\", \"modelType\": \"ASR\", \"name\": \"FunASR语音识别\", \"providerCode\": \"fun_local\", \"sort\": 1, \"updateDate\": \"2026-07-20 15:11:58\", \"updater\": \"1\"}, {\"createDate\": \"2026-07-20 15:11:58\", \"creator\": \"1\", \"fields\": \"[{\\\"key\\\": \\\"model_dir\\\", \\\"type\\\": \\\"string\\\", \\\"label\\\": \\\"模型目录\\\"}, {\\\"key\\\": \\\"output_dir\\\", \\\"type\\\": \\\"string\\\", \\\"label\\\": \\\"输出目录\\\"}]\", \"id\": \"SYSTEM_ASR_SherpaASR\", \"modelType\": \"ASR\", \"name\": \"SherpaASR语音识别\", \"providerCode\": \"sherpa_onnx_local\", \"sort\": 2, \"updateDate\": \"2026-07-20 15:11:58\", \"updater\": \"1\"}, {\"createDate\": \"2026-07-20 15:11:59\", \"creator\": \"1\", \"fields\": \"[{\\\"key\\\": \\\"appid\\\", \\\"type\\\": \\\"string\\\", \\\"label\\\": \\\"应用I" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "6364" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 61, + "list": [ + { + "id": "SYSTEM_ASR_FunASR", + "modelType": "ASR", + "providerCode": "fun_local", + "name": "FunASR语音识别", + "fields": "[{\"key\": \"model_dir\", \"type\": \"string\", \"label\": \"模型目录\"}, {\"key\": \"output_dir\", \"type\": \"string\", \"label\": \"输出目录\"}, {\"key\": \"language\", \"type\": \"string\", \"label\": \"识别语言\", \"default\": \"auto\"}]", + "sort": 1, + "creator": "1", + "createDate": "2026-07-20 15:11:58", + "updater": "1", + "updateDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_ASR_SherpaASR", + "modelType": "ASR", + "providerCode": "sherpa_onnx_local", + "name": "SherpaASR语音识别", + "fields": "[{\"key\": \"model_dir\", \"type\": \"string\", \"label\": \"模型目录\"}, {\"key\": \"output_dir\", \"type\": \"string\", \"label\": \"输出目录\"}]", + "sort": 2, + "creator": "1", + "createDate": "2026-07-20 15:11:58", + "updater": "1", + "updateDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_ASR_DoubaoStreamASR", + "modelType": "ASR", + "providerCode": "doubao_stream", + "name": "火山引擎语音识别(流式)", + "fields": "[{\"key\": \"appid\", \"type\": \"string\", \"label\": \"应用ID\"}, {\"key\": \"access_token\", \"type\": \"string\", \"label\": \"访问令牌\"}, {\"key\": \"boosting_table_name\", \"type\": \"string\", \"label\": \"热词文件名称\"}, {\"key\": \"correct_table_name\", \"type\": \"string\", \"label\": \"替换词文件名称\"}, {\"key\": \"output_dir\", \"type\": \"string\", \"label\": \"输出目录\"}, {\"key\": \"end_window_size\", \"type\": \"number\", \"label\": \"静音判定时长(ms)\"}, {\"key\": \"enable_multilingual\", \"type\": \"boolean\", \"label\": \"是否开启多语种识别模式\"}, {\"key\": \"language\", \"type\": \"string\", \"label\": \"指定语言编码\"}, {\"key\": \"resource_id\", \"type\": \"string\", \"label\": \"资源ID\"}]", + "sort": 3, + "creator": "1", + "createDate": "2026-07-20 15:11:59", + "updater": "1", + "updateDate": "2026-07-20 15:11:59" + }, + { + "id": "SYSTEM_ASR_DoubaoASR", + "modelType": "ASR", + "providerCode": "doubao", + "name": "火山引擎语音识别", + "fields": "[{\"key\": \"appid\", \"type\": \"string\", \"label\": \"应用ID\"}, {\"key\": \"access_token\", \"type\": \"string\", \"label\": \"访问令牌\"}, {\"key\": \"cluster\", \"type\": \"string\", \"label\": \"集群\"}, {\"key\": \"boosting_table_name\", \"type\": \"string\", \"label\": \"热词文件名称\"}, {\"key\": \"correct_table_name\", \"type\": \"string\", \"label\": \"替换词文件名称\"}, {\"key\": \"output_dir\", \"type\": \"string\", \"label\": \"输出目录\"}]", + "sort": 3, + "creator": "1", + "createDate": "2026-07-20 15:11:58", + "updater": "1", + "updateDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_ASR_TencentASR", + "modelType": "ASR", + "providerCode": "tencent", + "name": "腾讯语音识别", + "fields": "[{\"key\": \"appid\", \"type\": \"string\", \"label\": \"应用ID\"}, {\"key\": \"secret_id\", \"type\": \"string\", \"label\": \"Secret ID\"}, {\"key\": \"secret_key\", \"type\": \"string\", \"label\": \"Secret Key\"}, {\"key\": \"output_dir\", \"type\": \"string\", \"label\": \"输出目录\"}]", + "sort": 4, + "creator": "1", + "createDate": "2026-07-20 15:11:58", + "updater": "1", + "updateDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_ASR_FunASRServer", + "modelType": "ASR", + "providerCode": "fun_server", + "name": "FunASR服务语音识别", + "fields": "[{\"key\": \"host\", \"type\": \"string\", \"label\": \"服务地址\"}, {\"key\": \"port\", \"type\": \"number\", \"label\": \"端口号\"}, {\"key\": \"type\", \"type\": \"string\", \"label\": \"服务类型\"}, {\"key\": \"is_ssl\", \"type\": \"boolean\", \"label\": \"是否使用SSL\"}, {\"key\": \"api_key\", \"type\": \"string\", \"label\": \"API密钥\"}, {\"key\": \"output_dir\", \"type\": \"string\", \"label\": \"输出目录\"}]", + "sort": 4, + "creator": "1", + "createDate": "2026-07-20 15:11:58", + "updater": "1", + "updateDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_ASR_AliyunASR", + "modelType": "ASR", + "providerCode": "aliyun", + "name": "阿里云语音识别", + "fields": "[{\"key\": \"appkey\", \"type\": \"string\", \"label\": \"应用AppKey\"}, {\"key\": \"token\", \"type\": \"string\", \"label\": \"临时Token\"}, {\"key\": \"access_key_id\", \"type\": \"string\", \"label\": \"AccessKey ID\"}, {\"key\": \"access_key_secret\", \"type\": \"string\", \"label\": \"AccessKey Secret\"}, {\"key\": \"output_dir\", \"type\": \"string\", \"label\": \"输出目录\"}]", + "sort": 5, + "creator": "1", + "createDate": "2026-07-20 15:11:58", + "updater": "1", + "updateDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_ASR_AliyunStreamASR", + "modelType": "ASR", + "providerCode": "aliyun_stream", + "name": "阿里云语音识别(流式)", + "fields": "[{\"key\": \"appkey\", \"type\": \"string\", \"label\": \"应用AppKey\"}, {\"key\": \"token\", \"type\": \"string\", \"label\": \"临时Token\"}, {\"key\": \"access_key_id\", \"type\": \"string\", \"label\": \"AccessKey ID\"}, {\"key\": \"access_key_secret\", \"type\": \"string\", \"label\": \"AccessKey Secret\"}, {\"key\": \"host\", \"type\": \"string\", \"label\": \"服务地址\"}, {\"key\": \"max_sentence_silence\", \"type\": \"number\", \"label\": \"断句检测时间\"}, {\"key\": \"output_dir\", \"type\": \"string\", \"label\": \"输出目录\"}]", + "sort": 6, + "creator": "1", + "createDate": "2026-07-20 15:11:59", + "updater": "1", + "updateDate": "2026-07-20 15:11:59" + }, + { + "id": "SYSTEM_ASR_BaiduASR", + "modelType": "ASR", + "providerCode": "baidu", + "name": "百度语音识别", + "fields": "[{\"key\": \"app_id\", \"type\": \"string\", \"label\": \"应用AppID\"}, {\"key\": \"api_key\", \"type\": \"string\", \"label\": \"API Key\"}, {\"key\": \"secret_key\", \"type\": \"string\", \"label\": \"Secret Key\"}, {\"key\": \"dev_pid\", \"type\": \"number\", \"label\": \"语言参数\"}, {\"key\": \"output_dir\", \"type\": \"string\", \"label\": \"输出目录\"}]", + "sort": 7, + "creator": "1", + "createDate": "2026-07-20 15:11:58", + "updater": "1", + "updateDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_ASR_OpenaiASR", + "modelType": "ASR", + "providerCode": "openai", + "name": "OpenAI语音识别", + "fields": "[{\"key\": \"base_url\", \"type\": \"string\", \"label\": \"基础URL\"}, {\"key\": \"model_name\", \"type\": \"string\", \"label\": \"模型名称\"}, {\"key\": \"api_key\", \"type\": \"string\", \"label\": \"API密钥\"}, {\"key\": \"output_dir\", \"type\": \"string\", \"label\": \"输出目录\"}]", + "sort": 9, + "creator": "1", + "createDate": "2026-07-20 15:11:59", + "updater": "1", + "updateDate": "2026-07-20 15:11:59" + } + ] + } + }, + "body_sha256": "e1effec753bcf59cf1b1c1c7df914ea17b462c8595a953f7aa4fa77d9e6401d3", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [{\"createDate\": \"2026-07-20 15:11:58\", \"creator\": \"1\", \"fields\": \"[{\\\"key\\\": \\\"model_dir\\\", \\\"type\\\": \\\"string\\\", \\\"label\\\": \\\"模型目录\\\"}, {\\\"key\\\": \\\"output_dir\\\", \\\"type\\\": \\\"string\\\", \\\"label\\\": \\\"输出目录\\\"}, {\\\"key\\\": \\\"language\\\", \\\"type\\\": \\\"string\\\", \\\"label\\\": \\\"识别语言\\\", \\\"default\\\": \\\"auto\\\"}]\", \"id\": \"SYSTEM_ASR_FunASR\", \"modelType\": \"ASR\", \"name\": \"FunASR语音识别\", \"providerCode\": \"fun_local\", \"sort\": 1, \"updateDate\": \"2026-07-20 15:11:58\", \"updater\": \"1\"}, {\"createDate\": \"2026-07-20 15:11:58\", \"creator\": \"1\", \"fields\": \"[{\\\"key\\\": \\\"model_dir\\\", \\\"type\\\": \\\"string\\\", \\\"label\\\": \\\"模型目录\\\"}, {\\\"key\\\": \\\"output_dir\\\", \\\"type\\\": \\\"string\\\", \\\"label\\\": \\\"输出目录\\\"}]\", \"id\": \"SYSTEM_ASR_SherpaASR\", \"modelType\": \"ASR\", \"name\": \"SherpaASR语音识别\", \"providerCode\": \"sherpa_onnx_local\", \"sort\": 2, \"updateDate\": \"2026-07-20 15:11:58\", \"updater\": \"1\"}, {\"createDate\": \"2026-07-20 15:11:59\", \"creator\": \"1\", \"fields\": \"[{\\\"key\\\": \\\"appid\\\", \\\"type\\\": \\\"string\\\", \\\"label\\\": \\\"应用I" + } + }, + { + "name": "POST /models/provider", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10034, + "msg": "modelType不能为空", + "data": null + }, + "body_sha256": "7ba4eaf9c279eb33fb300d22779cdd750ceb2375df6def08d4c4451d75daeec5", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"modelType不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "56" + }, + "body": { + "code": 10034, + "msg": "modelType不能为空", + "data": null + }, + "body_sha256": "7ba4eaf9c279eb33fb300d22779cdd750ceb2375df6def08d4c4451d75daeec5", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"modelType不能为空\"}" + } + }, + { + "name": "PUT /models/provider", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10034, + "msg": "id不能为空", + "data": null + }, + "body_sha256": "2f546a643929d1beb75dab24cfffc758af29f22a36840772ec74b6061b565607", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"id不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "49" + }, + "body": { + "code": 10034, + "msg": "id不能为空", + "data": null + }, + "body_sha256": "2f546a643929d1beb75dab24cfffc758af29f22a36840772ec74b6061b565607", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"id不能为空\"}" + } + }, + { + "name": "POST /models/provider/delete", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "GET /models/provider/plugin/names", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": [ + { + "id": "SYSTEM_PLUGIN_CALL_DEVICE", + "modelType": "Plugin", + "providerCode": "call_device", + "name": "设备呼叫设备", + "fields": "[]", + "sort": 85, + "updater": "1988490863118454785", + "updateDate": "2026-05-18 12:00:00", + "creator": "1988490863118454785", + "createDate": "2026-05-18 12:00:00" + }, + { + "id": "SYSTEM_PLUGIN_HA_GET_STATE", + "modelType": "Plugin", + "providerCode": "hass_get_state", + "name": "HomeAssistant设备状态查询", + "fields": "[{\"key\": \"base_url\", \"type\": \"string\", \"label\": \"HA 服务器地址\", \"default\": \"http://homeassistant.local:8123\"}, {\"key\": \"api_key\", \"type\": \"string\", \"label\": \"HA API 访问令牌\", \"default\": \"你的home assistant api访问令牌\"}, {\"key\": \"devices\", \"type\": \"array\", \"label\": \"设备列表(名称,实体ID;…)\", \"default\": \"客厅,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1;卧室,台灯,switch.iot_cn_831898993_socn1_on_p_2_1\"}]", + "sort": 50, + "updater": "0", + "updateDate": "2026-07-20 15:11:58", + "creator": "0", + "createDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_PLUGIN_HA_PLAY_MUSIC", + "modelType": "Plugin", + "providerCode": "hass_play_music", + "name": "HomeAssistant音乐播放", + "fields": "[]", + "sort": 70, + "updater": "0", + "updateDate": "2026-07-20 15:11:58", + "creator": "0", + "createDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_PLUGIN_HA_SET_STATE", + "modelType": "Plugin", + "providerCode": "hass_set_state", + "name": "HomeAssistant设备状态修改", + "fields": "[]", + "sort": 60, + "updater": "0", + "updateDate": "2026-07-20 15:11:58", + "creator": "0", + "createDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_PLUGIN_MUSIC", + "modelType": "Plugin", + "providerCode": "play_music", + "name": "服务器音乐播放", + "fields": "[]", + "sort": 20, + "updater": "0", + "updateDate": "2026-07-20 15:11:58", + "creator": "0", + "createDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_PLUGIN_NEWS_CHINANEWS", + "modelType": "Plugin", + "providerCode": "get_news_from_chinanews", + "name": "中新网新闻", + "fields": "[{\"key\": \"default_rss_url\", \"type\": \"string\", \"label\": \"默认 RSS 源\", \"default\": \"https://www.chinanews.com.cn/rss/society.xml\"}, {\"key\": \"society_rss_url\", \"type\": \"string\", \"label\": \"社会新闻 RSS 地址\", \"default\": \"https://www.chinanews.com.cn/rss/society.xml\"}, {\"key\": \"world_rss_url\", \"type\": \"string\", \"label\": \"国际新闻 RSS 地址\", \"default\": \"https://www.chinanews.com.cn/rss/world.xml\"}, {\"key\": \"finance_rss_url\", \"type\": \"string\", \"label\": \"财经新闻 RSS 地址\", \"default\": \"https://www.chinanews.com.cn/rss/finance.xml\"}]", + "sort": 30, + "updater": "0", + "updateDate": "2026-07-20 15:11:58", + "creator": "0", + "createDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_PLUGIN_NEWS_NEWSNOW", + "modelType": "Plugin", + "providerCode": "get_news_from_newsnow", + "name": "newsnow新闻聚合", + "fields": "[{\"key\": \"url\", \"type\": \"string\", \"label\": \"接口地址\", \"default\": \"https://newsnow.busiyi.world/api/s?id=\"}, {\"key\": \"news_sources\", \"type\": \"string\", \"label\": \"新闻源配置\", \"default\": \"澎湃新闻;百度热搜;财联社\"}]", + "sort": 40, + "updater": "0", + "updateDate": "2026-07-20 15:11:58", + "creator": "0", + "createDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_PLUGIN_WEATHER", + "modelType": "Plugin", + "providerCode": "get_weather", + "name": "天气查询", + "fields": "[{\"key\": \"api_key\", \"type\": \"string\", \"label\": \"天气插件 API 密钥\", \"default\": \"a861d0d5e7bf4ee1a83d9a9e4f96d4da\"}, {\"key\": \"default_location\", \"type\": \"string\", \"label\": \"默认查询城市\", \"default\": \"广州\"}, {\"key\": \"api_host\", \"type\": \"string\", \"label\": \"开发者 API Host\", \"default\": \"mj7p3y7naa.re.qweatherapi.com\"}]", + "sort": 10, + "updater": "0", + "updateDate": "2026-07-20 15:11:58", + "creator": "0", + "createDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_PLUGIN_WEB_SEARCH", + "modelType": "Plugin", + "providerCode": "web_search", + "name": "联网搜索", + "fields": "[{\"key\": \"provider\", \"type\": \"string\", \"label\": \"搜索源:metaso / tavily\", \"default\": \"metaso\", \"editing\": false, \"selected\": false}, {\"key\": \"description\", \"type\": \"string\", \"label\": \"工具描述\", \"default\": \"联网搜索工具。当用户明确需要联网搜索问题时使用此工具。\", \"editing\": false, \"selected\": false}, {\"key\": \"max_results\", \"type\": \"string\", \"label\": \"返回数量\", \"default\": \"5\", \"editing\": false, \"selected\": false}, {\"key\": \"api_key\", \"type\": \"string\", \"label\": \"apiKey\", \"default\": \"mk-XXXX\", \"editing\": false, \"selected\": false}]", + "sort": 80, + "updater": "1988490863118454785", + "updateDate": "2026-05-12 10:55:49", + "creator": "1988490863118454785", + "createDate": "2026-05-11 17:15:35" + } + ] + }, + "body_sha256": "a50032dc4164c048e846507997954606d1266093958c2fe17e7ec724018c974c", + "body_excerpt": "{\"code\": 0, \"data\": [{\"createDate\": \"2026-05-18 12:00:00\", \"creator\": \"1988490863118454785\", \"fields\": \"[]\", \"id\": \"SYSTEM_PLUGIN_CALL_DEVICE\", \"modelType\": \"Plugin\", \"name\": \"设备呼叫设备\", \"providerCode\": \"call_device\", \"sort\": 85, \"updateDate\": \"2026-05-18 12:00:00\", \"updater\": \"1988490863118454785\"}, {\"createDate\": \"2026-07-20 15:11:58\", \"creator\": \"0\", \"fields\": \"[{\\\"key\\\": \\\"base_url\\\", \\\"type\\\": \\\"string\\\", \\\"label\\\": \\\"HA 服务器地址\\\", \\\"default\\\": \\\"http://homeassistant.local:8123\\\"}, {\\\"key\\\": \\\"api_key\\\", \\\"type\\\": \\\"string\\\", \\\"label\\\": \\\"HA API 访问令牌\\\", \\\"default\\\": \\\"你的home assistant api访问令牌\\\"}, {\\\"key\\\": \\\"devices\\\", \\\"type\\\": \\\"array\\\", \\\"label\\\": \\\"设备列表(名称,实体ID;…)\\\", \\\"default\\\": \\\"客厅,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1;卧室,台灯,switch.iot_cn_831898993_socn1_on_p_2_1\\\"}]\", \"id\": \"SYSTEM_PLUGIN_HA_GET_STATE\", \"modelType\": \"Plugin\", \"name\": \"HomeAssistant设备状态查询\", \"providerCode\": \"hass_get_state\", \"sort\": 50, \"updateDate\": \"2026-07-20 15:11:58\", \"updater\": \"0\"}, {\"createDate\": \"20" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "4670" + }, + "body": { + "code": 0, + "msg": "success", + "data": [ + { + "id": "SYSTEM_PLUGIN_CALL_DEVICE", + "modelType": "Plugin", + "providerCode": "call_device", + "name": "设备呼叫设备", + "fields": "[]", + "sort": 85, + "creator": "1988490863118454785", + "createDate": "2026-05-18 12:00:00", + "updater": "1988490863118454785", + "updateDate": "2026-05-18 12:00:00" + }, + { + "id": "SYSTEM_PLUGIN_HA_GET_STATE", + "modelType": "Plugin", + "providerCode": "hass_get_state", + "name": "HomeAssistant设备状态查询", + "fields": "[{\"key\": \"base_url\", \"type\": \"string\", \"label\": \"HA 服务器地址\", \"default\": \"http://homeassistant.local:8123\"}, {\"key\": \"api_key\", \"type\": \"string\", \"label\": \"HA API 访问令牌\", \"default\": \"你的home assistant api访问令牌\"}, {\"key\": \"devices\", \"type\": \"array\", \"label\": \"设备列表(名称,实体ID;…)\", \"default\": \"客厅,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1;卧室,台灯,switch.iot_cn_831898993_socn1_on_p_2_1\"}]", + "sort": 50, + "creator": "0", + "createDate": "2026-07-20 15:11:58", + "updater": "0", + "updateDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_PLUGIN_HA_PLAY_MUSIC", + "modelType": "Plugin", + "providerCode": "hass_play_music", + "name": "HomeAssistant音乐播放", + "fields": "[]", + "sort": 70, + "creator": "0", + "createDate": "2026-07-20 15:11:58", + "updater": "0", + "updateDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_PLUGIN_HA_SET_STATE", + "modelType": "Plugin", + "providerCode": "hass_set_state", + "name": "HomeAssistant设备状态修改", + "fields": "[]", + "sort": 60, + "creator": "0", + "createDate": "2026-07-20 15:11:58", + "updater": "0", + "updateDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_PLUGIN_MUSIC", + "modelType": "Plugin", + "providerCode": "play_music", + "name": "服务器音乐播放", + "fields": "[]", + "sort": 20, + "creator": "0", + "createDate": "2026-07-20 15:11:58", + "updater": "0", + "updateDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_PLUGIN_NEWS_CHINANEWS", + "modelType": "Plugin", + "providerCode": "get_news_from_chinanews", + "name": "中新网新闻", + "fields": "[{\"key\": \"default_rss_url\", \"type\": \"string\", \"label\": \"默认 RSS 源\", \"default\": \"https://www.chinanews.com.cn/rss/society.xml\"}, {\"key\": \"society_rss_url\", \"type\": \"string\", \"label\": \"社会新闻 RSS 地址\", \"default\": \"https://www.chinanews.com.cn/rss/society.xml\"}, {\"key\": \"world_rss_url\", \"type\": \"string\", \"label\": \"国际新闻 RSS 地址\", \"default\": \"https://www.chinanews.com.cn/rss/world.xml\"}, {\"key\": \"finance_rss_url\", \"type\": \"string\", \"label\": \"财经新闻 RSS 地址\", \"default\": \"https://www.chinanews.com.cn/rss/finance.xml\"}]", + "sort": 30, + "creator": "0", + "createDate": "2026-07-20 15:11:58", + "updater": "0", + "updateDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_PLUGIN_NEWS_NEWSNOW", + "modelType": "Plugin", + "providerCode": "get_news_from_newsnow", + "name": "newsnow新闻聚合", + "fields": "[{\"key\": \"url\", \"type\": \"string\", \"label\": \"接口地址\", \"default\": \"https://newsnow.busiyi.world/api/s?id=\"}, {\"key\": \"news_sources\", \"type\": \"string\", \"label\": \"新闻源配置\", \"default\": \"澎湃新闻;百度热搜;财联社\"}]", + "sort": 40, + "creator": "0", + "createDate": "2026-07-20 15:11:58", + "updater": "0", + "updateDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_PLUGIN_WEATHER", + "modelType": "Plugin", + "providerCode": "get_weather", + "name": "天气查询", + "fields": "[{\"key\": \"api_key\", \"type\": \"string\", \"label\": \"天气插件 API 密钥\", \"default\": \"a861d0d5e7bf4ee1a83d9a9e4f96d4da\"}, {\"key\": \"default_location\", \"type\": \"string\", \"label\": \"默认查询城市\", \"default\": \"广州\"}, {\"key\": \"api_host\", \"type\": \"string\", \"label\": \"开发者 API Host\", \"default\": \"mj7p3y7naa.re.qweatherapi.com\"}]", + "sort": 10, + "creator": "0", + "createDate": "2026-07-20 15:11:58", + "updater": "0", + "updateDate": "2026-07-20 15:11:58" + }, + { + "id": "SYSTEM_PLUGIN_WEB_SEARCH", + "modelType": "Plugin", + "providerCode": "web_search", + "name": "联网搜索", + "fields": "[{\"key\": \"provider\", \"type\": \"string\", \"label\": \"搜索源:metaso / tavily\", \"default\": \"metaso\", \"editing\": false, \"selected\": false}, {\"key\": \"description\", \"type\": \"string\", \"label\": \"工具描述\", \"default\": \"联网搜索工具。当用户明确需要联网搜索问题时使用此工具。\", \"editing\": false, \"selected\": false}, {\"key\": \"max_results\", \"type\": \"string\", \"label\": \"返回数量\", \"default\": \"5\", \"editing\": false, \"selected\": false}, {\"key\": \"api_key\", \"type\": \"string\", \"label\": \"apiKey\", \"default\": \"mk-XXXX\", \"editing\": false, \"selected\": false}]", + "sort": 80, + "creator": "1988490863118454785", + "createDate": "2026-05-11 17:15:35", + "updater": "1988490863118454785", + "updateDate": "2026-05-12 10:55:49" + } + ] + }, + "body_sha256": "a50032dc4164c048e846507997954606d1266093958c2fe17e7ec724018c974c", + "body_excerpt": "{\"code\": 0, \"data\": [{\"createDate\": \"2026-05-18 12:00:00\", \"creator\": \"1988490863118454785\", \"fields\": \"[]\", \"id\": \"SYSTEM_PLUGIN_CALL_DEVICE\", \"modelType\": \"Plugin\", \"name\": \"设备呼叫设备\", \"providerCode\": \"call_device\", \"sort\": 85, \"updateDate\": \"2026-05-18 12:00:00\", \"updater\": \"1988490863118454785\"}, {\"createDate\": \"2026-07-20 15:11:58\", \"creator\": \"0\", \"fields\": \"[{\\\"key\\\": \\\"base_url\\\", \\\"type\\\": \\\"string\\\", \\\"label\\\": \\\"HA 服务器地址\\\", \\\"default\\\": \\\"http://homeassistant.local:8123\\\"}, {\\\"key\\\": \\\"api_key\\\", \\\"type\\\": \\\"string\\\", \\\"label\\\": \\\"HA API 访问令牌\\\", \\\"default\\\": \\\"你的home assistant api访问令牌\\\"}, {\\\"key\\\": \\\"devices\\\", \\\"type\\\": \\\"array\\\", \\\"label\\\": \\\"设备列表(名称,实体ID;…)\\\", \\\"default\\\": \\\"客厅,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1;卧室,台灯,switch.iot_cn_831898993_socn1_on_p_2_1\\\"}]\", \"id\": \"SYSTEM_PLUGIN_HA_GET_STATE\", \"modelType\": \"Plugin\", \"name\": \"HomeAssistant设备状态查询\", \"providerCode\": \"hass_get_state\", \"sort\": 50, \"updateDate\": \"2026-07-20 15:11:58\", \"updater\": \"0\"}, {\"createDate\": \"20" + } + }, + { + "name": "DELETE /models/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "38" + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + } + }, + { + "name": "GET /models/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "38" + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + } + }, + { + "name": "GET /models/{modelId}/voices", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "38" + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + } + }, + { + "name": "GET /models/{modelType}/provideTypes", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": [] + }, + "body_sha256": "4bf59427c9480fa127a52080c89e8f56420bf728f234d868f1c6511dd1709a9e", + "body_excerpt": "{\"code\": 0, \"data\": [], \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "36" + }, + "body": { + "code": 0, + "msg": "success", + "data": [] + }, + "body_sha256": "4bf59427c9480fa127a52080c89e8f56420bf728f234d868f1c6511dd1709a9e", + "body_excerpt": "{\"code\": 0, \"data\": [], \"msg\": \"success\"}" + } + }, + { + "name": "POST /models/{modelType}/{provideCode}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10162, + "msg": "模型供应器不存在", + "data": null + }, + "body_sha256": "2f1b0bedafc90cfcc166e62c76406b586a41db8faa79018e0ec6f1be791e04e5", + "body_excerpt": "{\"code\": 10162, \"data\": null, \"msg\": \"模型供应器不存在\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "59" + }, + "body": { + "code": 10162, + "msg": "模型供应器不存在", + "data": null + }, + "body_sha256": "2f1b0bedafc90cfcc166e62c76406b586a41db8faa79018e0ec6f1be791e04e5", + "body_excerpt": "{\"code\": 10162, \"data\": null, \"msg\": \"模型供应器不存在\"}" + } + }, + { + "name": "PUT /models/{modelType}/{provideCode}/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10162, + "msg": "模型供应器不存在", + "data": null + }, + "body_sha256": "2f1b0bedafc90cfcc166e62c76406b586a41db8faa79018e0ec6f1be791e04e5", + "body_excerpt": "{\"code\": 10162, \"data\": null, \"msg\": \"模型供应器不存在\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "59" + }, + "body": { + "code": 10162, + "msg": "模型供应器不存在", + "data": null + }, + "body_sha256": "2f1b0bedafc90cfcc166e62c76406b586a41db8faa79018e0ec6f1be791e04e5", + "body_excerpt": "{\"code\": 10162, \"data\": null, \"msg\": \"模型供应器不存在\"}" + } + }, + { + "name": "GET /ota/", + "category": "authenticated-route:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "text/plain;charset=UTF-8", + "content-disposition": null, + "content-length": "49" + }, + "body": "OTA接口运行正常,websocket集群数量:1", + "body_sha256": "328287942eb320af0a21856fb6ccdeb1947ebe8f689ad6843654230856e0b101", + "body_excerpt": "\"OTA接口运行正常,websocket集群数量:1\"" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "text/plain;charset=UTF-8", + "content-disposition": null, + "content-length": "49" + }, + "body": "OTA接口运行正常,websocket集群数量:1", + "body_sha256": "328287942eb320af0a21856fb6ccdeb1947ebe8f689ad6843654230856e0b101", + "body_excerpt": "\"OTA接口运行正常,websocket集群数量:1\"" + } + }, + { + "name": "POST /ota/", + "category": "authenticated-route:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "POST /ota/activate", + "category": "authenticated-route:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "GET /otaMag", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 0, + "list": [] + } + }, + "body_sha256": "740b5f3bbe48cd7f6957e7edaaf0186e07a9c903d2c13bfa54799ef7ef36557d", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [], \"total\": 0}, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "55" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 0, + "list": [] + } + }, + "body_sha256": "740b5f3bbe48cd7f6957e7edaaf0186e07a9c903d2c13bfa54799ef7ef36557d", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [], \"total\": 0}, \"msg\": \"success\"}" + } + }, + { + "name": "POST /otaMag", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "固件名称不能为空", + "data": null + }, + "body_sha256": "9a75620baec93908d2123af5c418ba8cde2eba770f41e33c757a02ae54a94736", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"固件名称不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "57" + }, + "body": { + "code": 500, + "msg": "固件名称不能为空", + "data": null + }, + "body_sha256": "9a75620baec93908d2123af5c418ba8cde2eba770f41e33c757a02ae54a94736", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"固件名称不能为空\"}" + } + }, + { + "name": "GET /otaMag/download/{uuid}", + "category": "authenticated-route:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 404, + "headers": { + "content-type": null, + "content-disposition": null, + "content-length": "0" + }, + "body": "", + "body_sha256": "12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "body_excerpt": "\"\"" + }, + "fastapi": { + "status": 404, + "headers": { + "content-type": null, + "content-disposition": null, + "content-length": "0" + }, + "body": "", + "body_sha256": "12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "body_excerpt": "\"\"" + } + }, + { + "name": "GET /otaMag/getDownloadUrl/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true, + "java:uuid_v4": true, + "fastapi:uuid_v4": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": "" + }, + "body_sha256": "96b6d9efff047192d7c3392789c41c448881356bf9340af156f507243d0a87ee", + "body_excerpt": "{\"code\": 0, \"data\": \"\", \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "72" + }, + "body": { + "code": 0, + "msg": "success", + "data": "" + }, + "body_sha256": "96b6d9efff047192d7c3392789c41c448881356bf9340af156f507243d0a87ee", + "body_excerpt": "{\"code\": 0, \"data\": \"\", \"msg\": \"success\"}" + } + }, + { + "name": "POST /otaMag/upload", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "POST /otaMag/uploadAssetsBin", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "DELETE /otaMag/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "38" + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + } + }, + { + "name": "GET /otaMag/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "38" + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + } + }, + { + "name": "PUT /otaMag/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "38" + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + } + }, + { + "name": "GET /ttsVoice", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "音色的tts主键不能为空", + "data": null + }, + "body_sha256": "3a4bce25cad126865d7b2a5d20d8364251d1ae19ab582776e31d85831cbd1df6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"音色的tts主键不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "63" + }, + "body": { + "code": 500, + "msg": "音色的tts主键不能为空", + "data": null + }, + "body_sha256": "3a4bce25cad126865d7b2a5d20d8364251d1ae19ab582776e31d85831cbd1df6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"音色的tts主键不能为空\"}" + } + }, + { + "name": "POST /ttsVoice", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "音色的编码不能为空", + "data": null + }, + "body_sha256": "ed40b75f1c513f01c8041c3fd5f16c53aa7533275b6c60071526a2267106d0aa", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"音色的编码不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "60" + }, + "body": { + "code": 500, + "msg": "音色的编码不能为空", + "data": null + }, + "body_sha256": "ed40b75f1c513f01c8041c3fd5f16c53aa7533275b6c60071526a2267106d0aa", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"音色的编码不能为空\"}" + } + }, + { + "name": "POST /ttsVoice/delete", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "PUT /ttsVoice/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "音色的名称不能为空", + "data": null + }, + "body_sha256": "98b738036cc5a81d88dc7ad97aaf26d1ab402f69493fe5ea1a3423cea39a28a1", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"音色的名称不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "60" + }, + "body": { + "code": 500, + "msg": "音色的名称不能为空", + "data": null + }, + "body_sha256": "98b738036cc5a81d88dc7ad97aaf26d1ab402f69493fe5ea1a3423cea39a28a1", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"音色的名称不能为空\"}" + } + }, + { + "name": "GET /user/captcha", + "category": "authenticated-route:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10006, + "msg": "唯一标识不能为空", + "data": null + }, + "body_sha256": "2398fd58b0eb89f9523328054ad97a9189ca3a861ab0ab2088a3a2a37272e514", + "body_excerpt": "{\"code\": 10006, \"data\": null, \"msg\": \"唯一标识不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "59" + }, + "body": { + "code": 10006, + "msg": "唯一标识不能为空", + "data": null + }, + "body_sha256": "2398fd58b0eb89f9523328054ad97a9189ca3a861ab0ab2088a3a2a37272e514", + "body_excerpt": "{\"code\": 10006, \"data\": null, \"msg\": \"唯一标识不能为空\"}" + } + }, + { + "name": "PUT /user/change-password", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "密码不能为空", + "data": null + }, + "body_sha256": "628c86fa6242cd547b7e027f8a81fb40bbbc6203263006abeef38b82498029e2", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"密码不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "51" + }, + "body": { + "code": 500, + "msg": "密码不能为空", + "data": null + }, + "body_sha256": "628c86fa6242cd547b7e027f8a81fb40bbbc6203263006abeef38b82498029e2", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"密码不能为空\"}" + } + }, + { + "name": "GET /user/info", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "id": "9007199254740993", + "username": "contract-admin", + "superAdmin": 1, + "token": "", + "status": 1 + } + }, + "body_sha256": "b3a85a17bf6cc8e39dab402eea0267f50c25887134468125fe1890d4e8972358", + "body_excerpt": "{\"code\": 0, \"data\": {\"id\": \"9007199254740993\", \"status\": 1, \"superAdmin\": 1, \"token\": \"\", \"username\": \"contract-admin\"}, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "144" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "id": "9007199254740993", + "username": "contract-admin", + "superAdmin": 1, + "token": "", + "status": 1 + } + }, + "body_sha256": "b3a85a17bf6cc8e39dab402eea0267f50c25887134468125fe1890d4e8972358", + "body_excerpt": "{\"code\": 0, \"data\": {\"id\": \"9007199254740993\", \"status\": 1, \"superAdmin\": 1, \"token\": \"\", \"username\": \"contract-admin\"}, \"msg\": \"success\"}" + } + }, + { + "name": "POST /user/login", + "category": "authenticated-route:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10130, + "msg": "SM2解密失败", + "data": null + }, + "body_sha256": "19eb0ba73f880f3d881a4611322314d3aba0c9950a9fa5eefbf6ae651cd1085b", + "body_excerpt": "{\"code\": 10130, \"data\": null, \"msg\": \"SM2解密失败\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "50" + }, + "body": { + "code": 10130, + "msg": "SM2解密失败", + "data": null + }, + "body_sha256": "19eb0ba73f880f3d881a4611322314d3aba0c9950a9fa5eefbf6ae651cd1085b", + "body_excerpt": "{\"code\": 10130, \"data\": null, \"msg\": \"SM2解密失败\"}" + } + }, + { + "name": "GET /user/pub-config", + "category": "authenticated-route:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "beianIcpNum": "null", + "systemWebMenu": { + "groups": { + "voiceManagement": [ + "vad", + "asr" + ], + "featureManagement": [ + "voiceprintRecognition", + "voiceClone", + "knowledgeBase", + "mcpAccessPoint" + ] + }, + "features": { + "asr": { + "name": "feature.asr.name", + "enabled": true, + "description": "feature.asr.description" + }, + "vad": { + "name": "feature.vad.name", + "enabled": true, + "description": "feature.vad.description" + }, + "voiceClone": { + "name": "feature.voiceClone.name", + "enabled": false, + "description": "feature.voiceClone.description" + }, + "knowledgeBase": { + "name": "feature.knowledgeBase.name", + "enabled": false, + "description": "feature.knowledgeBase.description" + }, + "mcpAccessPoint": { + "name": "feature.mcpAccessPoint.name", + "enabled": false, + "description": "feature.mcpAccessPoint.description" + }, + "voiceprintRecognition": { + "name": "feature.voiceprintRecognition.name", + "enabled": false, + "description": "feature.voiceprintRecognition.description" + } + }, + "addressBook": { + "name": "feature.addressBook.name", + "enabled": false, + "description": "feature.addressBook.description" + } + }, + "enableMobileRegister": false, + "year": "©2026", + "beianGaNum": "null", + "name": "xiaozhi-esp32-server", + "sm2PublicKey": "04409d3dc074e2b0217ee931ba5d167d85fed556ac5bf5a1c2ca0c294ab57e15b7aa55b06fd49bb337b23f88e1feaebd9ae4a5206c72ad8832f40c1a0374f83999", + "allowUserRegister": false, + "version": "0.9.5", + "mobileAreaList": [ + { + "name": "中国大陆", + "key": "+86" + }, + { + "name": "中国香港", + "key": "+852" + }, + { + "name": "中国澳门", + "key": "+853" + }, + { + "name": "中国台湾", + "key": "+886" + }, + { + "name": "美国/加拿大", + "key": "+1" + }, + { + "name": "英国", + "key": "+44" + }, + { + "name": "法国", + "key": "+33" + }, + { + "name": "意大利", + "key": "+39" + }, + { + "name": "德国", + "key": "+49" + }, + { + "name": "波兰", + "key": "+48" + }, + { + "name": "瑞士", + "key": "+41" + }, + { + "name": "西班牙", + "key": "+34" + }, + { + "name": "丹麦", + "key": "+45" + }, + { + "name": "马来西亚", + "key": "+60" + }, + { + "name": "澳大利亚", + "key": "+61" + }, + { + "name": "印度尼西亚", + "key": "+62" + }, + { + "name": "菲律宾", + "key": "+63" + }, + { + "name": "新西兰", + "key": "+64" + }, + { + "name": "新加坡", + "key": "+65" + }, + { + "name": "泰国", + "key": "+66" + }, + { + "name": "日本", + "key": "+81" + }, + { + "name": "韩国", + "key": "+82" + }, + { + "name": "越南", + "key": "+84" + }, + { + "name": "印度", + "key": "+91" + }, + { + "name": "巴基斯坦", + "key": "+92" + }, + { + "name": "尼日利亚", + "key": "+234" + }, + { + "name": "孟加拉国", + "key": "+880" + }, + { + "name": "沙特阿拉伯", + "key": "+966" + }, + { + "name": "阿联酋", + "key": "+971" + }, + { + "name": "巴西", + "key": "+55" + }, + { + "name": "墨西哥", + "key": "+52" + }, + { + "name": "智利", + "key": "+56" + }, + { + "name": "阿根廷", + "key": "+54" + }, + { + "name": "埃及", + "key": "+20" + }, + { + "name": "南非", + "key": "+27" + }, + { + "name": "肯尼亚", + "key": "+254" + }, + { + "name": "坦桑尼亚", + "key": "+255" + }, + { + "name": "哈萨克斯坦", + "key": "+7" + } + ] + } + }, + "body_sha256": "c41809c50c6e6bef5e7695d1755d9d6a9cb391d884d2dafb45c32fe410ce096f", + "body_excerpt": "{\"code\": 0, \"data\": {\"allowUserRegister\": false, \"beianGaNum\": \"null\", \"beianIcpNum\": \"null\", \"enableMobileRegister\": false, \"mobileAreaList\": [{\"key\": \"+86\", \"name\": \"中国大陆\"}, {\"key\": \"+852\", \"name\": \"中国香港\"}, {\"key\": \"+853\", \"name\": \"中国澳门\"}, {\"key\": \"+886\", \"name\": \"中国台湾\"}, {\"key\": \"+1\", \"name\": \"美国/加拿大\"}, {\"key\": \"+44\", \"name\": \"英国\"}, {\"key\": \"+33\", \"name\": \"法国\"}, {\"key\": \"+39\", \"name\": \"意大利\"}, {\"key\": \"+49\", \"name\": \"德国\"}, {\"key\": \"+48\", \"name\": \"波兰\"}, {\"key\": \"+41\", \"name\": \"瑞士\"}, {\"key\": \"+34\", \"name\": \"西班牙\"}, {\"key\": \"+45\", \"name\": \"丹麦\"}, {\"key\": \"+60\", \"name\": \"马来西亚\"}, {\"key\": \"+61\", \"name\": \"澳大利亚\"}, {\"key\": \"+62\", \"name\": \"印度尼西亚\"}, {\"key\": \"+63\", \"name\": \"菲律宾\"}, {\"key\": \"+64\", \"name\": \"新西兰\"}, {\"key\": \"+65\", \"name\": \"新加坡\"}, {\"key\": \"+66\", \"name\": \"泰国\"}, {\"key\": \"+81\", \"name\": \"日本\"}, {\"key\": \"+82\", \"name\": \"韩国\"}, {\"key\": \"+84\", \"name\": \"越南\"}, {\"key\": \"+91\", \"name\": \"印度\"}, {\"key\": \"+92\", \"name\": \"巴基斯坦\"}, {\"key\": \"+234\", \"name\": \"尼日利亚\"}, {\"key\": \"+880\", \"name\": \"孟加拉国\"}, {\"key\": \"+96" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "2589" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "enableMobileRegister": false, + "version": "0.9.5", + "year": "©2026", + "allowUserRegister": false, + "mobileAreaList": [ + { + "name": "中国大陆", + "key": "+86" + }, + { + "name": "中国香港", + "key": "+852" + }, + { + "name": "中国澳门", + "key": "+853" + }, + { + "name": "中国台湾", + "key": "+886" + }, + { + "name": "美国/加拿大", + "key": "+1" + }, + { + "name": "英国", + "key": "+44" + }, + { + "name": "法国", + "key": "+33" + }, + { + "name": "意大利", + "key": "+39" + }, + { + "name": "德国", + "key": "+49" + }, + { + "name": "波兰", + "key": "+48" + }, + { + "name": "瑞士", + "key": "+41" + }, + { + "name": "西班牙", + "key": "+34" + }, + { + "name": "丹麦", + "key": "+45" + }, + { + "name": "马来西亚", + "key": "+60" + }, + { + "name": "澳大利亚", + "key": "+61" + }, + { + "name": "印度尼西亚", + "key": "+62" + }, + { + "name": "菲律宾", + "key": "+63" + }, + { + "name": "新西兰", + "key": "+64" + }, + { + "name": "新加坡", + "key": "+65" + }, + { + "name": "泰国", + "key": "+66" + }, + { + "name": "日本", + "key": "+81" + }, + { + "name": "韩国", + "key": "+82" + }, + { + "name": "越南", + "key": "+84" + }, + { + "name": "印度", + "key": "+91" + }, + { + "name": "巴基斯坦", + "key": "+92" + }, + { + "name": "尼日利亚", + "key": "+234" + }, + { + "name": "孟加拉国", + "key": "+880" + }, + { + "name": "沙特阿拉伯", + "key": "+966" + }, + { + "name": "阿联酋", + "key": "+971" + }, + { + "name": "巴西", + "key": "+55" + }, + { + "name": "墨西哥", + "key": "+52" + }, + { + "name": "智利", + "key": "+56" + }, + { + "name": "阿根廷", + "key": "+54" + }, + { + "name": "埃及", + "key": "+20" + }, + { + "name": "南非", + "key": "+27" + }, + { + "name": "肯尼亚", + "key": "+254" + }, + { + "name": "坦桑尼亚", + "key": "+255" + }, + { + "name": "哈萨克斯坦", + "key": "+7" + } + ], + "beianIcpNum": "null", + "beianGaNum": "null", + "name": "xiaozhi-esp32-server", + "sm2PublicKey": "04409d3dc074e2b0217ee931ba5d167d85fed556ac5bf5a1c2ca0c294ab57e15b7aa55b06fd49bb337b23f88e1feaebd9ae4a5206c72ad8832f40c1a0374f83999", + "systemWebMenu": { + "groups": { + "voiceManagement": [ + "vad", + "asr" + ], + "featureManagement": [ + "voiceprintRecognition", + "voiceClone", + "knowledgeBase", + "mcpAccessPoint" + ] + }, + "features": { + "asr": { + "name": "feature.asr.name", + "enabled": true, + "description": "feature.asr.description" + }, + "vad": { + "name": "feature.vad.name", + "enabled": true, + "description": "feature.vad.description" + }, + "voiceClone": { + "name": "feature.voiceClone.name", + "enabled": false, + "description": "feature.voiceClone.description" + }, + "knowledgeBase": { + "name": "feature.knowledgeBase.name", + "enabled": false, + "description": "feature.knowledgeBase.description" + }, + "mcpAccessPoint": { + "name": "feature.mcpAccessPoint.name", + "enabled": false, + "description": "feature.mcpAccessPoint.description" + }, + "voiceprintRecognition": { + "name": "feature.voiceprintRecognition.name", + "enabled": false, + "description": "feature.voiceprintRecognition.description" + } + }, + "addressBook": { + "name": "feature.addressBook.name", + "enabled": false, + "description": "feature.addressBook.description" + } + } + } + }, + "body_sha256": "c41809c50c6e6bef5e7695d1755d9d6a9cb391d884d2dafb45c32fe410ce096f", + "body_excerpt": "{\"code\": 0, \"data\": {\"allowUserRegister\": false, \"beianGaNum\": \"null\", \"beianIcpNum\": \"null\", \"enableMobileRegister\": false, \"mobileAreaList\": [{\"key\": \"+86\", \"name\": \"中国大陆\"}, {\"key\": \"+852\", \"name\": \"中国香港\"}, {\"key\": \"+853\", \"name\": \"中国澳门\"}, {\"key\": \"+886\", \"name\": \"中国台湾\"}, {\"key\": \"+1\", \"name\": \"美国/加拿大\"}, {\"key\": \"+44\", \"name\": \"英国\"}, {\"key\": \"+33\", \"name\": \"法国\"}, {\"key\": \"+39\", \"name\": \"意大利\"}, {\"key\": \"+49\", \"name\": \"德国\"}, {\"key\": \"+48\", \"name\": \"波兰\"}, {\"key\": \"+41\", \"name\": \"瑞士\"}, {\"key\": \"+34\", \"name\": \"西班牙\"}, {\"key\": \"+45\", \"name\": \"丹麦\"}, {\"key\": \"+60\", \"name\": \"马来西亚\"}, {\"key\": \"+61\", \"name\": \"澳大利亚\"}, {\"key\": \"+62\", \"name\": \"印度尼西亚\"}, {\"key\": \"+63\", \"name\": \"菲律宾\"}, {\"key\": \"+64\", \"name\": \"新西兰\"}, {\"key\": \"+65\", \"name\": \"新加坡\"}, {\"key\": \"+66\", \"name\": \"泰国\"}, {\"key\": \"+81\", \"name\": \"日本\"}, {\"key\": \"+82\", \"name\": \"韩国\"}, {\"key\": \"+84\", \"name\": \"越南\"}, {\"key\": \"+91\", \"name\": \"印度\"}, {\"key\": \"+92\", \"name\": \"巴基斯坦\"}, {\"key\": \"+234\", \"name\": \"尼日利亚\"}, {\"key\": \"+880\", \"name\": \"孟加拉国\"}, {\"key\": \"+96" + } + }, + { + "name": "POST /user/register", + "category": "authenticated-route:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10072, + "msg": "当前不允许普通用户注册", + "data": null + }, + "body_sha256": "6fbd96767be5228be43ce185547260fe96fde887df7ece3a4596b1041a124983", + "body_excerpt": "{\"code\": 10072, \"data\": null, \"msg\": \"当前不允许普通用户注册\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "68" + }, + "body": { + "code": 10072, + "msg": "当前不允许普通用户注册", + "data": null + }, + "body_sha256": "6fbd96767be5228be43ce185547260fe96fde887df7ece3a4596b1041a124983", + "body_excerpt": "{\"code\": 10072, \"data\": null, \"msg\": \"当前不允许普通用户注册\"}" + } + }, + { + "name": "PUT /user/retrieve-password", + "category": "authenticated-route:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10073, + "msg": "没有开启手机注册,没法使用找回密码功能", + "data": null + }, + "body_sha256": "96dd7aa17d432b1a9cc3ffbaff141b1453c6b1e5d7b3fdf8f699927f04d7862c", + "body_excerpt": "{\"code\": 10073, \"data\": null, \"msg\": \"没有开启手机注册,没法使用找回密码功能\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "92" + }, + "body": { + "code": 10073, + "msg": "没有开启手机注册,没法使用找回密码功能", + "data": null + }, + "body_sha256": "96dd7aa17d432b1a9cc3ffbaff141b1453c6b1e5d7b3fdf8f699927f04d7862c", + "body_excerpt": "{\"code\": 10073, \"data\": null, \"msg\": \"没有开启手机注册,没法使用找回密码功能\"}" + } + }, + { + "name": "POST /user/smsVerification", + "category": "authenticated-route:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10067, + "msg": "图形验证码错误", + "data": null + }, + "body_sha256": "da2861cb1f17cfa9c099c79e95ced39c41e2a99fdfce117731645046ad95546f", + "body_excerpt": "{\"code\": 10067, \"data\": null, \"msg\": \"图形验证码错误\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "56" + }, + "body": { + "code": 10067, + "msg": "图形验证码错误", + "data": null + }, + "body_sha256": "da2861cb1f17cfa9c099c79e95ced39c41e2a99fdfce117731645046ad95546f", + "body_excerpt": "{\"code\": 10067, \"data\": null, \"msg\": \"图形验证码错误\"}" + } + }, + { + "name": "GET /voiceClone", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 0, + "list": [] + } + }, + "body_sha256": "740b5f3bbe48cd7f6957e7edaaf0186e07a9c903d2c13bfa54799ef7ef36557d", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [], \"total\": 0}, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "55" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 0, + "list": [] + } + }, + "body_sha256": "740b5f3bbe48cd7f6957e7edaaf0186e07a9c903d2c13bfa54799ef7ef36557d", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [], \"total\": 0}, \"msg\": \"success\"}" + } + }, + { + "name": "POST /voiceClone/audio/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10144, + "msg": "声音克隆记录不存在", + "data": null + }, + "body_sha256": "19ddfce6eb2a08fe6d802254754ceeba98a8fb4c0964c9be181b1877d67eec78", + "body_excerpt": "{\"code\": 10144, \"data\": null, \"msg\": \"声音克隆记录不存在\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "62" + }, + "body": { + "code": 10144, + "msg": "声音克隆记录不存在", + "data": null + }, + "body_sha256": "19ddfce6eb2a08fe6d802254754ceeba98a8fb4c0964c9be181b1877d67eec78", + "body_excerpt": "{\"code\": 10144, \"data\": null, \"msg\": \"声音克隆记录不存在\"}" + } + }, + { + "name": "POST /voiceClone/cloneAudio", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10144, + "msg": "声音克隆记录不存在", + "data": null + }, + "body_sha256": "19ddfce6eb2a08fe6d802254754ceeba98a8fb4c0964c9be181b1877d67eec78", + "body_excerpt": "{\"code\": 10144, \"data\": null, \"msg\": \"声音克隆记录不存在\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "62" + }, + "body": { + "code": 10144, + "msg": "声音克隆记录不存在", + "data": null + }, + "body_sha256": "19ddfce6eb2a08fe6d802254754ceeba98a8fb4c0964c9be181b1877d67eec78", + "body_excerpt": "{\"code\": 10144, \"data\": null, \"msg\": \"声音克隆记录不存在\"}" + } + }, + { + "name": "GET /voiceClone/play/{uuid}", + "category": "authenticated-route:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 404, + "headers": { + "content-type": null, + "content-disposition": null, + "content-length": "0" + }, + "body": "", + "body_sha256": "12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "body_excerpt": "\"\"" + }, + "fastapi": { + "status": 404, + "headers": { + "content-type": null, + "content-disposition": null, + "content-length": "0" + }, + "body": "", + "body_sha256": "12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "body_excerpt": "\"\"" + } + }, + { + "name": "POST /voiceClone/updateName", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10006, + "msg": "唯一标识不能为空", + "data": null + }, + "body_sha256": "2398fd58b0eb89f9523328054ad97a9189ca3a861ab0ab2088a3a2a37272e514", + "body_excerpt": "{\"code\": 10006, \"data\": null, \"msg\": \"唯一标识不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "59" + }, + "body": { + "code": 10006, + "msg": "唯一标识不能为空", + "data": null + }, + "body_sha256": "2398fd58b0eb89f9523328054ad97a9189ca3a861ab0ab2088a3a2a37272e514", + "body_excerpt": "{\"code\": 10006, \"data\": null, \"msg\": \"唯一标识不能为空\"}" + } + }, + { + "name": "POST /voiceClone/upload", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "GET /voiceResource", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 0, + "list": [] + } + }, + "body_sha256": "740b5f3bbe48cd7f6957e7edaaf0186e07a9c903d2c13bfa54799ef7ef36557d", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [], \"total\": 0}, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "55" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 0, + "list": [] + } + }, + "body_sha256": "740b5f3bbe48cd7f6957e7edaaf0186e07a9c903d2c13bfa54799ef7ef36557d", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [], \"total\": 0}, \"msg\": \"success\"}" + } + }, + { + "name": "POST /voiceResource", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10146, + "msg": "平台名称不能为空", + "data": null + }, + "body_sha256": "0fccbf2b192b125a413010d2bf42bdfe973c0bc29576188a61524c7d34b921b5", + "body_excerpt": "{\"code\": 10146, \"data\": null, \"msg\": \"平台名称不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "59" + }, + "body": { + "code": 10146, + "msg": "平台名称不能为空", + "data": null + }, + "body_sha256": "0fccbf2b192b125a413010d2bf42bdfe973c0bc29576188a61524c7d34b921b5", + "body_excerpt": "{\"code\": 10146, \"data\": null, \"msg\": \"平台名称不能为空\"}" + } + }, + { + "name": "GET /voiceResource/ttsPlatforms", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": [ + { + "modelName": "豆包语音合成2.0(流式)", + "id": "TTS_HSDSTTS_V2" + }, + { + "modelName": "火山引擎(流式)", + "id": "TTS_HuoshanDoubleStreamTTS" + } + ] + }, + "body_sha256": "f51dac89cad786580b33af6576327cce75e49d16d77fa1f15684970a86d8b19e", + "body_excerpt": "{\"code\": 0, \"data\": [{\"id\": \"TTS_HSDSTTS_V2\", \"modelName\": \"豆包语音合成2.0(流式)\"}, {\"id\": \"TTS_HuoshanDoubleStreamTTS\", \"modelName\": \"火山引擎(流式)\"}], \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "174" + }, + "body": { + "code": 0, + "msg": "success", + "data": [ + { + "id": "TTS_HSDSTTS_V2", + "modelName": "豆包语音合成2.0(流式)" + }, + { + "id": "TTS_HuoshanDoubleStreamTTS", + "modelName": "火山引擎(流式)" + } + ] + }, + "body_sha256": "f51dac89cad786580b33af6576327cce75e49d16d77fa1f15684970a86d8b19e", + "body_excerpt": "{\"code\": 0, \"data\": [{\"id\": \"TTS_HSDSTTS_V2\", \"modelName\": \"豆包语音合成2.0(流式)\"}, {\"id\": \"TTS_HuoshanDoubleStreamTTS\", \"modelName\": \"火山引擎(流式)\"}], \"msg\": \"success\"}" + } + }, + { + "name": "GET /voiceResource/user/{userId}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "DELETE /voiceResource/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "38" + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + } + }, + { + "name": "GET /voiceResource/{id}", + "category": "authenticated-route:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "38" + }, + "body": { + "code": 0, + "msg": "success", + "data": null + }, + "body_sha256": "5d8799a8be1b9c24ca46738ef6fc169204dad21141d2a336bd023037222d9d48", + "body_excerpt": "{\"code\": 0, \"data\": null, \"msg\": \"success\"}" + } + } + ], + "coverage": { + "java_routes": 154, + "request_profile": "authenticated-safe-business-or-validation", + "side_effect_policy": "no intentional successful writes" + } +} diff --git a/main/manager-api-fastapi/compatibility/consumer-routes.json b/main/manager-api-fastapi/compatibility/consumer-routes.json new file mode 100644 index 00000000..8ad4bfca --- /dev/null +++ b/main/manager-api-fastapi/compatibility/consumer-routes.json @@ -0,0 +1,1164 @@ +{ + "count": 188, + "uniqueRoutes": 140, + "consumers": { + "manager-web": { + "callSites": 134, + "uniqueRoutes": 130, + "methods": { + "DELETE": 12, + "GET": 59, + "POST": 40, + "PUT": 23 + } + }, + "manager-mobile": { + "callSites": 46, + "uniqueRoutes": 40, + "methods": { + "DELETE": 3, + "GET": 26, + "POST": 12, + "PUT": 5 + } + }, + "xiaozhi-server": { + "callSites": 8, + "uniqueRoutes": 8, + "methods": { + "GET": 2, + "POST": 6 + } + } + }, + "calls": [ + { + "consumer": "manager-mobile", + "method": "PUT", + "path": "/agent/{id}", + "source": "main/manager-mobile/src/api/agent/agent.ts:108" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/models/provider/plugin/names", + "source": "main/manager-mobile/src/api/agent/agent.ts:121" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/agent/mcp/address/{agentId}", + "source": "main/manager-mobile/src/api/agent/agent.ts:134" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/agent/mcp/tools/{agentId}", + "source": "main/manager-mobile/src/api/agent/agent.ts:145" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/agent/voice-print/list/{agentId}", + "source": "main/manager-mobile/src/api/agent/agent.ts:158" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/agent/{id}", + "source": "main/manager-mobile/src/api/agent/agent.ts:16" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/agent/{agentId}/chat-history/user", + "source": "main/manager-mobile/src/api/agent/agent.ts:171" + }, + { + "consumer": "manager-mobile", + "method": "POST", + "path": "/agent/voice-print", + "source": "main/manager-mobile/src/api/agent/agent.ts:184" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/agent/{agentId}/tags", + "source": "main/manager-mobile/src/api/agent/agent.ts:194" + }, + { + "consumer": "manager-mobile", + "method": "PUT", + "path": "/agent/{agentId}/tags", + "source": "main/manager-mobile/src/api/agent/agent.ts:207" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/models/{modelId}/voices", + "source": "main/manager-mobile/src/api/agent/agent.ts:217" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/agent/{agentId}/snapshots", + "source": "main/manager-mobile/src/api/agent/agent.ts:230" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/agent/{agentId}/snapshots/{snapshotId}", + "source": "main/manager-mobile/src/api/agent/agent.ts:244" + }, + { + "consumer": "manager-mobile", + "method": "POST", + "path": "/agent/{agentId}/snapshots/{snapshotId}/restore", + "source": "main/manager-mobile/src/api/agent/agent.ts:257" + }, + { + "consumer": "manager-mobile", + "method": "DELETE", + "path": "/agent/{agentId}/snapshots/{snapshotId}", + "source": "main/manager-mobile/src/api/agent/agent.ts:267" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/correct-word/file/select", + "source": "main/manager-mobile/src/api/agent/agent.ts:277" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/agent/template", + "source": "main/manager-mobile/src/api/agent/agent.ts:29" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/models/names", + "source": "main/manager-mobile/src/api/agent/agent.ts:42" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/agent/list", + "source": "main/manager-mobile/src/api/agent/agent.ts:59" + }, + { + "consumer": "manager-mobile", + "method": "POST", + "path": "/agent", + "source": "main/manager-mobile/src/api/agent/agent.ts:72" + }, + { + "consumer": "manager-mobile", + "method": "DELETE", + "path": "/agent/{id}", + "source": "main/manager-mobile/src/api/agent/agent.ts:82" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/models/{ttsModelId}/voices", + "source": "main/manager-mobile/src/api/agent/agent.ts:92" + }, + { + "consumer": "manager-mobile", + "method": "POST", + "path": "/user/smsVerification", + "source": "main/manager-mobile/src/api/auth.ts:109" + }, + { + "consumer": "manager-mobile", + "method": "POST", + "path": "/user/register", + "source": "main/manager-mobile/src/api/auth.ts:119" + }, + { + "consumer": "manager-mobile", + "method": "PUT", + "path": "/user/retrieve-password", + "source": "main/manager-mobile/src/api/auth.ts:137" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/user/captcha", + "source": "main/manager-mobile/src/api/auth.ts:27" + }, + { + "consumer": "manager-mobile", + "method": "POST", + "path": "/user/login", + "source": "main/manager-mobile/src/api/auth.ts:38" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/user/info", + "source": "main/manager-mobile/src/api/auth.ts:75" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/user/pub-config", + "source": "main/manager-mobile/src/api/auth.ts:85" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/agent/{agentId}/sessions", + "source": "main/manager-mobile/src/api/chat-history/chat-history.ts:14" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/agent/{agentId}/chat-history/{sessionId}", + "source": "main/manager-mobile/src/api/chat-history/chat-history.ts:32" + }, + { + "consumer": "manager-mobile", + "method": "POST", + "path": "/agent/audio/{audioId}", + "source": "main/manager-mobile/src/api/chat-history/chat-history.ts:48" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/device/bind/{agentId}", + "source": "main/manager-mobile/src/api/device/device.ts:16" + }, + { + "consumer": "manager-mobile", + "method": "POST", + "path": "/device/bind/{agentId}/{code}", + "source": "main/manager-mobile/src/api/device/device.ts:33" + }, + { + "consumer": "manager-mobile", + "method": "POST", + "path": "/device/manual-add", + "source": "main/manager-mobile/src/api/device/device.ts:49" + }, + { + "consumer": "manager-mobile", + "method": "PUT", + "path": "/device/update/{deviceId}", + "source": "main/manager-mobile/src/api/device/device.ts:58" + }, + { + "consumer": "manager-mobile", + "method": "POST", + "path": "/device/unbind", + "source": "main/manager-mobile/src/api/device/device.ts:68" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/admin/dict/data/type/FIRMWARE_TYPE", + "source": "main/manager-mobile/src/api/device/device.ts:8" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/agent/voice-print/list/{agentId}", + "source": "main/manager-mobile/src/api/voiceprint/voiceprint.ts:10" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/agent/{agentId}/chat-history/user", + "source": "main/manager-mobile/src/api/voiceprint/voiceprint.ts:23" + }, + { + "consumer": "manager-mobile", + "method": "POST", + "path": "/agent/voice-print", + "source": "main/manager-mobile/src/api/voiceprint/voiceprint.ts:36" + }, + { + "consumer": "manager-mobile", + "method": "DELETE", + "path": "/agent/voice-print/{id}", + "source": "main/manager-mobile/src/api/voiceprint/voiceprint.ts:46" + }, + { + "consumer": "manager-mobile", + "method": "PUT", + "path": "/agent/voice-print", + "source": "main/manager-mobile/src/api/voiceprint/voiceprint.ts:56" + }, + { + "consumer": "manager-mobile", + "method": "POST", + "path": "/agent/audio/{audioId}", + "source": "main/manager-mobile/src/api/voiceprint/voiceprint.ts:66" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/user/pub-config", + "source": "main/manager-mobile/src/pages/settings/index.vue:128" + }, + { + "consumer": "manager-mobile", + "method": "GET", + "path": "/api/ping", + "source": "main/manager-mobile/src/pages/settings/index.vue:87" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/device/address-book/{macAddress}", + "source": "main/manager-web/src/apis/module/addressBook.js:10" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/device/address-book/alias", + "source": "main/manager-web/src/apis/module/addressBook.js:28" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/device/address-book/permission", + "source": "main/manager-web/src/apis/module/addressBook.js:47" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/admin/params", + "source": "main/manager-web/src/apis/module/admin.js:106" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/admin/params/delete", + "source": "main/manager-web/src/apis/module/admin.js:127" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/admin/server/server-list", + "source": "main/manager-web/src/apis/module/admin.js:144" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/admin/users", + "source": "main/manager-web/src/apis/module/admin.js:15" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/admin/server/emit-action", + "source": "main/manager-web/src/apis/module/admin.js:160" + }, + { + "consumer": "manager-web", + "method": "DELETE", + "path": "/admin/users/{id}", + "source": "main/manager-web/src/apis/module/admin.js:31" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/admin/users/{id}", + "source": "main/manager-web/src/apis/module/admin.js:47" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/admin/params/page", + "source": "main/manager-web/src/apis/module/admin.js:69" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/admin/params", + "source": "main/manager-web/src/apis/module/admin.js:85" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/agent/{agentId}", + "source": "main/manager-web/src/apis/module/agent.js:118" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/agent/{agentId}/snapshots", + "source": "main/manager-web/src/apis/module/agent.js:135" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/agent/{agentId}/snapshots/{snapshotId}", + "source": "main/manager-web/src/apis/module/agent.js:164" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/agent/{agentId}/snapshots/{snapshotId}/restore", + "source": "main/manager-web/src/apis/module/agent.js:191" + }, + { + "consumer": "manager-web", + "method": "DELETE", + "path": "/agent/{agentId}/snapshots/{snapshotId}", + "source": "main/manager-web/src/apis/module/agent.js:206" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/agent/template", + "source": "main/manager-web/src/apis/module/agent.js:220" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/agent/template/page", + "source": "main/manager-web/src/apis/module/agent.js:237" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/agent/{agentId}/sessions", + "source": "main/manager-web/src/apis/module/agent.js:254" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/agent/{agentId}/chat-history/{sessionId}", + "source": "main/manager-web/src/apis/module/agent.js:270" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/agent/audio/{audioId}", + "source": "main/manager-web/src/apis/module/agent.js:285" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/agent/mcp/address/{agentId}", + "source": "main/manager-web/src/apis/module/agent.js:300" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/agent/mcp/tools/{agentId}", + "source": "main/manager-web/src/apis/module/agent.js:318" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/agent/voice-print", + "source": "main/manager-web/src/apis/module/agent.js:333" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/agent/voice-print/list/{id}", + "source": "main/manager-web/src/apis/module/agent.js:349" + }, + { + "consumer": "manager-web", + "method": "DELETE", + "path": "/agent/voice-print/{id}", + "source": "main/manager-web/src/apis/module/agent.js:364" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/agent/voice-print", + "source": "main/manager-web/src/apis/module/agent.js:379" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/agent/{id}/chat-history/user", + "source": "main/manager-web/src/apis/module/agent.js:395" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/agent/{id}/chat-history/audio", + "source": "main/manager-web/src/apis/module/agent.js:410" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/agent/template", + "source": "main/manager-web/src/apis/module/agent.js:426" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/agent/list", + "source": "main/manager-web/src/apis/module/agent.js:44" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/agent/template", + "source": "main/manager-web/src/apis/module/agent.js:443" + }, + { + "consumer": "manager-web", + "method": "DELETE", + "path": "/agent/template/{id}", + "source": "main/manager-web/src/apis/module/agent.js:460" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/agent/template/batch-remove", + "source": "main/manager-web/src/apis/module/agent.js:476" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/agent/template/{templateId}", + "source": "main/manager-web/src/apis/module/agent.js:492" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/agent/chat-history/getDownloadUrl/{agentId}/{sessionId}", + "source": "main/manager-web/src/apis/module/agent.js:509" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/agent/list", + "source": "main/manager-web/src/apis/module/agent.js:525" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/agent/{agentId}/tags", + "source": "main/manager-web/src/apis/module/agent.js:541" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/agent/{agentId}/tags", + "source": "main/manager-web/src/apis/module/agent.js:567" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/agent", + "source": "main/manager-web/src/apis/module/agent.js:59" + }, + { + "consumer": "manager-web", + "method": "DELETE", + "path": "/agent/{agentId}", + "source": "main/manager-web/src/apis/module/agent.js:75" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/agent/{agentId}", + "source": "main/manager-web/src/apis/module/agent.js:91" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/correct-word/file", + "source": "main/manager-web/src/apis/module/correctWord.js:100" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/correct-word/file/{id}", + "source": "main/manager-web/src/apis/module/correctWord.js:116" + }, + { + "consumer": "manager-web", + "method": "DELETE", + "path": "/correct-word/file/{id}", + "source": "main/manager-web/src/apis/module/correctWord.js:135" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/correct-word/file/batch-delete", + "source": "main/manager-web/src/apis/module/correctWord.js:152" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/correct-word/file/list", + "source": "main/manager-web/src/apis/module/correctWord.js:34" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/correct-word/file/select", + "source": "main/manager-web/src/apis/module/correctWord.js:52" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/correct-word/file/download/{id}", + "source": "main/manager-web/src/apis/module/correctWord.js:85" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/device/unbind", + "source": "main/manager-web/src/apis/module/device.js:24" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/device/bind/{agentId}/{deviceCode}", + "source": "main/manager-web/src/apis/module/device.js:41" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/device/update/{id}", + "source": "main/manager-web/src/apis/module/device.js:56" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/device/manual-add", + "source": "main/manager-web/src/apis/module/device.js:74" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/device/bind/{agentId}", + "source": "main/manager-web/src/apis/module/device.js:8" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/device/bind/{agentId}", + "source": "main/manager-web/src/apis/module/device.js:91" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/admin/dict/data/page", + "source": "main/manager-web/src/apis/module/dict.js:116" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/admin/dict/data/{id}", + "source": "main/manager-web/src/apis/module/dict.js:134" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/admin/dict/type/page", + "source": "main/manager-web/src/apis/module/dict.js:15" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/admin/dict/data/save", + "source": "main/manager-web/src/apis/module/dict.js:152" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/admin/dict/data/update", + "source": "main/manager-web/src/apis/module/dict.js:171" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/admin/dict/data/delete", + "source": "main/manager-web/src/apis/module/dict.js:190" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/admin/dict/data/type/{dictType}", + "source": "main/manager-web/src/apis/module/dict.js:210" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/admin/dict/type/{id}", + "source": "main/manager-web/src/apis/module/dict.js:33" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/admin/dict/type/save", + "source": "main/manager-web/src/apis/module/dict.js:51" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/admin/dict/type/update", + "source": "main/manager-web/src/apis/module/dict.js:70" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/admin/dict/type/delete", + "source": "main/manager-web/src/apis/module/dict.js:89" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/datasets/{datasetId}", + "source": "main/manager-web/src/apis/module/knowledgeBase.js:132" + }, + { + "consumer": "manager-web", + "method": "DELETE", + "path": "/datasets/{datasetId}", + "source": "main/manager-web/src/apis/module/knowledgeBase.js:154" + }, + { + "consumer": "manager-web", + "method": "DELETE", + "path": "/datasets/batch", + "source": "main/manager-web/src/apis/module/knowledgeBase.js:174" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/datasets/{datasetId}/documents", + "source": "main/manager-web/src/apis/module/knowledgeBase.js:198" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/datasets/{datasetId}/documents", + "source": "main/manager-web/src/apis/module/knowledgeBase.js:216" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/datasets/{datasetId}/chunks", + "source": "main/manager-web/src/apis/module/knowledgeBase.js:240" + }, + { + "consumer": "manager-web", + "method": "DELETE", + "path": "/datasets/{datasetId}/documents/{documentId}", + "source": "main/manager-web/src/apis/module/knowledgeBase.js:260" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/datasets/{datasetId}/documents/{documentId}/chunks", + "source": "main/manager-web/src/apis/module/knowledgeBase.js:289" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/datasets/{datasetId}/retrieval-test", + "source": "main/manager-web/src/apis/module/knowledgeBase.js:307" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/datasets", + "source": "main/manager-web/src/apis/module/knowledgeBase.js:77" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/datasets", + "source": "main/manager-web/src/apis/module/knowledgeBase.js:97" + }, + { + "consumer": "manager-web", + "method": "DELETE", + "path": "/models/{id}", + "source": "main/manager-web/src/apis/module/model.js:100" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/models/names", + "source": "main/manager-web/src/apis/module/model.js:118" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/models/llm/names", + "source": "main/manager-web/src/apis/module/model.js:153" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/models/{modelId}/voices", + "source": "main/manager-web/src/apis/module/model.js:190" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/models/{id}", + "source": "main/manager-web/src/apis/module/model.js:223" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/models/enable/{id}/{status}", + "source": "main/manager-web/src/apis/module/model.js:240" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/models/{modelType}/{provideCode}/{id}", + "source": "main/manager-web/src/apis/module/model.js:262" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/models/default/{id}", + "source": "main/manager-web/src/apis/module/model.js:280" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/models/provider", + "source": "main/manager-web/src/apis/module/model.js:309" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/models/provider", + "source": "main/manager-web/src/apis/module/model.js:338" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/models/list", + "source": "main/manager-web/src/apis/module/model.js:35" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/models/provider", + "source": "main/manager-web/src/apis/module/model.js:370" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/models/provider/delete", + "source": "main/manager-web/src/apis/module/model.js:387" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/models/provider/plugin/names", + "source": "main/manager-web/src/apis/module/model.js:405" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/datasets/rag-models", + "source": "main/manager-web/src/apis/module/model.js:441" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/models/{modelType}/provideTypes", + "source": "main/manager-web/src/apis/module/model.js:51" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/models/{modelType}/{provideCode}", + "source": "main/manager-web/src/apis/module/model.js:82" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/otaMag/getDownloadUrl/{id}", + "source": "main/manager-web/src/apis/module/ota.js:110" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/otaMag/{id}", + "source": "main/manager-web/src/apis/module/ota.js:25" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/otaMag", + "source": "main/manager-web/src/apis/module/ota.js:41" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/otaMag/{id}", + "source": "main/manager-web/src/apis/module/ota.js:58" + }, + { + "consumer": "manager-web", + "method": "DELETE", + "path": "/otaMag/{id}", + "source": "main/manager-web/src/apis/module/ota.js:75" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/otaMag", + "source": "main/manager-web/src/apis/module/ota.js:8" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/otaMag/upload", + "source": "main/manager-web/src/apis/module/ota.js:93" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/ttsVoice", + "source": "main/manager-web/src/apis/module/timbre.js:15" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/ttsVoice", + "source": "main/manager-web/src/apis/module/timbre.js:31" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/ttsVoice/delete", + "source": "main/manager-web/src/apis/module/timbre.js:57" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/ttsVoice/{id}", + "source": "main/manager-web/src/apis/module/timbre.js:74" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/user/info", + "source": "main/manager-web/src/apis/module/user.js:105" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/user/change-password", + "source": "main/manager-web/src/apis/module/user.js:121" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/admin/users/changeStatus/{status}", + "source": "main/manager-web/src/apis/module/user.js:142" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/user/pub-config", + "source": "main/manager-web/src/apis/module/user.js:159" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/user/retrieve-password", + "source": "main/manager-web/src/apis/module/user.js:181" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/user/captcha", + "source": "main/manager-web/src/apis/module/user.js:29" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/user/smsVerification", + "source": "main/manager-web/src/apis/module/user.js:48" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/user/register", + "source": "main/manager-web/src/apis/module/user.js:68" + }, + { + "consumer": "manager-web", + "method": "PUT", + "path": "/user/configDevice/{device_id}", + "source": "main/manager-web/src/apis/module/user.js:88" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/user/login", + "source": "main/manager-web/src/apis/module/user.js:9" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/voiceClone/upload", + "source": "main/manager-web/src/apis/module/voiceClone.js:26" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/voiceClone/updateName", + "source": "main/manager-web/src/apis/module/voiceClone.js:44" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/voiceClone/audio/{id}", + "source": "main/manager-web/src/apis/module/voiceClone.js:62" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/voiceClone/play/{uuid}", + "source": "main/manager-web/src/apis/module/voiceClone.js:78" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/voiceClone", + "source": "main/manager-web/src/apis/module/voiceClone.js:8" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/voiceClone/cloneAudio", + "source": "main/manager-web/src/apis/module/voiceClone.js:84" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/voiceResource/{id}", + "source": "main/manager-web/src/apis/module/voiceResource.js:25" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/voiceResource", + "source": "main/manager-web/src/apis/module/voiceResource.js:41" + }, + { + "consumer": "manager-web", + "method": "DELETE", + "path": "/voiceResource/{ids}", + "source": "main/manager-web/src/apis/module/voiceResource.js:58" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/voiceResource/user/{userId}", + "source": "main/manager-web/src/apis/module/voiceResource.js:74" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/voiceResource", + "source": "main/manager-web/src/apis/module/voiceResource.js:8" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/voiceResource/ttsPlatforms", + "source": "main/manager-web/src/apis/module/voiceResource.js:90" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/agent/play/{data}", + "source": "main/manager-web/src/components/ChatHistoryDialog.vue:361" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/agent/chat-history/download/{uuid}/current", + "source": "main/manager-web/src/components/ChatHistoryDialog.vue:391" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/agent/chat-history/download/{uuid}/previous", + "source": "main/manager-web/src/components/ChatHistoryDialog.vue:403" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/agent/play/{data}", + "source": "main/manager-web/src/components/VoicePrintDialog.vue:123" + }, + { + "consumer": "manager-web", + "method": "POST", + "path": "/voiceClone/audio/{id}", + "source": "main/manager-web/src/views/roleConfig.vue:1633" + }, + { + "consumer": "manager-web", + "method": "GET", + "path": "/voiceClone/play/{audioId}", + "source": "main/manager-web/src/views/roleConfig.vue:1641" + }, + { + "consumer": "xiaozhi-server", + "method": "POST", + "path": "/config/server-base", + "source": "main/xiaozhi-server/config/manage_api_client.py:168" + }, + { + "consumer": "xiaozhi-server", + "method": "POST", + "path": "/config/agent-models", + "source": "main/xiaozhi-server/config/manage_api_client.py:178" + }, + { + "consumer": "xiaozhi-server", + "method": "POST", + "path": "/config/correct-words", + "source": "main/xiaozhi-server/config/manage_api_client.py:191" + }, + { + "consumer": "xiaozhi-server", + "method": "POST", + "path": "/agent/chat-summary/{session_id}/save", + "source": "main/xiaozhi-server/config/manage_api_client.py:204" + }, + { + "consumer": "xiaozhi-server", + "method": "POST", + "path": "/agent/chat-title/{session_id}/generate", + "source": "main/xiaozhi-server/config/manage_api_client.py:216" + }, + { + "consumer": "xiaozhi-server", + "method": "POST", + "path": "/agent/chat-history/report", + "source": "main/xiaozhi-server/config/manage_api_client.py:232" + }, + { + "consumer": "xiaozhi-server", + "method": "GET", + "path": "/device/address-book/lookup", + "source": "main/xiaozhi-server/config/manage_api_client.py:256" + }, + { + "consumer": "xiaozhi-server", + "method": "GET", + "path": "/device/address-book/call", + "source": "main/xiaozhi-server/plugins_func/functions/call_device.py:75" + } + ] +} diff --git a/main/manager-api-fastapi/compatibility/contract-results.json b/main/manager-api-fastapi/compatibility/contract-results.json new file mode 100644 index 00000000..6f292f1f --- /dev/null +++ b/main/manager-api-fastapi/compatibility/contract-results.json @@ -0,0 +1,2905 @@ +{ + "schema_version": 1, + "generated_at": "2026-07-20T07:12:31.520810+00:00", + "services": { + "java": "http://127.0.0.1:18082/xiaozhi", + "fastapi": "http://127.0.0.1:18083/xiaozhi" + }, + "fixture_ids": { + "admin": "9007199254740993", + "normal": "9007199254740994" + }, + "summary": { + "total": 49, + "passed": 49, + "failed": 0, + "skipped": 0 + }, + "categories": { + "configuration": { + "passed": 1, + "failed": 0 + }, + "authentication-i18n": { + "passed": 7, + "failed": 0 + }, + "authentication": { + "passed": 1, + "failed": 0 + }, + "authorization": { + "passed": 1, + "failed": 0 + }, + "serialization": { + "passed": 2, + "failed": 0 + }, + "agent": { + "passed": 1, + "failed": 0 + }, + "device": { + "passed": 1, + "failed": 0 + }, + "model": { + "passed": 1, + "failed": 0 + }, + "correct-word": { + "passed": 1, + "failed": 0 + }, + "binary-download": { + "passed": 6, + "failed": 0 + }, + "validation": { + "passed": 5, + "failed": 0 + }, + "server-secret": { + "passed": 3, + "failed": 0 + }, + "ota": { + "passed": 1, + "failed": 0 + }, + "ota-validation": { + "passed": 2, + "failed": 0 + }, + "ota-signing": { + "passed": 2, + "failed": 0 + }, + "activation": { + "passed": 3, + "failed": 0 + }, + "external-mock": { + "passed": 2, + "failed": 0 + }, + "crud": { + "passed": 3, + "failed": 0 + }, + "database-side-effect": { + "passed": 4, + "failed": 0 + }, + "upload": { + "passed": 1, + "failed": 0 + }, + "upload-validation": { + "passed": 1, + "failed": 0 + } + }, + "results": [ + { + "name": "public configuration", + "category": "configuration", + "passed": true, + "checks": { + "http_status": true, + "body": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "beianIcpNum": "null", + "systemWebMenu": { + "groups": { + "voiceManagement": [ + "vad", + "asr" + ], + "featureManagement": [ + "voiceprintRecognition", + "voiceClone", + "knowledgeBase", + "mcpAccessPoint" + ] + }, + "features": { + "asr": { + "name": "feature.asr.name", + "enabled": true, + "description": "feature.asr.description" + }, + "vad": { + "name": "feature.vad.name", + "enabled": true, + "description": "feature.vad.description" + }, + "voiceClone": { + "name": "feature.voiceClone.name", + "enabled": false, + "description": "feature.voiceClone.description" + }, + "knowledgeBase": { + "name": "feature.knowledgeBase.name", + "enabled": false, + "description": "feature.knowledgeBase.description" + }, + "mcpAccessPoint": { + "name": "feature.mcpAccessPoint.name", + "enabled": false, + "description": "feature.mcpAccessPoint.description" + }, + "voiceprintRecognition": { + "name": "feature.voiceprintRecognition.name", + "enabled": false, + "description": "feature.voiceprintRecognition.description" + } + }, + "addressBook": { + "name": "feature.addressBook.name", + "enabled": false, + "description": "feature.addressBook.description" + } + }, + "enableMobileRegister": false, + "year": "©2026", + "beianGaNum": "null", + "name": "xiaozhi-esp32-server", + "sm2PublicKey": "04409d3dc074e2b0217ee931ba5d167d85fed556ac5bf5a1c2ca0c294ab57e15b7aa55b06fd49bb337b23f88e1feaebd9ae4a5206c72ad8832f40c1a0374f83999", + "allowUserRegister": false, + "version": "0.9.5", + "mobileAreaList": [ + { + "name": "中国大陆", + "key": "+86" + }, + { + "name": "中国香港", + "key": "+852" + }, + { + "name": "中国澳门", + "key": "+853" + }, + { + "name": "中国台湾", + "key": "+886" + }, + { + "name": "美国/加拿大", + "key": "+1" + }, + { + "name": "英国", + "key": "+44" + }, + { + "name": "法国", + "key": "+33" + }, + { + "name": "意大利", + "key": "+39" + }, + { + "name": "德国", + "key": "+49" + }, + { + "name": "波兰", + "key": "+48" + }, + { + "name": "瑞士", + "key": "+41" + }, + { + "name": "西班牙", + "key": "+34" + }, + { + "name": "丹麦", + "key": "+45" + }, + { + "name": "马来西亚", + "key": "+60" + }, + { + "name": "澳大利亚", + "key": "+61" + }, + { + "name": "印度尼西亚", + "key": "+62" + }, + { + "name": "菲律宾", + "key": "+63" + }, + { + "name": "新西兰", + "key": "+64" + }, + { + "name": "新加坡", + "key": "+65" + }, + { + "name": "泰国", + "key": "+66" + }, + { + "name": "日本", + "key": "+81" + }, + { + "name": "韩国", + "key": "+82" + }, + { + "name": "越南", + "key": "+84" + }, + { + "name": "印度", + "key": "+91" + }, + { + "name": "巴基斯坦", + "key": "+92" + }, + { + "name": "尼日利亚", + "key": "+234" + }, + { + "name": "孟加拉国", + "key": "+880" + }, + { + "name": "沙特阿拉伯", + "key": "+966" + }, + { + "name": "阿联酋", + "key": "+971" + }, + { + "name": "巴西", + "key": "+55" + }, + { + "name": "墨西哥", + "key": "+52" + }, + { + "name": "智利", + "key": "+56" + }, + { + "name": "阿根廷", + "key": "+54" + }, + { + "name": "埃及", + "key": "+20" + }, + { + "name": "南非", + "key": "+27" + }, + { + "name": "肯尼亚", + "key": "+254" + }, + { + "name": "坦桑尼亚", + "key": "+255" + }, + { + "name": "哈萨克斯坦", + "key": "+7" + } + ] + } + }, + "body_sha256": "c41809c50c6e6bef5e7695d1755d9d6a9cb391d884d2dafb45c32fe410ce096f", + "body_excerpt": "{\"code\": 0, \"data\": {\"allowUserRegister\": false, \"beianGaNum\": \"null\", \"beianIcpNum\": \"null\", \"enableMobileRegister\": false, \"mobileAreaList\": [{\"key\": \"+86\", \"name\": \"中国大陆\"}, {\"key\": \"+852\", \"name\": \"中国香港\"}, {\"key\": \"+853\", \"name\": \"中国澳门\"}, {\"key\": \"+886\", \"name\": \"中国台湾\"}, {\"key\": \"+1\", \"name\": \"美国/加拿大\"}, {\"key\": \"+44\", \"name\": \"英国\"}, {\"key\": \"+33\", \"name\": \"法国\"}, {\"key\": \"+39\", \"name\": \"意大利\"}, {\"key\": \"+49\", \"name\": \"德国\"}, {\"key\": \"+48\", \"name\": \"波兰\"}, {\"key\": \"+41\", \"name\": \"瑞士\"}, {\"key\": \"+34\", \"name\": \"西班牙\"}, {\"key\": \"+45\", \"name\": \"丹麦\"}, {\"key\": \"+60\", \"name\": \"马来西亚\"}, {\"key\": \"+61\", \"name\": \"澳大利亚\"}, {\"key\": \"+62\", \"name\": \"印度尼西亚\"}, {\"key\": \"+63\", \"name\": \"菲律宾\"}, {\"key\": \"+64\", \"name\": \"新西兰\"}, {\"key\": \"+65\", \"name\": \"新加坡\"}, {\"key\": \"+66\", \"name\": \"泰国\"}, {\"key\": \"+81\", \"name\": \"日本\"}, {\"key\": \"+82\", \"name\": \"韩国\"}, {\"key\": \"+84\", \"name\": \"越南\"}, {\"key\": \"+91\", \"name\": \"印度\"}, {\"key\": \"+92\", \"name\": \"巴基斯坦\"}, {\"key\": \"+234\", \"name\": \"尼日利亚\"}, {\"key\": \"+880\", \"name\": \"孟加拉国\"}, {\"key\": \"+96" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "2589" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "enableMobileRegister": false, + "version": "0.9.5", + "year": "©2026", + "allowUserRegister": false, + "mobileAreaList": [ + { + "name": "中国大陆", + "key": "+86" + }, + { + "name": "中国香港", + "key": "+852" + }, + { + "name": "中国澳门", + "key": "+853" + }, + { + "name": "中国台湾", + "key": "+886" + }, + { + "name": "美国/加拿大", + "key": "+1" + }, + { + "name": "英国", + "key": "+44" + }, + { + "name": "法国", + "key": "+33" + }, + { + "name": "意大利", + "key": "+39" + }, + { + "name": "德国", + "key": "+49" + }, + { + "name": "波兰", + "key": "+48" + }, + { + "name": "瑞士", + "key": "+41" + }, + { + "name": "西班牙", + "key": "+34" + }, + { + "name": "丹麦", + "key": "+45" + }, + { + "name": "马来西亚", + "key": "+60" + }, + { + "name": "澳大利亚", + "key": "+61" + }, + { + "name": "印度尼西亚", + "key": "+62" + }, + { + "name": "菲律宾", + "key": "+63" + }, + { + "name": "新西兰", + "key": "+64" + }, + { + "name": "新加坡", + "key": "+65" + }, + { + "name": "泰国", + "key": "+66" + }, + { + "name": "日本", + "key": "+81" + }, + { + "name": "韩国", + "key": "+82" + }, + { + "name": "越南", + "key": "+84" + }, + { + "name": "印度", + "key": "+91" + }, + { + "name": "巴基斯坦", + "key": "+92" + }, + { + "name": "尼日利亚", + "key": "+234" + }, + { + "name": "孟加拉国", + "key": "+880" + }, + { + "name": "沙特阿拉伯", + "key": "+966" + }, + { + "name": "阿联酋", + "key": "+971" + }, + { + "name": "巴西", + "key": "+55" + }, + { + "name": "墨西哥", + "key": "+52" + }, + { + "name": "智利", + "key": "+56" + }, + { + "name": "阿根廷", + "key": "+54" + }, + { + "name": "埃及", + "key": "+20" + }, + { + "name": "南非", + "key": "+27" + }, + { + "name": "肯尼亚", + "key": "+254" + }, + { + "name": "坦桑尼亚", + "key": "+255" + }, + { + "name": "哈萨克斯坦", + "key": "+7" + } + ], + "beianIcpNum": "null", + "beianGaNum": "null", + "name": "xiaozhi-esp32-server", + "sm2PublicKey": "04409d3dc074e2b0217ee931ba5d167d85fed556ac5bf5a1c2ca0c294ab57e15b7aa55b06fd49bb337b23f88e1feaebd9ae4a5206c72ad8832f40c1a0374f83999", + "systemWebMenu": { + "groups": { + "voiceManagement": [ + "vad", + "asr" + ], + "featureManagement": [ + "voiceprintRecognition", + "voiceClone", + "knowledgeBase", + "mcpAccessPoint" + ] + }, + "features": { + "asr": { + "name": "feature.asr.name", + "enabled": true, + "description": "feature.asr.description" + }, + "vad": { + "name": "feature.vad.name", + "enabled": true, + "description": "feature.vad.description" + }, + "voiceClone": { + "name": "feature.voiceClone.name", + "enabled": false, + "description": "feature.voiceClone.description" + }, + "knowledgeBase": { + "name": "feature.knowledgeBase.name", + "enabled": false, + "description": "feature.knowledgeBase.description" + }, + "mcpAccessPoint": { + "name": "feature.mcpAccessPoint.name", + "enabled": false, + "description": "feature.mcpAccessPoint.description" + }, + "voiceprintRecognition": { + "name": "feature.voiceprintRecognition.name", + "enabled": false, + "description": "feature.voiceprintRecognition.description" + } + }, + "addressBook": { + "name": "feature.addressBook.name", + "enabled": false, + "description": "feature.addressBook.description" + } + } + } + }, + "body_sha256": "c41809c50c6e6bef5e7695d1755d9d6a9cb391d884d2dafb45c32fe410ce096f", + "body_excerpt": "{\"code\": 0, \"data\": {\"allowUserRegister\": false, \"beianGaNum\": \"null\", \"beianIcpNum\": \"null\", \"enableMobileRegister\": false, \"mobileAreaList\": [{\"key\": \"+86\", \"name\": \"中国大陆\"}, {\"key\": \"+852\", \"name\": \"中国香港\"}, {\"key\": \"+853\", \"name\": \"中国澳门\"}, {\"key\": \"+886\", \"name\": \"中国台湾\"}, {\"key\": \"+1\", \"name\": \"美国/加拿大\"}, {\"key\": \"+44\", \"name\": \"英国\"}, {\"key\": \"+33\", \"name\": \"法国\"}, {\"key\": \"+39\", \"name\": \"意大利\"}, {\"key\": \"+49\", \"name\": \"德国\"}, {\"key\": \"+48\", \"name\": \"波兰\"}, {\"key\": \"+41\", \"name\": \"瑞士\"}, {\"key\": \"+34\", \"name\": \"西班牙\"}, {\"key\": \"+45\", \"name\": \"丹麦\"}, {\"key\": \"+60\", \"name\": \"马来西亚\"}, {\"key\": \"+61\", \"name\": \"澳大利亚\"}, {\"key\": \"+62\", \"name\": \"印度尼西亚\"}, {\"key\": \"+63\", \"name\": \"菲律宾\"}, {\"key\": \"+64\", \"name\": \"新西兰\"}, {\"key\": \"+65\", \"name\": \"新加坡\"}, {\"key\": \"+66\", \"name\": \"泰国\"}, {\"key\": \"+81\", \"name\": \"日本\"}, {\"key\": \"+82\", \"name\": \"韩国\"}, {\"key\": \"+84\", \"name\": \"越南\"}, {\"key\": \"+91\", \"name\": \"印度\"}, {\"key\": \"+92\", \"name\": \"巴基斯坦\"}, {\"key\": \"+234\", \"name\": \"尼日利亚\"}, {\"key\": \"+880\", \"name\": \"孟加拉国\"}, {\"key\": \"+96" + } + }, + { + "name": "unauthenticated localized response [default]", + "category": "authentication-i18n", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "unauthenticated localized response [zh-CN]", + "category": "authentication-i18n", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "unauthenticated localized response [zh-TW]", + "category": "authentication-i18n", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授權", + "data": null + }, + "body_sha256": "4d01f8e3935fc54e281e4a51a2af1f17594bf01f9cfa825488a04c0c34627b65", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授權\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授權", + "data": null + }, + "body_sha256": "4d01f8e3935fc54e281e4a51a2af1f17594bf01f9cfa825488a04c0c34627b65", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授權\"}" + } + }, + { + "name": "unauthenticated localized response [en-US]", + "category": "authentication-i18n", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "45" + }, + "body": { + "code": 401, + "msg": "Unauthorized", + "data": null + }, + "body_sha256": "c5f1a8536c903848e6ada6a1cfc27d77965f02e646b7d1fe1b2e700f31966d3c", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"Unauthorized\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "45" + }, + "body": { + "code": 401, + "msg": "Unauthorized", + "data": null + }, + "body_sha256": "c5f1a8536c903848e6ada6a1cfc27d77965f02e646b7d1fe1b2e700f31966d3c", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"Unauthorized\"}" + } + }, + { + "name": "unauthenticated localized response [de-DE]", + "category": "authentication-i18n", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "50" + }, + "body": { + "code": 401, + "msg": "Nicht autorisiert", + "data": null + }, + "body_sha256": "358b8c88c55006b160615e9c3dc5022354a9d036e2cc2f05cd430e05dee9fdbb", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"Nicht autorisiert\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "50" + }, + "body": { + "code": 401, + "msg": "Nicht autorisiert", + "data": null + }, + "body_sha256": "358b8c88c55006b160615e9c3dc5022354a9d036e2cc2f05cd430e05dee9fdbb", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"Nicht autorisiert\"}" + } + }, + { + "name": "unauthenticated localized response [vi-VN]", + "category": "authentication-i18n", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "61" + }, + "body": { + "code": 401, + "msg": "Không được ủy quyền", + "data": null + }, + "body_sha256": "5e5d5fdacca91510acc9d76e9ae68324b63cab0fa926f23ca1f867c02db65a0a", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"Không được ủy quyền\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "61" + }, + "body": { + "code": 401, + "msg": "Không được ủy quyền", + "data": null + }, + "body_sha256": "5e5d5fdacca91510acc9d76e9ae68324b63cab0fa926f23ca1f867c02db65a0a", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"Không được ủy quyền\"}" + } + }, + { + "name": "unauthenticated localized response [pt-BR]", + "category": "authentication-i18n", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "48" + }, + "body": { + "code": 401, + "msg": "Não autorizado", + "data": null + }, + "body_sha256": "b3e974b1153f0a879223ec07b9b525ebf504e2294d39c3eceaae0bfb87e80dd9", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"Não autorizado\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "48" + }, + "body": { + "code": 401, + "msg": "Não autorizado", + "data": null + }, + "body_sha256": "b3e974b1153f0a879223ec07b9b525ebf504e2294d39c3eceaae0bfb87e80dd9", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"Não autorizado\"}" + } + }, + { + "name": "expired database token", + "category": "authentication", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "normal user forbidden from admin endpoint", + "category": "authorization", + "passed": true, + "checks": { + "http_status": true, + "body": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 403, + "msg": "拒绝访问,没有权限", + "data": null + }, + "body_sha256": "61729f3e91e3145e8022343a9b8ca00d6b69f1f0fbfc9e7e238713bf56ebcfbb", + "body_excerpt": "{\"code\": 403, \"data\": null, \"msg\": \"拒绝访问,没有权限\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "60" + }, + "body": { + "code": 403, + "msg": "拒绝访问,没有权限", + "data": null + }, + "body_sha256": "61729f3e91e3145e8022343a9b8ca00d6b69f1f0fbfc9e7e238713bf56ebcfbb", + "body_excerpt": "{\"code\": 403, \"data\": null, \"msg\": \"拒绝访问,没有权限\"}" + } + }, + { + "name": "Long user ID and token response", + "category": "serialization", + "passed": true, + "checks": { + "http_status": true, + "body": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "id": "9007199254740994", + "username": "contract-user", + "superAdmin": 0, + "token": "", + "status": 1 + } + }, + "body_sha256": "a489d49f9db3df1c863beb4d03c1afbc4f982c0c37864b41c26cea25550ccf61", + "body_excerpt": "{\"code\": 0, \"data\": {\"id\": \"9007199254740994\", \"status\": 1, \"superAdmin\": 0, \"token\": \"\", \"username\": \"contract-user\"}, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "144" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "id": "9007199254740994", + "username": "contract-user", + "superAdmin": 0, + "token": "", + "status": 1 + } + }, + "body_sha256": "a489d49f9db3df1c863beb4d03c1afbc4f982c0c37864b41c26cea25550ccf61", + "body_excerpt": "{\"code\": 0, \"data\": {\"id\": \"9007199254740994\", \"status\": 1, \"superAdmin\": 0, \"token\": \"\", \"username\": \"contract-user\"}, \"msg\": \"success\"}" + } + }, + { + "name": "user page Long/date/null structure", + "category": "serialization", + "passed": true, + "checks": { + "http_status": true, + "body": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 3, + "list": [ + { + "deviceCount": "0", + "mobile": "contract-admin", + "status": 1, + "userid": "9007199254740993", + "createDate": "2026-07-20 12:34:56" + }, + { + "deviceCount": "1", + "mobile": "contract-user", + "status": 1, + "userid": "9007199254740994", + "createDate": "2026-07-20 12:34:56" + }, + { + "deviceCount": "0", + "mobile": "contract-expired", + "status": 1, + "userid": "9007199254740995", + "createDate": "2026-07-20 12:34:56" + } + ] + } + }, + "body_sha256": "87d98d1726ad3cd728dfa41159bc5ae1a363011fefc7caf0b3b1660a886d910b", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [{\"createDate\": \"2026-07-20 12:34:56\", \"deviceCount\": \"0\", \"mobile\": \"contract-admin\", \"status\": 1, \"userid\": \"9007199254740993\"}, {\"createDate\": \"2026-07-20 12:34:56\", \"deviceCount\": \"1\", \"mobile\": \"contract-user\", \"status\": 1, \"userid\": \"9007199254740994\"}, {\"createDate\": \"2026-07-20 12:34:56\", \"deviceCount\": \"0\", \"mobile\": \"contract-expired\", \"status\": 1, \"userid\": \"9007199254740995\"}], \"total\": 3}, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "415" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "list": [ + { + "deviceCount": "0", + "mobile": "contract-admin", + "status": 1, + "userid": "9007199254740993", + "createDate": "2026-07-20 12:34:56" + }, + { + "deviceCount": "1", + "mobile": "contract-user", + "status": 1, + "userid": "9007199254740994", + "createDate": "2026-07-20 12:34:56" + }, + { + "deviceCount": "0", + "mobile": "contract-expired", + "status": 1, + "userid": "9007199254740995", + "createDate": "2026-07-20 12:34:56" + } + ], + "total": 3 + } + }, + "body_sha256": "87d98d1726ad3cd728dfa41159bc5ae1a363011fefc7caf0b3b1660a886d910b", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [{\"createDate\": \"2026-07-20 12:34:56\", \"deviceCount\": \"0\", \"mobile\": \"contract-admin\", \"status\": 1, \"userid\": \"9007199254740993\"}, {\"createDate\": \"2026-07-20 12:34:56\", \"deviceCount\": \"1\", \"mobile\": \"contract-user\", \"status\": 1, \"userid\": \"9007199254740994\"}, {\"createDate\": \"2026-07-20 12:34:56\", \"deviceCount\": \"0\", \"mobile\": \"contract-expired\", \"status\": 1, \"userid\": \"9007199254740995\"}], \"total\": 3}, \"msg\": \"success\"}" + } + }, + { + "name": "agent list null/date fields", + "category": "agent", + "passed": true, + "checks": { + "http_status": true, + "body": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": [ + { + "id": "contract-agent-1", + "agentName": "Contract Agent", + "ttsModelName": null, + "ttsVoiceName": null, + "llmModelName": null, + "vllmModelName": "智谱视觉AI", + "memModelId": null, + "systemPrompt": null, + "summaryMemory": null, + "lastConnectedAt": "2026-07-20 12:34:56", + "deviceCount": 1, + "tags": null + } + ] + }, + "body_sha256": "d5bf3b06646ea61af86180faa469cb6780014496dfc95f485b1a1008c8f414b1", + "body_excerpt": "{\"code\": 0, \"data\": [{\"agentName\": \"Contract Agent\", \"deviceCount\": 1, \"id\": \"contract-agent-1\", \"lastConnectedAt\": \"2026-07-20 12:34:56\", \"llmModelName\": null, \"memModelId\": null, \"summaryMemory\": null, \"systemPrompt\": null, \"tags\": null, \"ttsModelName\": null, \"ttsVoiceName\": null, \"vllmModelName\": \"智谱视觉AI\"}], \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "310" + }, + "body": { + "code": 0, + "msg": "success", + "data": [ + { + "id": "contract-agent-1", + "agentName": "Contract Agent", + "ttsModelName": null, + "ttsVoiceName": null, + "llmModelName": null, + "vllmModelName": "智谱视觉AI", + "memModelId": null, + "systemPrompt": null, + "summaryMemory": null, + "lastConnectedAt": "2026-07-20 12:34:56", + "deviceCount": 1, + "tags": null + } + ] + }, + "body_sha256": "d5bf3b06646ea61af86180faa469cb6780014496dfc95f485b1a1008c8f414b1", + "body_excerpt": "{\"code\": 0, \"data\": [{\"agentName\": \"Contract Agent\", \"deviceCount\": 1, \"id\": \"contract-agent-1\", \"lastConnectedAt\": \"2026-07-20 12:34:56\", \"llmModelName\": null, \"memModelId\": null, \"summaryMemory\": null, \"systemPrompt\": null, \"tags\": null, \"ttsModelName\": null, \"ttsVoiceName\": null, \"vllmModelName\": \"智谱视觉AI\"}], \"msg\": \"success\"}" + } + }, + { + "name": "device list UTC display and epoch fields", + "category": "device", + "passed": true, + "checks": { + "http_status": true, + "body": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": [ + { + "appVersion": "1.2.3", + "bindUserName": null, + "deviceType": "esp32s3", + "board": "esp32s3", + "id": "contract-device-1", + "macAddress": "AA:BB:CC:DD:EE:01", + "alias": null, + "otaUpgrade": null, + "recentChatTime": null, + "lastConnectedAtTimestamp": "1784522096000", + "createDateTimestamp": "1784522096000", + "createDate": "2026-07-20 04:34:56" + } + ] + }, + "body_sha256": "a686e41292ba8c11b557bc99638006340fab77df07ae9c80a9220f563baf864d", + "body_excerpt": "{\"code\": 0, \"data\": [{\"alias\": null, \"appVersion\": \"1.2.3\", \"bindUserName\": null, \"board\": \"esp32s3\", \"createDate\": \"2026-07-20 04:34:56\", \"createDateTimestamp\": \"1784522096000\", \"deviceType\": \"esp32s3\", \"id\": \"contract-device-1\", \"lastConnectedAtTimestamp\": \"1784522096000\", \"macAddress\": \"AA:BB:CC:DD:EE:01\", \"otaUpgrade\": null, \"recentChatTime\": null}], \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "346" + }, + "body": { + "code": 0, + "msg": "success", + "data": [ + { + "appVersion": "1.2.3", + "bindUserName": null, + "deviceType": "esp32s3", + "board": "esp32s3", + "id": "contract-device-1", + "macAddress": "AA:BB:CC:DD:EE:01", + "alias": null, + "otaUpgrade": null, + "recentChatTime": null, + "lastConnectedAtTimestamp": "1784522096000", + "createDateTimestamp": "1784522096000", + "createDate": "2026-07-20 04:34:56" + } + ] + }, + "body_sha256": "a686e41292ba8c11b557bc99638006340fab77df07ae9c80a9220f563baf864d", + "body_excerpt": "{\"code\": 0, \"data\": [{\"alias\": null, \"appVersion\": \"1.2.3\", \"bindUserName\": null, \"board\": \"esp32s3\", \"createDate\": \"2026-07-20 04:34:56\", \"createDateTimestamp\": \"1784522096000\", \"deviceType\": \"esp32s3\", \"id\": \"contract-device-1\", \"lastConnectedAtTimestamp\": \"1784522096000\", \"macAddress\": \"AA:BB:CC:DD:EE:01\", \"otaUpgrade\": null, \"recentChatTime\": null}], \"msg\": \"success\"}" + } + }, + { + "name": "filtered model provider page", + "category": "model", + "passed": true, + "checks": { + "http_status": true, + "body": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 1, + "list": [ + { + "id": "contract-provider", + "modelType": "LLM", + "providerCode": "contract", + "name": "Contract Provider", + "fields": "[{\"key\": \"api_key\", \"type\": \"string\", \"label\": \"API Key\"}]", + "sort": 9, + "updater": "9007199254740993", + "updateDate": "2026-07-20 12:34:56", + "creator": "9007199254740993", + "createDate": "2026-07-20 12:34:56" + } + ] + } + }, + "body_sha256": "96c276b24b360b652a2637368bcf43017f06047974eebfa90e69612fe22f53df", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [{\"createDate\": \"2026-07-20 12:34:56\", \"creator\": \"9007199254740993\", \"fields\": \"[{\\\"key\\\": \\\"api_key\\\", \\\"type\\\": \\\"string\\\", \\\"label\\\": \\\"API Key\\\"}]\", \"id\": \"contract-provider\", \"modelType\": \"LLM\", \"name\": \"Contract Provider\", \"providerCode\": \"contract\", \"sort\": 9, \"updateDate\": \"2026-07-20 12:34:56\", \"updater\": \"9007199254740993\"}], \"total\": 1}, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "371" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 1, + "list": [ + { + "id": "contract-provider", + "modelType": "LLM", + "providerCode": "contract", + "name": "Contract Provider", + "fields": "[{\"key\": \"api_key\", \"type\": \"string\", \"label\": \"API Key\"}]", + "sort": 9, + "creator": "9007199254740993", + "createDate": "2026-07-20 12:34:56", + "updater": "9007199254740993", + "updateDate": "2026-07-20 12:34:56" + } + ] + } + }, + "body_sha256": "96c276b24b360b652a2637368bcf43017f06047974eebfa90e69612fe22f53df", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [{\"createDate\": \"2026-07-20 12:34:56\", \"creator\": \"9007199254740993\", \"fields\": \"[{\\\"key\\\": \\\"api_key\\\", \\\"type\\\": \\\"string\\\", \\\"label\\\": \\\"API Key\\\"}]\", \"id\": \"contract-provider\", \"modelType\": \"LLM\", \"name\": \"Contract Provider\", \"providerCode\": \"contract\", \"sort\": 9, \"updateDate\": \"2026-07-20 12:34:56\", \"updater\": \"9007199254740993\"}], \"total\": 1}, \"msg\": \"success\"}" + } + }, + { + "name": "correct-word page", + "category": "correct-word", + "passed": true, + "checks": { + "http_status": true, + "body": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 1, + "list": [ + { + "id": "contract-words", + "fileName": "合同词表.txt", + "wordCount": 2, + "content": [ + "小智,小志", + "ESP32,esp32" + ], + "createdAt": "2026-07-20 12:34:56", + "updatedAt": "2026-07-20 12:34:56" + } + ] + } + }, + "body_sha256": "79287e1c218fce90088787f6a6467100120fdfc592a4a6ba2267aa0a58ed2200", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [{\"content\": [\"小智,小志\", \"ESP32,esp32\"], \"createdAt\": \"2026-07-20 12:34:56\", \"fileName\": \"合同词表.txt\", \"id\": \"contract-words\", \"updatedAt\": \"2026-07-20 12:34:56\", \"wordCount\": 2}], \"total\": 1}, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "232" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "total": 1, + "list": [ + { + "id": "contract-words", + "fileName": "合同词表.txt", + "wordCount": 2, + "content": [ + "小智,小志", + "ESP32,esp32" + ], + "createdAt": "2026-07-20 12:34:56", + "updatedAt": "2026-07-20 12:34:56" + } + ] + } + }, + "body_sha256": "79287e1c218fce90088787f6a6467100120fdfc592a4a6ba2267aa0a58ed2200", + "body_excerpt": "{\"code\": 0, \"data\": {\"list\": [{\"content\": [\"小智,小志\", \"ESP32,esp32\"], \"createdAt\": \"2026-07-20 12:34:56\", \"fileName\": \"合同词表.txt\", \"id\": \"contract-words\", \"updatedAt\": \"2026-07-20 12:34:56\", \"wordCount\": 2}], \"total\": 1}, \"msg\": \"success\"}" + } + }, + { + "name": "correct-word binary download headers and bytes", + "category": "binary-download", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true, + "header:content-disposition": true, + "header:content-length": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/octet-stream", + "content-disposition": "attachment; filename=\"____.txt\"; filename*=UTF-8''%E5%90%88%E5%90%8C%E8%AF%8D%E8%A1%A8.txt", + "content-length": "25" + }, + "body": "小智,小志\nESP32,esp32", + "body_sha256": "2d7a34c5282e77b6efe94797118cbe860c652b4e01c42a966e3611f71e22e2d6", + "body_excerpt": "\"小智,小志\\nESP32,esp32\"" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/octet-stream", + "content-disposition": "attachment; filename=\"____.txt\"; filename*=UTF-8''%E5%90%88%E5%90%8C%E8%AF%8D%E8%A1%A8.txt", + "content-length": "25" + }, + "body": "小智,小志\nESP32,esp32", + "body_sha256": "2d7a34c5282e77b6efe94797118cbe860c652b4e01c42a966e3611f71e22e2d6", + "body_excerpt": "\"小智,小志\\nESP32,esp32\"" + } + }, + { + "name": "minimum boundary validation", + "category": "validation", + "passed": true, + "checks": { + "http_status": true, + "body": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10034, + "msg": "最小不能小于0", + "data": null + }, + "body_sha256": "546388597c1749ab4659f233c87632589e28022d653f16b6a14dd9aee95e7772", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"最小不能小于0\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 10034, + "msg": "最小不能小于0", + "data": null + }, + "body_sha256": "546388597c1749ab4659f233c87632589e28022d653f16b6a14dd9aee95e7772", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"最小不能小于0\"}" + } + }, + { + "name": "maximum boundary validation", + "category": "validation", + "passed": true, + "checks": { + "http_status": true, + "body": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10034, + "msg": "最大不能超过1", + "data": null + }, + "body_sha256": "fee5205e471a80c0a5f0ff1cbaf8d41067b64be174c681514d24c5da31ad6dca", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"最大不能超过1\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 10034, + "msg": "最大不能超过1", + "data": null + }, + "body_sha256": "fee5205e471a80c0a5f0ff1cbaf8d41067b64be174c681514d24c5da31ad6dca", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"最大不能超过1\"}" + } + }, + { + "name": "UTF-16 string-length validation boundary", + "category": "validation", + "passed": true, + "checks": { + "http_status": true, + "body": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10034, + "msg": "个数必须在0和64之间", + "data": null + }, + "body_sha256": "aff178d29594159cf7e18713d4de0916dc3a52eba0e94a093e43b0f43feec2e4", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"个数必须在0和64之间\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "62" + }, + "body": { + "code": 10034, + "msg": "个数必须在0和64之间", + "data": null + }, + "body_sha256": "aff178d29594159cf7e18713d4de0916dc3a52eba0e94a093e43b0f43feec2e4", + "body_excerpt": "{\"code\": 10034, \"data\": null, \"msg\": \"个数必须在0和64之间\"}" + } + }, + { + "name": "required model-provider fields (unordered Java ConstraintViolation Set)", + "category": "validation", + "passed": true, + "checks": { + "java_http_200": true, + "java_code_10034": true, + "java_messages_are_declared_constraints": true, + "fastapi_http_200": true, + "fastapi_code_10034": true, + "fastapi_message_is_declared_constraint": true + }, + "difference": null, + "java": { + "observed_messages": [ + "fields(JSON格式)不能为空", + "name不能为空", + "sort不能为空" + ], + "declared_messages": [ + "fields(JSON格式)不能为空", + "modelType不能为空", + "name不能为空", + "providerCode不能为空", + "sort不能为空" + ] + }, + "fastapi": { + "body": { + "code": 10034, + "msg": "providerCode不能为空", + "data": null + } + } + }, + { + "name": "invalid pagination number format", + "category": "validation", + "passed": true, + "checks": { + "http_status": true, + "body": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "排序值不能小于0", + "data": null + }, + "body_sha256": "0cae350a6aa1fb188221254f94d412f8ca4373c2e66c1d1fdfaaab174c62a10f", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"排序值不能小于0\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "55" + }, + "body": { + "code": 500, + "msg": "排序值不能小于0", + "data": null + }, + "body_sha256": "0cae350a6aa1fb188221254f94d412f8ca4373c2e66c1d1fdfaaab174c62a10f", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"排序值不能小于0\"}" + } + }, + { + "name": "missing server secret", + "category": "server-secret", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "60" + }, + "body": { + "code": 401, + "msg": "服务器密钥不能为空", + "data": null + }, + "body_sha256": "de9f43e4af222a5918a2d7f70a6e9900c4a9d224961bf6e4161a8335efe6ab44", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"服务器密钥不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "60" + }, + "body": { + "code": 401, + "msg": "服务器密钥不能为空", + "data": null + }, + "body_sha256": "de9f43e4af222a5918a2d7f70a6e9900c4a9d224961bf6e4161a8335efe6ab44", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"服务器密钥不能为空\"}" + } + }, + { + "name": "invalid server secret", + "category": "server-secret", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "57" + }, + "body": { + "code": 401, + "msg": "无效的服务器密钥", + "data": null + }, + "body_sha256": "eda919b0be50a497b95eb5aebf076111a8284ad0badb3cd7957975a55c5fca3f", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"无效的服务器密钥\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "57" + }, + "body": { + "code": 401, + "msg": "无效的服务器密钥", + "data": null + }, + "body_sha256": "eda919b0be50a497b95eb5aebf076111a8284ad0badb3cd7957975a55c5fca3f", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"无效的服务器密钥\"}" + } + }, + { + "name": "valid server secret runtime configuration", + "category": "server-secret", + "passed": true, + "checks": { + "http_status": true, + "body": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "delete_audio": true, + "ASR": { + "ASR_FunASR": { + "type": "fun_local", + "language": "auto", + "model_dir": "models/SenseVoiceSmall", + "output_dir": "tmp/" + } + }, + "server": { + "sms_max_send_count": 10, + "mqtt_gateway": "mqtt://127.0.0.1:1883", + "public_key": "04409d3dc074e2b0217ee931ba5d167d85fed556ac5bf5a1c2ca0c294ab57e15b7aa55b06fd49bb337b23f88e1feaebd9ae4a5206c72ad8832f40c1a0374f83999", + "fronted_url": "http://xiaozhi.server.com", + "auth": { + "enabled": true + }, + "private_key": "", + "secret": "", + "beian_ga_num": "null", + "allow_user_register": false, + "enable_mobile_register": false, + "mqtt_manager_api": "127.0.0.1:18084", + "websocket": "ws://127.0.0.1:18080/xiaozhi/v1/", + "udp_gateway": "null", + "mqtt_signature_key": "", + "name": "xiaozhi-esp32-server", + "mcp_endpoint": "null", + "voice_print": "null", + "ota": "http://127.0.0.1:18082/xiaozhi/ota/", + "beian_icp_num": "null", + "voiceprint_similarity_threshold": "0.4" + }, + "enable_stop_tts_notify": false, + "close_connection_no_voice_time": 120, + "enable_wakeup_words_response_cache": true, + "log": { + "log_format_file": "{time:YYYY-MM-DD HH:mm:ss} - {version}_{selected_module} - {name} - {level} - {extra[tag]} - {message}", + "log_dir": "tmp", + "log_format": "{time:YYMMDD HH:mm:ss}[{version}-{selected_module}][{extra[tag]}]-{level}-{message}", + "log_file": "server.log", + "log_level": "INFO", + "data_dir": "data" + }, + "wakeup_words": [ + "你好小智", + "你好小志", + "小爱同学", + "你好小鑫", + "你好小新", + "小美同学", + "小龙小龙", + "喵喵同学", + "小滨小滨", + "小冰小冰", + "嘿你好呀" + ], + "selected_module": { + "ASR": "ASR_FunASR", + "VAD": "VAD_SileroVAD" + }, + "enable_greeting": true, + "end_prompt": { + "enable": true, + "prompt": "请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧!" + }, + "exit_commands": [ + "退出", + "关闭" + ], + "tts_timeout": 10, + "device_max_output_size": 0, + "system-web": { + "menu": { + "groups": { + "voiceManagement": [ + "vad", + "asr" + ], + "featureManagement": [ + "voiceprintRecognition", + "voiceClone", + "knowledgeBase", + "mcpAccessPoint" + ] + }, + "features": { + "asr": { + "name": "feature.asr.name", + "enabled": true, + "description": "feature.asr.description" + }, + "vad": { + "name": "feature.vad.name", + "enabled": true, + "description": "feature.vad.description" + }, + "voiceClone": { + "name": "feature.voiceClone.name", + "enabled": false, + "description": "feature.voiceClone.description" + }, + "knowledgeBase": { + "name": "feature.knowledgeBase.name", + "enabled": false, + "description": "feature.knowledgeBase.description" + }, + "mcpAccessPoint": { + "name": "feature.mcpAccessPoint.name", + "enabled": false, + "description": "feature.mcpAccessPoint.description" + }, + "voiceprintRecognition": { + "name": "feature.voiceprintRecognition.name", + "enabled": false, + "description": "feature.voiceprintRecognition.description" + } + }, + "addressBook": { + "name": "feature.addressBook.name", + "enabled": false, + "description": "feature.addressBook.description" + } + } + }, + "tool_call_timeout": 30, + "VAD": { + "VAD_SileroVAD": { + "type": "silero", + "model_dir": "models/snakers4_silero-vad", + "threshold": 0.5, + "min_silence_duration_ms": 700 + } + }, + "summaryMemory": null, + "stop_tts_notify_voice": "config/assets/tts_notify.mp3", + "system_error_response": "主人,小智现在有点忙,我们稍后再试吧。", + "aliyun": { + "sms": { + "access_key_id": "", + "sign_name": "", + "access_key_secret": "", + "sms_code_template_code": "" + } + }, + "prompt": null, + "enable_websocket_ping": false, + "xiaozhi": { + "type": "hello", + "version": 1, + "transport": "websocket", + "audio_params": { + "format": "opus", + "sample_rate": 24000, + "channels": 1, + "frame_duration": 60 + } + } + } + }, + "body_sha256": "969fdeb7bf416a11bf8265f541781239191b6627c0cdf415204ae26481a0ddb3", + "body_excerpt": "{\"code\": 0, \"data\": {\"ASR\": {\"ASR_FunASR\": {\"language\": \"auto\", \"model_dir\": \"models/SenseVoiceSmall\", \"output_dir\": \"tmp/\", \"type\": \"fun_local\"}}, \"VAD\": {\"VAD_SileroVAD\": {\"min_silence_duration_ms\": 700, \"model_dir\": \"models/snakers4_silero-vad\", \"threshold\": 0.5, \"type\": \"silero\"}}, \"aliyun\": {\"sms\": {\"access_key_id\": \"\", \"access_key_secret\": \"\", \"sign_name\": \"\", \"sms_code_template_code\": \"\"}}, \"close_connection_no_voice_time\": 120, \"delete_audio\": true, \"device_max_output_size\": 0, \"enable_greeting\": true, \"enable_stop_tts_notify\": false, \"enable_wakeup_words_response_cache\": true, \"enable_websocket_ping\": false, \"end_prompt\": {\"enable\": true, \"prompt\": \"请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧!\"}, \"exit_commands\": [\"退出\", \"关闭\"], \"log\": {\"data_dir\": \"data\", \"log_dir\": \"tmp\", \"log_file\": \"server.log\", \"log_format\": \"{time:YYMMDD HH:mm:ss}[{version}-{selected_module}][{extra[tag]}]-{level}-{message" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "3582" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "server": { + "secret": "", + "allow_user_register": false, + "fronted_url": "http://xiaozhi.server.com", + "websocket": "ws://127.0.0.1:18080/xiaozhi/v1/", + "ota": "http://127.0.0.1:18082/xiaozhi/ota/", + "name": "xiaozhi-esp32-server", + "beian_icp_num": "null", + "beian_ga_num": "null", + "enable_mobile_register": false, + "sms_max_send_count": 10, + "mcp_endpoint": "null", + "voice_print": "null", + "voiceprint_similarity_threshold": "0.4", + "mqtt_gateway": "mqtt://127.0.0.1:1883", + "mqtt_signature_key": "", + "udp_gateway": "null", + "mqtt_manager_api": "127.0.0.1:18084", + "public_key": "04409d3dc074e2b0217ee931ba5d167d85fed556ac5bf5a1c2ca0c294ab57e15b7aa55b06fd49bb337b23f88e1feaebd9ae4a5206c72ad8832f40c1a0374f83999", + "private_key": "", + "auth": { + "enabled": true + } + }, + "device_max_output_size": 0, + "log": { + "log_format": "{time:YYMMDD HH:mm:ss}[{version}-{selected_module}][{extra[tag]}]-{level}-{message}", + "log_format_file": "{time:YYYY-MM-DD HH:mm:ss} - {version}_{selected_module} - {name} - {level} - {extra[tag]} - {message}", + "log_level": "INFO", + "log_dir": "tmp", + "log_file": "server.log", + "data_dir": "data" + }, + "delete_audio": true, + "close_connection_no_voice_time": 120, + "tts_timeout": 10, + "enable_wakeup_words_response_cache": true, + "enable_greeting": true, + "enable_stop_tts_notify": false, + "stop_tts_notify_voice": "config/assets/tts_notify.mp3", + "exit_commands": [ + "退出", + "关闭" + ], + "xiaozhi": { + "type": "hello", + "version": 1, + "transport": "websocket", + "audio_params": { + "format": "opus", + "sample_rate": 24000, + "channels": 1, + "frame_duration": 60 + } + }, + "wakeup_words": [ + "你好小智", + "你好小志", + "小爱同学", + "你好小鑫", + "你好小新", + "小美同学", + "小龙小龙", + "喵喵同学", + "小滨小滨", + "小冰小冰", + "嘿你好呀" + ], + "enable_websocket_ping": false, + "tool_call_timeout": 30, + "end_prompt": { + "enable": true, + "prompt": "请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧!" + }, + "system_error_response": "主人,小智现在有点忙,我们稍后再试吧。", + "system-web": { + "menu": { + "groups": { + "voiceManagement": [ + "vad", + "asr" + ], + "featureManagement": [ + "voiceprintRecognition", + "voiceClone", + "knowledgeBase", + "mcpAccessPoint" + ] + }, + "features": { + "asr": { + "name": "feature.asr.name", + "enabled": true, + "description": "feature.asr.description" + }, + "vad": { + "name": "feature.vad.name", + "enabled": true, + "description": "feature.vad.description" + }, + "voiceClone": { + "name": "feature.voiceClone.name", + "enabled": false, + "description": "feature.voiceClone.description" + }, + "knowledgeBase": { + "name": "feature.knowledgeBase.name", + "enabled": false, + "description": "feature.knowledgeBase.description" + }, + "mcpAccessPoint": { + "name": "feature.mcpAccessPoint.name", + "enabled": false, + "description": "feature.mcpAccessPoint.description" + }, + "voiceprintRecognition": { + "name": "feature.voiceprintRecognition.name", + "enabled": false, + "description": "feature.voiceprintRecognition.description" + } + }, + "addressBook": { + "name": "feature.addressBook.name", + "enabled": false, + "description": "feature.addressBook.description" + } + } + }, + "aliyun": { + "sms": { + "access_key_id": "", + "access_key_secret": "", + "sign_name": "", + "sms_code_template_code": "" + } + }, + "VAD": { + "VAD_SileroVAD": { + "type": "silero", + "model_dir": "models/snakers4_silero-vad", + "threshold": 0.5, + "min_silence_duration_ms": 700 + } + }, + "ASR": { + "ASR_FunASR": { + "type": "fun_local", + "language": "auto", + "model_dir": "models/SenseVoiceSmall", + "output_dir": "tmp/" + } + }, + "selected_module": { + "VAD": "VAD_SileroVAD", + "ASR": "ASR_FunASR" + }, + "prompt": null, + "summaryMemory": null + } + }, + "body_sha256": "969fdeb7bf416a11bf8265f541781239191b6627c0cdf415204ae26481a0ddb3", + "body_excerpt": "{\"code\": 0, \"data\": {\"ASR\": {\"ASR_FunASR\": {\"language\": \"auto\", \"model_dir\": \"models/SenseVoiceSmall\", \"output_dir\": \"tmp/\", \"type\": \"fun_local\"}}, \"VAD\": {\"VAD_SileroVAD\": {\"min_silence_duration_ms\": 700, \"model_dir\": \"models/snakers4_silero-vad\", \"threshold\": 0.5, \"type\": \"silero\"}}, \"aliyun\": {\"sms\": {\"access_key_id\": \"\", \"access_key_secret\": \"\", \"sign_name\": \"\", \"sms_code_template_code\": \"\"}}, \"close_connection_no_voice_time\": 120, \"delete_audio\": true, \"device_max_output_size\": 0, \"enable_greeting\": true, \"enable_stop_tts_notify\": false, \"enable_wakeup_words_response_cache\": true, \"enable_websocket_ping\": false, \"end_prompt\": {\"enable\": true, \"prompt\": \"请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧!\"}, \"exit_commands\": [\"退出\", \"关闭\"], \"log\": {\"data_dir\": \"data\", \"log_dir\": \"tmp\", \"log_file\": \"server.log\", \"log_format\": \"{time:YYMMDD HH:mm:ss}[{version}-{selected_module}][{extra[tag]}]-{level}-{message" + } + }, + { + "name": "OTA health MIME and body", + "category": "ota", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "text/plain;charset=UTF-8", + "content-disposition": null, + "content-length": "49" + }, + "body": "OTA接口运行正常,websocket集群数量:1", + "body_sha256": "328287942eb320af0a21856fb6ccdeb1947ebe8f689ad6843654230856e0b101", + "body_excerpt": "\"OTA接口运行正常,websocket集群数量:1\"" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "text/plain;charset=UTF-8", + "content-disposition": null, + "content-length": "49" + }, + "body": "OTA接口运行正常,websocket集群数量:1", + "body_sha256": "328287942eb320af0a21856fb6ccdeb1947ebe8f689ad6843654230856e0b101", + "body_excerpt": "\"OTA接口运行正常,websocket集群数量:1\"" + } + }, + { + "name": "OTA missing required Device-Id", + "category": "ota-validation", + "passed": true, + "checks": { + "http_status": true, + "body": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "OTA invalid Device-Id", + "category": "ota-validation", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true, + "header:content-length": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "29" + }, + "body": { + "error": "Invalid device ID" + }, + "body_sha256": "1de3f545556a1a2676e7d673ac88d85d06729d36cdd0fe649d9755470885056b", + "body_excerpt": "{\"error\": \"Invalid device ID\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "29" + }, + "body": { + "error": "Invalid device ID" + }, + "body_sha256": "1de3f545556a1a2676e7d673ac88d85d06729d36cdd0fe649d9755470885056b", + "body_excerpt": "{\"error\": \"Invalid device ID\"}" + } + }, + { + "name": "OTA WebSocket/MQTT credentials", + "category": "ota-signing", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true, + "header:content-length": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "635" + }, + "body": { + "server_time": { + "timestamp": "", + "timeZone": "Asia/Shanghai", + "timezone_offset": 480 + }, + "firmware": { + "version": "1.2.3", + "url": "http://xiaozhi.server.com:8002/xiaozhi/otaMag/download/NOT_ACTIVATED_FIRMWARE_THIS_IS_A_INVALID_URL" + }, + "websocket": { + "url": "ws://127.0.0.1:18080/xiaozhi/v1/", + "token": "" + }, + "mqtt": { + "endpoint": "mqtt://127.0.0.1:1883", + "client_id": "esp32s3@@@AA_BB_CC_DD_EE_01@@@AA_BB_CC_DD_EE_01", + "username": "eyJpcCI6IjEyNy4wLjAuMSJ9", + "password": "", + "publish_topic": "device-server", + "subscribe_topic": "devices/p2p/AA_BB_CC_DD_EE_01" + } + }, + "body_sha256": "a998dbd6b9ceec19c5d7b0a09d848fca191d94d4941a0da2b634dd61e926dd89", + "body_excerpt": "{\"firmware\": {\"url\": \"http://xiaozhi.server.com:8002/xiaozhi/otaMag/download/NOT_ACTIVATED_FIRMWARE_THIS_IS_A_INVALID_URL\", \"version\": \"1.2.3\"}, \"mqtt\": {\"client_id\": \"esp32s3@@@AA_BB_CC_DD_EE_01@@@AA_BB_CC_DD_EE_01\", \"endpoint\": \"mqtt://127.0.0.1:1883\", \"password\": \"\", \"publish_topic\": \"device-server\", \"subscribe_topic\": \"devices/p2p/AA_BB_CC_DD_EE_01\", \"username\": \"eyJpcCI6IjEyNy4wLjAuMSJ9\"}, \"server_time\": {\"timeZone\": \"Asia/Shanghai\", \"timestamp\": \"\", \"timezone_offset\": 480}, \"websocket\": {\"token\": \"\", \"url\": \"ws://127.0.0.1:18080/xiaozhi/v1/\"}}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "635" + }, + "body": { + "server_time": { + "timestamp": "", + "timeZone": "Asia/Shanghai", + "timezone_offset": 480 + }, + "firmware": { + "version": "1.2.3", + "url": "http://xiaozhi.server.com:8002/xiaozhi/otaMag/download/NOT_ACTIVATED_FIRMWARE_THIS_IS_A_INVALID_URL" + }, + "websocket": { + "url": "ws://127.0.0.1:18080/xiaozhi/v1/", + "token": "" + }, + "mqtt": { + "client_id": "esp32s3@@@AA_BB_CC_DD_EE_01@@@AA_BB_CC_DD_EE_01", + "username": "eyJpcCI6IjEyNy4wLjAuMSJ9", + "password": "", + "publish_topic": "device-server", + "subscribe_topic": "devices/p2p/AA_BB_CC_DD_EE_01", + "endpoint": "mqtt://127.0.0.1:1883" + } + }, + "body_sha256": "a998dbd6b9ceec19c5d7b0a09d848fca191d94d4941a0da2b634dd61e926dd89", + "body_excerpt": "{\"firmware\": {\"url\": \"http://xiaozhi.server.com:8002/xiaozhi/otaMag/download/NOT_ACTIVATED_FIRMWARE_THIS_IS_A_INVALID_URL\", \"version\": \"1.2.3\"}, \"mqtt\": {\"client_id\": \"esp32s3@@@AA_BB_CC_DD_EE_01@@@AA_BB_CC_DD_EE_01\", \"endpoint\": \"mqtt://127.0.0.1:1883\", \"password\": \"\", \"publish_topic\": \"device-server\", \"subscribe_topic\": \"devices/p2p/AA_BB_CC_DD_EE_01\", \"username\": \"eyJpcCI6IjEyNy4wLjAuMSJ9\"}, \"server_time\": {\"timeZone\": \"Asia/Shanghai\", \"timestamp\": \"\", \"timezone_offset\": 480}, \"websocket\": {\"token\": \"\", \"url\": \"ws://127.0.0.1:18080/xiaozhi/v1/\"}}" + } + }, + { + "name": "OTA HMAC/Base64 cryptographic validity", + "category": "ota-signing", + "passed": true, + "checks": { + "java_credentials": true, + "fastapi_credentials": true + }, + "difference": null, + "java": { + "credential_shape": { + "websocket_token_pattern": true, + "mqtt_fields": [ + "client_id", + "endpoint", + "password", + "publish_topic", + "subscribe_topic", + "username" + ] + } + }, + "fastapi": { + "credential_shape": { + "websocket_token_pattern": true, + "mqtt_fields": [ + "client_id", + "endpoint", + "password", + "publish_topic", + "subscribe_topic", + "username" + ] + } + } + }, + { + "name": "activation missing Device-Id", + "category": "activation", + "passed": true, + "checks": { + "http_status": true, + "body": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "activation unknown device", + "category": "activation", + "passed": true, + "checks": { + "http_status": true, + "body": true + }, + "difference": null, + "java": { + "status": 202, + "headers": { + "content-type": null, + "content-disposition": null, + "content-length": "0" + }, + "body": "", + "body_sha256": "12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "body_excerpt": "\"\"" + }, + "fastapi": { + "status": 202, + "headers": { + "content-type": null, + "content-disposition": null, + "content-length": "0" + }, + "body": "", + "body_sha256": "12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "body_excerpt": "\"\"" + } + }, + { + "name": "activation bound device MIME", + "category": "activation", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "text/plain;charset=UTF-8", + "content-disposition": null, + "content-length": "7" + }, + "body": "success", + "body_sha256": "68e7a69974a641064a6a5ae8b1a00997939a325ec585a49e9fe82b386a21726a", + "body_excerpt": "\"success\"" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "text/plain;charset=UTF-8", + "content-disposition": null, + "content-length": "7" + }, + "body": "success", + "body_sha256": "68e7a69974a641064a6a5ae8b1a00997939a325ec585a49e9fe82b386a21726a", + "body_excerpt": "\"success\"" + } + }, + { + "name": "MQTT tools external mock response", + "category": "external-mock", + "passed": true, + "checks": { + "http_status": true, + "body": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "tools": [ + { + "name": "contract.echo", + "description": "repeatable contract fixture", + "inputSchema": { + "type": "object" + } + } + ] + } + }, + "body_sha256": "b32106f0f36e75e9f995a9421ddd23195025af3e872b5f6bc1cb14a1ecc868bb", + "body_excerpt": "{\"code\": 0, \"data\": {\"tools\": [{\"description\": \"repeatable contract fixture\", \"inputSchema\": {\"type\": \"object\"}, \"name\": \"contract.echo\"}]}, \"msg\": \"success\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "146" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "tools": [ + { + "name": "contract.echo", + "description": "repeatable contract fixture", + "inputSchema": { + "type": "object" + } + } + ] + } + }, + "body_sha256": "b32106f0f36e75e9f995a9421ddd23195025af3e872b5f6bc1cb14a1ecc868bb", + "body_excerpt": "{\"code\": 0, \"data\": {\"tools\": [{\"description\": \"repeatable contract fixture\", \"inputSchema\": {\"type\": \"object\"}, \"name\": \"contract.echo\"}]}, \"msg\": \"success\"}" + } + }, + { + "name": "MQTT external request format and daily auth", + "category": "external-mock", + "passed": true, + "checks": { + "both_services_called_mock": true, + "request_bodies_equal": true, + "daily_tokens_valid": true, + "responses_compatible": true + }, + "difference": null, + "java": { + "request": { + "method": "POST", + "path": "/api/commands/esp32s3@@@AA_BB_CC_DD_EE_01@@@AA_BB_CC_DD_EE_01", + "query": {}, + "headers": { + "authorization": "", + "content-type": "application/json" + }, + "body": { + "payload": { + "method": "tools/list", + "id": 2, + "jsonrpc": "2.0", + "params": { + "withUserTools": true + } + }, + "type": "mcp" + } + } + }, + "fastapi": { + "request": { + "method": "POST", + "path": "/api/commands/esp32s3@@@AA_BB_CC_DD_EE_01@@@AA_BB_CC_DD_EE_01", + "query": {}, + "headers": { + "content-type": "application/json", + "authorization": "" + }, + "body": { + "type": "mcp", + "payload": { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": { + "withUserTools": true + } + } + } + } + } + }, + { + "name": "correct-word create response", + "category": "crud", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "ids_returned": true + }, + "difference": null, + "java": { + "body": { + "code": 0, + "msg": "success", + "data": { + "id": "", + "fileName": "contract-run-created.txt", + "wordCount": 2, + "content": [ + "xiaozhi|小智", + "ESP32|esp32" + ], + "createdAt": "", + "updatedAt": null + } + } + }, + "fastapi": { + "body": { + "code": 0, + "msg": "success", + "data": { + "id": "", + "fileName": "contract-run-created.txt", + "wordCount": 2, + "content": [ + "xiaozhi|小智", + "ESP32|esp32" + ], + "createdAt": "", + "updatedAt": null + } + } + } + }, + { + "name": "correct-word create database side effect", + "category": "database-side-effect", + "passed": true, + "checks": { + "row_equal": true + }, + "difference": null, + "java": { + "file_name": "contract-run-created.txt", + "word_count": 2, + "content": "xiaozhi|小智\nESP32|esp32", + "creator": 9007199254740994 + }, + "fastapi": { + "file_name": "contract-run-created.txt", + "word_count": 2, + "content": "xiaozhi|小智\nESP32|esp32", + "creator": 9007199254740994 + } + }, + { + "name": "correct-word update response", + "category": "crud", + "passed": true, + "checks": { + "status": true, + "body": true + }, + "difference": null, + "java": { + "body": { + "code": 0, + "msg": "success", + "data": null + } + }, + "fastapi": { + "body": { + "code": 0, + "msg": "success", + "data": null + } + } + }, + { + "name": "correct-word update database side effect", + "category": "database-side-effect", + "passed": true, + "checks": { + "row_equal": true + }, + "difference": null, + "java": { + "file_name": "contract-run-updated.txt", + "word_count": 1, + "content": "A|B", + "creator": 9007199254740994, + "updater": 9007199254740994 + }, + "fastapi": { + "file_name": "contract-run-updated.txt", + "word_count": 1, + "content": "A|B", + "creator": 9007199254740994, + "updater": 9007199254740994 + } + }, + { + "name": "correct-word updated binary download", + "category": "binary-download", + "passed": true, + "checks": { + "status": true, + "bytes": true, + "content_type": true, + "content_disposition": true, + "content_length": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/octet-stream", + "content-disposition": "attachment; filename=\"contract-run-updated.txt\"; filename*=UTF-8''contract-run-updated.txt", + "content-length": "3" + }, + "body": "A|B", + "body_sha256": "cfb6e8834699f9737eb031728d563a451274f04eab5efd8b7d1bdd2a6b82ba00" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/octet-stream", + "content-disposition": "attachment; filename=\"contract-run-updated.txt\"; filename*=UTF-8''contract-run-updated.txt", + "content-length": "3" + }, + "body": "A|B", + "body_sha256": "cfb6e8834699f9737eb031728d563a451274f04eab5efd8b7d1bdd2a6b82ba00" + } + }, + { + "name": "correct-word delete cascade side effect", + "category": "database-side-effect", + "passed": true, + "checks": { + "both_deleted": true + }, + "difference": null, + "java": { + "remaining": 0 + }, + "fastapi": { + "remaining": 0 + } + }, + { + "name": "OTA multipart upload response", + "category": "upload", + "passed": true, + "checks": { + "status": true, + "body": true, + "paths_returned": true + }, + "difference": null, + "java": { + "body": { + "code": 0, + "msg": "success", + "data": "uploadfile/45bc46ec1dbbe0608854dda63e72f2d2.bin" + } + }, + "fastapi": { + "body": { + "code": 0, + "msg": "success", + "data": "uploadfile/45bc46ec1dbbe0608854dda63e72f2d2.bin" + } + } + }, + { + "name": "OTA multipart extension validation", + "category": "upload-validation", + "passed": true, + "checks": { + "http_status": true, + "body": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "只允许上传.bin和.apk格式的文件", + "data": null + }, + "body_sha256": "a7705b4b1e10b70ce0d5a8eda51f02f2a4b9e00be167583b7f65b87a0c2b41af", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"只允许上传.bin和.apk格式的文件\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "74" + }, + "body": { + "code": 500, + "msg": "只允许上传.bin和.apk格式的文件", + "data": null + }, + "body_sha256": "a7705b4b1e10b70ce0d5a8eda51f02f2a4b9e00be167583b7f65b87a0c2b41af", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"只允许上传.bin和.apk格式的文件\"}" + } + }, + { + "name": "OTA metadata create response", + "category": "crud", + "passed": true, + "checks": { + "status": true, + "body": true + }, + "difference": null, + "java": { + "body": { + "code": 0, + "msg": "success", + "data": null + } + }, + "fastapi": { + "body": { + "code": 0, + "msg": "success", + "data": null + } + } + }, + { + "name": "OTA metadata database side effect", + "category": "database-side-effect", + "passed": true, + "checks": { + "row_equal": true + }, + "difference": null, + "java": { + "id": "contract-ota-run", + "firmware_name": "Contract Firmware", + "type": "contract-run", + "version": "1.0.0", + "size": 31, + "remark": null, + "firmware_path": "uploadfile/45bc46ec1dbbe0608854dda63e72f2d2.bin", + "sort": 9 + }, + "fastapi": { + "id": "contract-ota-run", + "firmware_name": "Contract Firmware", + "type": "contract-run", + "version": "1.0.0", + "size": 31, + "remark": null, + "firmware_path": "uploadfile/45bc46ec1dbbe0608854dda63e72f2d2.bin", + "sort": 9 + } + }, + { + "name": "OTA binary download attempt 1", + "category": "binary-download", + "passed": true, + "checks": { + "status": true, + "bytes": true, + "content_type": true, + "content_disposition": true, + "content_length": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/octet-stream", + "content-disposition": "attachment; filename=\"contract-run_1.0.0.bin\"", + "content-length": "31" + }, + "body": { + "base64": "bWFuYWdlci1hcGktY29udHJhY3QtZmlybXdhcmUA/w==" + }, + "body_sha256": "bceb8e6abf5d1a7555f67b0ce88b95769b0909e8be2daa596d22302e83058cc6" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/octet-stream", + "content-disposition": "attachment; filename=\"contract-run_1.0.0.bin\"", + "content-length": "31" + }, + "body": { + "base64": "bWFuYWdlci1hcGktY29udHJhY3QtZmlybXdhcmUA/w==" + }, + "body_sha256": "bceb8e6abf5d1a7555f67b0ce88b95769b0909e8be2daa596d22302e83058cc6" + } + }, + { + "name": "OTA binary download attempt 2", + "category": "binary-download", + "passed": true, + "checks": { + "status": true, + "bytes": true, + "content_type": true, + "content_disposition": true, + "content_length": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/octet-stream", + "content-disposition": "attachment; filename=\"contract-run_1.0.0.bin\"", + "content-length": "31" + }, + "body": { + "base64": "bWFuYWdlci1hcGktY29udHJhY3QtZmlybXdhcmUA/w==" + }, + "body_sha256": "bceb8e6abf5d1a7555f67b0ce88b95769b0909e8be2daa596d22302e83058cc6" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/octet-stream", + "content-disposition": "attachment; filename=\"contract-run_1.0.0.bin\"", + "content-length": "31" + }, + "body": { + "base64": "bWFuYWdlci1hcGktY29udHJhY3QtZmlybXdhcmUA/w==" + }, + "body_sha256": "bceb8e6abf5d1a7555f67b0ce88b95769b0909e8be2daa596d22302e83058cc6" + } + }, + { + "name": "OTA binary download attempt 3", + "category": "binary-download", + "passed": true, + "checks": { + "status": true, + "bytes": true, + "content_type": true, + "content_disposition": true, + "content_length": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/octet-stream", + "content-disposition": "attachment; filename=\"contract-run_1.0.0.bin\"", + "content-length": "31" + }, + "body": { + "base64": "bWFuYWdlci1hcGktY29udHJhY3QtZmlybXdhcmUA/w==" + }, + "body_sha256": "bceb8e6abf5d1a7555f67b0ce88b95769b0909e8be2daa596d22302e83058cc6" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/octet-stream", + "content-disposition": "attachment; filename=\"contract-run_1.0.0.bin\"", + "content-length": "31" + }, + "body": { + "base64": "bWFuYWdlci1hcGktY29udHJhY3QtZmlybXdhcmUA/w==" + }, + "body_sha256": "bceb8e6abf5d1a7555f67b0ce88b95769b0909e8be2daa596d22302e83058cc6" + } + }, + { + "name": "OTA binary download attempt 4", + "category": "binary-download", + "passed": true, + "checks": { + "status": true, + "bytes": true, + "content_type": true, + "content_disposition": true, + "content_length": true + }, + "difference": null, + "java": { + "status": 404, + "headers": { + "content-type": null, + "content-disposition": null, + "content-length": "0" + }, + "body": "", + "body_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "fastapi": { + "status": 404, + "headers": { + "content-type": null, + "content-disposition": null, + "content-length": "0" + }, + "body": "", + "body_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + } + ] +} diff --git a/main/manager-api-fastapi/compatibility/java-routes.json b/main/manager-api-fastapi/compatibility/java-routes.json new file mode 100644 index 00000000..eadc76f9 --- /dev/null +++ b/main/manager-api-fastapi/compatibility/java-routes.json @@ -0,0 +1,1701 @@ +{ + "source": "main/manager-api", + "contextPath": "/xiaozhi", + "count": 154, + "routes": [ + { + "method": "GET", + "path": "/admin/device/all", + "controller": "AdminController", + "handler": "pageDevice", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/AdminController.java", + "line": 91, + "java_signature": "}) public Result> pageDevice(" + }, + { + "method": "POST", + "path": "/admin/dict/data/delete", + "controller": "SysDictDataController", + "handler": "delete", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysDictDataController.java", + "line": 88, + "java_signature": "public Result delete(@RequestBody Long[] ids) {" + }, + { + "method": "GET", + "path": "/admin/dict/data/page", + "controller": "SysDictDataController", + "handler": "page", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysDictDataController.java", + "line": 44, + "java_signature": "public Result> page(@Parameter(hidden = true) @RequestParam Map params) {" + }, + { + "method": "POST", + "path": "/admin/dict/data/save", + "controller": "SysDictDataController", + "handler": "save", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysDictDataController.java", + "line": 70, + "java_signature": "public Result save(@RequestBody SysDictDataDTO dto) {" + }, + { + "method": "GET", + "path": "/admin/dict/data/type/{dictType}", + "controller": "SysDictDataController", + "handler": "getDictDataByType", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysDictDataController.java", + "line": 97, + "java_signature": "public Result> getDictDataByType(@PathVariable(\"dictType\") String dictType) {" + }, + { + "method": "PUT", + "path": "/admin/dict/data/update", + "controller": "SysDictDataController", + "handler": "update", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysDictDataController.java", + "line": 79, + "java_signature": "public Result update(@RequestBody SysDictDataDTO dto) {" + }, + { + "method": "GET", + "path": "/admin/dict/data/{id}", + "controller": "SysDictDataController", + "handler": "get", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysDictDataController.java", + "line": 62, + "java_signature": "public Result get(@PathVariable(\"id\") Long id) {" + }, + { + "method": "POST", + "path": "/admin/dict/type/delete", + "controller": "SysDictTypeController", + "handler": "delete", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysDictTypeController.java", + "line": 84, + "java_signature": "public Result delete(@RequestBody Long[] ids) {" + }, + { + "method": "GET", + "path": "/admin/dict/type/page", + "controller": "SysDictTypeController", + "handler": "page", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysDictTypeController.java", + "line": 41, + "java_signature": "public Result> page(@Parameter(hidden = true) @RequestParam Map params) {" + }, + { + "method": "POST", + "path": "/admin/dict/type/save", + "controller": "SysDictTypeController", + "handler": "save", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysDictTypeController.java", + "line": 62, + "java_signature": "public Result save(@RequestBody SysDictTypeDTO dto) {" + }, + { + "method": "PUT", + "path": "/admin/dict/type/update", + "controller": "SysDictTypeController", + "handler": "update", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysDictTypeController.java", + "line": 73, + "java_signature": "public Result update(@RequestBody SysDictTypeDTO dto) {" + }, + { + "method": "GET", + "path": "/admin/dict/type/{id}", + "controller": "SysDictTypeController", + "handler": "get", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysDictTypeController.java", + "line": 54, + "java_signature": "public Result get(@PathVariable(\"id\") Long id) {" + }, + { + "method": "POST", + "path": "/admin/params", + "controller": "SysParamsController", + "handler": "save", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysParamsController.java", + "line": 81, + "java_signature": "public Result save(@RequestBody SysParamsDTO dto) {" + }, + { + "method": "PUT", + "path": "/admin/params", + "controller": "SysParamsController", + "handler": "update", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysParamsController.java", + "line": 94, + "java_signature": "public Result update(@RequestBody SysParamsDTO dto) {" + }, + { + "method": "POST", + "path": "/admin/params/delete", + "controller": "SysParamsController", + "handler": "delete", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysParamsController.java", + "line": 161, + "java_signature": "public Result delete(@RequestBody String[] ids) {" + }, + { + "method": "GET", + "path": "/admin/params/page", + "controller": "SysParamsController", + "handler": "page", + "auth": "database-token", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysParamsController.java", + "line": 56, + "java_signature": "}) @RequiresPermissions(\"sys:role:superAdmin\") public Result> page(@Parameter(hidden = true) @RequestParam Map params) {" + }, + { + "method": "GET", + "path": "/admin/params/{id}", + "controller": "SysParamsController", + "handler": "get", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysParamsController.java", + "line": 72, + "java_signature": "public Result get(@PathVariable(\"id\") Long id) {" + }, + { + "method": "POST", + "path": "/admin/server/emit-action", + "controller": "ServerSideManageController", + "handler": "emitServerAction", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/ServerSideManageController.java", + "line": 67, + "java_signature": "public Result emitServerAction(@RequestBody @Valid EmitSeverActionDTO emitSeverActionDTO) {" + }, + { + "method": "GET", + "path": "/admin/server/server-list", + "controller": "ServerSideManageController", + "handler": "getWsServerList", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/ServerSideManageController.java", + "line": 56, + "java_signature": "public Result> getWsServerList() {" + }, + { + "method": "GET", + "path": "/admin/users", + "controller": "AdminController", + "handler": "pageUser", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/AdminController.java", + "line": 46, + "java_signature": "}) public Result> pageUser(" + }, + { + "method": "PUT", + "path": "/admin/users/changeStatus/{status}", + "controller": "AdminController", + "handler": "changeStatus", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/AdminController.java", + "line": 82, + "java_signature": "public Result changeStatus(@PathVariable Integer status, @RequestBody String[] userIds) {" + }, + { + "method": "DELETE", + "path": "/admin/users/{id}", + "controller": "AdminController", + "handler": "delete", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/AdminController.java", + "line": 74, + "java_signature": "public Result delete(@PathVariable Long id) {" + }, + { + "method": "PUT", + "path": "/admin/users/{id}", + "controller": "AdminController", + "handler": "update", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/sys/controller/AdminController.java", + "line": 65, + "java_signature": "public Result update(" + }, + { + "method": "POST", + "path": "/agent", + "controller": "AgentController", + "handler": "save", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java", + "line": 132, + "java_signature": "public Result save(@RequestBody @Valid AgentCreateDTO dto) {" + }, + { + "method": "GET", + "path": "/agent/all", + "controller": "AgentController", + "handler": "adminAgentList", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java", + "line": 111, + "java_signature": "}) public Result> adminAgentList(" + }, + { + "method": "POST", + "path": "/agent/audio/{audioId}", + "controller": "AgentController", + "handler": "getAudioId", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java", + "line": 267, + "java_signature": "public Result getAudioId(@PathVariable(\"audioId\") String audioId) {" + }, + { + "method": "GET", + "path": "/agent/chat-history/download/{uuid}/current", + "controller": "AgentChatHistoryController", + "handler": "downloadCurrentSession", + "auth": "anonymous", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentChatHistoryController.java", + "line": 103, + "java_signature": "public void downloadCurrentSession(@PathVariable(\"uuid\") String uuid," + }, + { + "method": "GET", + "path": "/agent/chat-history/download/{uuid}/previous", + "controller": "AgentChatHistoryController", + "handler": "downloadCurrentSessionWithPrevious", + "auth": "anonymous", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentChatHistoryController.java", + "line": 136, + "java_signature": "public void downloadCurrentSessionWithPrevious(@PathVariable(\"uuid\") String uuid," + }, + { + "method": "POST", + "path": "/agent/chat-history/getDownloadUrl/{agentId}/{sessionId}", + "controller": "AgentChatHistoryController", + "handler": "getDownloadUrl", + "auth": "database-token", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentChatHistoryController.java", + "line": 78, + "java_signature": "public Result getDownloadUrl(@PathVariable(\"agentId\") String agentId," + }, + { + "method": "POST", + "path": "/agent/chat-history/report", + "controller": "AgentChatHistoryController", + "handler": "uploadFile", + "auth": "server-secret", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentChatHistoryController.java", + "line": 63, + "java_signature": "public Result uploadFile(@Valid @RequestBody AgentChatHistoryReportDTO request) {" + }, + { + "method": "POST", + "path": "/agent/chat-summary/{sessionId}/save", + "controller": "AgentController", + "handler": "generateAndSaveChatSummary", + "auth": "server-secret", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java", + "line": 148, + "java_signature": "public Result generateAndSaveChatSummary(@PathVariable String sessionId) {" + }, + { + "method": "POST", + "path": "/agent/chat-title/{sessionId}/generate", + "controller": "AgentController", + "handler": "generateAndSaveChatTitle", + "auth": "server-secret", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java", + "line": 170, + "java_signature": "public Result generateAndSaveChatTitle(@PathVariable String sessionId) {" + }, + { + "method": "GET", + "path": "/agent/list", + "controller": "AgentController", + "handler": "getUserAgents", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java", + "line": 98, + "java_signature": "public Result> getUserAgents(" + }, + { + "method": "GET", + "path": "/agent/mcp/address/{agentId}", + "controller": "AgentMcpAccessPointController", + "handler": "getAgentMcpAccessAddress", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentMcpAccessPointController.java", + "line": 36, + "java_signature": "public Result getAgentMcpAccessAddress(@PathVariable(\"agentId\") String agentId) {" + }, + { + "method": "GET", + "path": "/agent/mcp/tools/{agentId}", + "controller": "AgentMcpAccessPointController", + "handler": "getAgentMcpToolsList", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentMcpAccessPointController.java", + "line": 54, + "java_signature": "public Result> getAgentMcpToolsList(@PathVariable(\"agentId\") String agentId) {" + }, + { + "method": "GET", + "path": "/agent/play/{uuid}", + "controller": "AgentController", + "handler": "playAudio", + "auth": "anonymous", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java", + "line": 281, + "java_signature": "public ResponseEntity playAudio(@PathVariable(\"uuid\") String uuid) {" + }, + { + "method": "PUT", + "path": "/agent/saveMemory/{macAddress}", + "controller": "AgentController", + "handler": "updateByDeviceId", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java", + "line": 140, + "java_signature": "public Result updateByDeviceId(@PathVariable String macAddress, @RequestBody @Valid AgentMemoryDTO dto) {" + }, + { + "method": "POST", + "path": "/agent/tag", + "controller": "AgentController", + "handler": "createTag", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java", + "line": 301, + "java_signature": "public Result createTag(@RequestBody Map params) {" + }, + { + "method": "GET", + "path": "/agent/tag/list", + "controller": "AgentController", + "handler": "getAllTags", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java", + "line": 313, + "java_signature": "public Result> getAllTags() {" + }, + { + "method": "DELETE", + "path": "/agent/tag/{id}", + "controller": "AgentController", + "handler": "deleteTag", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java", + "line": 321, + "java_signature": "public Result deleteTag(@PathVariable String id) {" + }, + { + "method": "GET", + "path": "/agent/template", + "controller": "AgentController", + "handler": "templateList", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java", + "line": 194, + "java_signature": "public Result> templateList() {" + }, + { + "method": "POST", + "path": "/agent/template", + "controller": "AgentTemplateController", + "handler": "createAgentTemplate", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentTemplateController.java", + "line": 95, + "java_signature": "public Result createAgentTemplate(@Valid @RequestBody AgentTemplateEntity template) {" + }, + { + "method": "PUT", + "path": "/agent/template", + "controller": "AgentTemplateController", + "handler": "updateAgentTemplate", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentTemplateController.java", + "line": 110, + "java_signature": "public Result updateAgentTemplate(@Valid @RequestBody AgentTemplateEntity template) {" + }, + { + "method": "POST", + "path": "/agent/template/batch-remove", + "controller": "AgentTemplateController", + "handler": "batchRemoveAgentTemplates", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentTemplateController.java", + "line": 147, + "java_signature": "public Result batchRemoveAgentTemplates(@RequestBody List ids) {" + }, + { + "method": "GET", + "path": "/agent/template/page", + "controller": "AgentTemplateController", + "handler": "getAgentTemplatesPage", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentTemplateController.java", + "line": 44, + "java_signature": "}) public Result> getAgentTemplatesPage(" + }, + { + "method": "DELETE", + "path": "/agent/template/{id}", + "controller": "AgentTemplateController", + "handler": "deleteAgentTemplate", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentTemplateController.java", + "line": 122, + "java_signature": "public Result deleteAgentTemplate(@PathVariable(\"id\") String id) {" + }, + { + "method": "GET", + "path": "/agent/template/{id}", + "controller": "AgentTemplateController", + "handler": "getAgentTemplateById", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentTemplateController.java", + "line": 80, + "java_signature": "public Result getAgentTemplateById(@PathVariable(\"id\") String id) {" + }, + { + "method": "POST", + "path": "/agent/voice-print", + "controller": "AgentVoicePrintController", + "handler": "save", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentVoicePrintController.java", + "line": 38, + "java_signature": "public Result save(@RequestBody @Valid AgentVoicePrintSaveDTO dto) {" + }, + { + "method": "PUT", + "path": "/agent/voice-print", + "controller": "AgentVoicePrintController", + "handler": "update", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentVoicePrintController.java", + "line": 49, + "java_signature": "public Result update(@RequestBody @Valid AgentVoicePrintUpdateDTO dto) {" + }, + { + "method": "GET", + "path": "/agent/voice-print/list/{id}", + "controller": "AgentVoicePrintController", + "handler": "list", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentVoicePrintController.java", + "line": 74, + "java_signature": "public Result> list(@PathVariable String id) {" + }, + { + "method": "DELETE", + "path": "/agent/voice-print/{id}", + "controller": "AgentVoicePrintController", + "handler": "delete", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentVoicePrintController.java", + "line": 61, + "java_signature": "public Result delete(@PathVariable String id) {" + }, + { + "method": "GET", + "path": "/agent/{agentId}/snapshots", + "controller": "AgentSnapshotController", + "handler": "page", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentSnapshotController.java", + "line": 36, + "java_signature": "public Result> page(" + }, + { + "method": "DELETE", + "path": "/agent/{agentId}/snapshots/{snapshotId}", + "controller": "AgentSnapshotController", + "handler": "deleteSnapshot", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentSnapshotController.java", + "line": 64, + "java_signature": "public Result deleteSnapshot(@PathVariable String agentId, @PathVariable String snapshotId) {" + }, + { + "method": "GET", + "path": "/agent/{agentId}/snapshots/{snapshotId}", + "controller": "AgentSnapshotController", + "handler": "getSnapshot", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentSnapshotController.java", + "line": 46, + "java_signature": "public Result getSnapshot(@PathVariable String agentId, @PathVariable String snapshotId) {" + }, + { + "method": "POST", + "path": "/agent/{agentId}/snapshots/{snapshotId}/restore", + "controller": "AgentSnapshotController", + "handler": "restore", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentSnapshotController.java", + "line": 54, + "java_signature": "public Result restore(@PathVariable String agentId, @PathVariable String snapshotId," + }, + { + "method": "DELETE", + "path": "/agent/{id}", + "controller": "AgentController", + "handler": "delete", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java", + "line": 186, + "java_signature": "public Result delete(@PathVariable String id) {" + }, + { + "method": "GET", + "path": "/agent/{id}", + "controller": "AgentController", + "handler": "getAgentById", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java", + "line": 124, + "java_signature": "public Result getAgentById(@PathVariable(\"id\") String id) {" + }, + { + "method": "PUT", + "path": "/agent/{id}", + "controller": "AgentController", + "handler": "update", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java", + "line": 178, + "java_signature": "public Result update(@PathVariable String id, @RequestBody @Valid AgentUpdateDTO dto) {" + }, + { + "method": "GET", + "path": "/agent/{id}/chat-history/audio", + "controller": "AgentController", + "handler": "getContentByAudioId", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java", + "line": 256, + "java_signature": "public Result getContentByAudioId(" + }, + { + "method": "GET", + "path": "/agent/{id}/chat-history/user", + "controller": "AgentController", + "handler": "getRecentlyFiftyByAgentId", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java", + "line": 238, + "java_signature": "public Result> getRecentlyFiftyByAgentId(" + }, + { + "method": "GET", + "path": "/agent/{id}/chat-history/{sessionId}", + "controller": "AgentController", + "handler": "getAgentChatHistory", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java", + "line": 219, + "java_signature": "public Result> getAgentChatHistory(" + }, + { + "method": "GET", + "path": "/agent/{id}/sessions", + "controller": "AgentController", + "handler": "getAgentSessions", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java", + "line": 203, + "java_signature": "}) public Result> getAgentSessions(" + }, + { + "method": "GET", + "path": "/agent/{id}/tags", + "controller": "AgentController", + "handler": "getAgentTags", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java", + "line": 329, + "java_signature": "public Result> getAgentTags(@PathVariable String id) {" + }, + { + "method": "PUT", + "path": "/agent/{id}/tags", + "controller": "AgentController", + "handler": "saveAgentTags", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java", + "line": 338, + "java_signature": "public Result saveAgentTags(@PathVariable String id, @RequestBody Map params) {" + }, + { + "method": "POST", + "path": "/config/agent-models", + "controller": "ConfigController", + "handler": "getAgentModels", + "auth": "server-secret", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/config/controller/ConfigController.java", + "line": 39, + "java_signature": "public Result getAgentModels(@Valid @RequestBody AgentModelsDTO dto) {" + }, + { + "method": "POST", + "path": "/config/correct-words", + "controller": "ConfigController", + "handler": "getCorrectWords", + "auth": "server-secret", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/config/controller/ConfigController.java", + "line": 48, + "java_signature": "public Result getCorrectWords(@Valid @RequestBody CorrectWordsDTO dto) {" + }, + { + "method": "POST", + "path": "/config/server-base", + "controller": "ConfigController", + "handler": "getConfig", + "auth": "server-secret", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/config/controller/ConfigController.java", + "line": 32, + "java_signature": "public Result getConfig() {" + }, + { + "method": "POST", + "path": "/correct-word/file", + "controller": "CorrectWordController", + "handler": "createFile", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/correctword/controller/CorrectWordController.java", + "line": 43, + "java_signature": "public Result createFile(@Valid @RequestBody CorrectWordFileCreateDTO dto) {" + }, + { + "method": "POST", + "path": "/correct-word/file/batch-delete", + "controller": "CorrectWordController", + "handler": "batchDeleteFiles", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/correctword/controller/CorrectWordController.java", + "line": 106, + "java_signature": "public Result batchDeleteFiles(@RequestBody List fileIds) {" + }, + { + "method": "GET", + "path": "/correct-word/file/download/{fileId}", + "controller": "CorrectWordController", + "handler": "downloadFile", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/correctword/controller/CorrectWordController.java", + "line": 80, + "java_signature": "public ResponseEntity downloadFile(@PathVariable String fileId) {" + }, + { + "method": "GET", + "path": "/correct-word/file/list", + "controller": "CorrectWordController", + "handler": "listFiles", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/correctword/controller/CorrectWordController.java", + "line": 59, + "java_signature": "}) public Result> listFiles(" + }, + { + "method": "GET", + "path": "/correct-word/file/select", + "controller": "CorrectWordController", + "handler": "listAllFiles", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/correctword/controller/CorrectWordController.java", + "line": 72, + "java_signature": "public Result> listAllFiles() {" + }, + { + "method": "DELETE", + "path": "/correct-word/file/{fileId}", + "controller": "CorrectWordController", + "handler": "deleteFile", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/correctword/controller/CorrectWordController.java", + "line": 98, + "java_signature": "public Result deleteFile(@PathVariable String fileId) {" + }, + { + "method": "PUT", + "path": "/correct-word/file/{fileId}", + "controller": "CorrectWordController", + "handler": "updateFile", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/correctword/controller/CorrectWordController.java", + "line": 51, + "java_signature": "public Result updateFile(@PathVariable String fileId, @Valid @RequestBody CorrectWordFileCreateDTO dto) {" + }, + { + "method": "GET", + "path": "/datasets", + "controller": "KnowledgeBaseController", + "handler": "getPageList", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/knowledge/controller/KnowledgeBaseController.java", + "line": 42, + "java_signature": "public Result> getPageList(" + }, + { + "method": "POST", + "path": "/datasets", + "controller": "KnowledgeBaseController", + "handler": "save", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/knowledge/controller/KnowledgeBaseController.java", + "line": 77, + "java_signature": "public Result save(@RequestBody @Validated KnowledgeBaseDTO knowledgeBaseDTO) {" + }, + { + "method": "DELETE", + "path": "/datasets/batch", + "controller": "KnowledgeBaseController", + "handler": "deleteBatch", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/knowledge/controller/KnowledgeBaseController.java", + "line": 129, + "java_signature": "public Result deleteBatch(@RequestParam(\"ids\") String ids) {" + }, + { + "method": "GET", + "path": "/datasets/rag-models", + "controller": "KnowledgeBaseController", + "handler": "getRAGModels", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/knowledge/controller/KnowledgeBaseController.java", + "line": 156, + "java_signature": "public Result> getRAGModels() {" + }, + { + "method": "DELETE", + "path": "/datasets/{dataset_id}", + "controller": "KnowledgeBaseController", + "handler": "delete", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/knowledge/controller/KnowledgeBaseController.java", + "line": 108, + "java_signature": "public Result delete(@PathVariable(\"dataset_id\") String datasetId) {" + }, + { + "method": "GET", + "path": "/datasets/{dataset_id}", + "controller": "KnowledgeBaseController", + "handler": "getByDatasetId", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/knowledge/controller/KnowledgeBaseController.java", + "line": 60, + "java_signature": "public Result getByDatasetId(@PathVariable(\"dataset_id\") String datasetId) {" + }, + { + "method": "PUT", + "path": "/datasets/{dataset_id}", + "controller": "KnowledgeBaseController", + "handler": "update", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/knowledge/controller/KnowledgeBaseController.java", + "line": 85, + "java_signature": "public Result update(@PathVariable(\"dataset_id\") String datasetId," + }, + { + "method": "POST", + "path": "/datasets/{dataset_id}/chunks", + "controller": "KnowledgeFilesController", + "handler": "parseDocuments", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/knowledge/controller/KnowledgeFilesController.java", + "line": 143, + "java_signature": "public Result parseDocuments(@PathVariable(\"dataset_id\") String datasetId," + }, + { + "method": "DELETE", + "path": "/datasets/{dataset_id}/documents", + "controller": "KnowledgeFilesController", + "handler": "delete", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/knowledge/controller/KnowledgeFilesController.java", + "line": 117, + "java_signature": "public Result delete(@PathVariable(\"dataset_id\") String datasetId," + }, + { + "method": "GET", + "path": "/datasets/{dataset_id}/documents", + "controller": "KnowledgeFilesController", + "handler": "getPageList", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/knowledge/controller/KnowledgeFilesController.java", + "line": 57, + "java_signature": "public Result> getPageList(" + }, + { + "method": "POST", + "path": "/datasets/{dataset_id}/documents", + "controller": "KnowledgeFilesController", + "handler": "uploadDocument", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/knowledge/controller/KnowledgeFilesController.java", + "line": 96, + "java_signature": "public Result uploadDocument(" + }, + { + "method": "GET", + "path": "/datasets/{dataset_id}/documents/status/{status}", + "controller": "KnowledgeFilesController", + "handler": "getPageListByStatus", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/knowledge/controller/KnowledgeFilesController.java", + "line": 78, + "java_signature": "public Result> getPageListByStatus(" + }, + { + "method": "DELETE", + "path": "/datasets/{dataset_id}/documents/{document_id}", + "controller": "KnowledgeFilesController", + "handler": "deleteSingle", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/knowledge/controller/KnowledgeFilesController.java", + "line": 129, + "java_signature": "public Result deleteSingle(@PathVariable(\"dataset_id\") String datasetId," + }, + { + "method": "GET", + "path": "/datasets/{dataset_id}/documents/{document_id}/chunks", + "controller": "KnowledgeFilesController", + "handler": "listChunks", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/knowledge/controller/KnowledgeFilesController.java", + "line": 164, + "java_signature": "public Result listChunks(" + }, + { + "method": "POST", + "path": "/datasets/{dataset_id}/retrieval-test", + "controller": "KnowledgeFilesController", + "handler": "retrievalTest", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/knowledge/controller/KnowledgeFilesController.java", + "line": 191, + "java_signature": "public Result retrievalTest(" + }, + { + "method": "PUT", + "path": "/device/address-book/alias", + "controller": "DeviceController", + "handler": "updateAlias", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java", + "line": 189, + "java_signature": "public Result updateAlias(@Valid @RequestBody DeviceAddressBookAliasDTO dto) {" + }, + { + "method": "GET", + "path": "/device/address-book/call", + "controller": "DeviceController", + "handler": "callByNickname", + "auth": "server-secret", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java", + "line": 178, + "java_signature": "public Result> callByNickname(String callerMac, String nickname," + }, + { + "method": "PUT", + "path": "/device/address-book/permission", + "controller": "DeviceController", + "handler": "updatePermission", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java", + "line": 202, + "java_signature": "public Result updatePermission(@Valid @RequestBody DeviceAddressBookPermissionDTO dto) {" + }, + { + "method": "GET", + "path": "/device/address-book/{macAddress}", + "controller": "DeviceController", + "handler": "getAddressBook", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java", + "line": 171, + "java_signature": "public Result getAddressBook(@PathVariable String macAddress) {" + }, + { + "method": "GET", + "path": "/device/bind/{agentId}", + "controller": "DeviceController", + "handler": "getUserDevices", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java", + "line": 86, + "java_signature": "public Result> getUserDevices(@PathVariable String agentId) {" + }, + { + "method": "POST", + "path": "/device/bind/{agentId}", + "controller": "DeviceController", + "handler": "forwardToMqttGateway", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java", + "line": 95, + "java_signature": "public Result forwardToMqttGateway(@PathVariable String agentId, @RequestBody String requestBody) {" + }, + { + "method": "POST", + "path": "/device/bind/{agentId}/{deviceCode}", + "controller": "DeviceController", + "handler": "bindDevice", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java", + "line": 57, + "java_signature": "public Result bindDevice(@PathVariable String agentId, @PathVariable String deviceCode) {" + }, + { + "method": "POST", + "path": "/device/manual-add", + "controller": "DeviceController", + "handler": "manualAddDevice", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java", + "line": 132, + "java_signature": "public Result manualAddDevice(@RequestBody @Valid DeviceManualAddDTO dto) {" + }, + { + "method": "POST", + "path": "/device/register", + "controller": "DeviceController", + "handler": "registerDevice", + "auth": "database-token", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java", + "line": 65, + "java_signature": "public Result registerDevice(@RequestBody DeviceRegisterDTO deviceRegisterDTO) {" + }, + { + "method": "POST", + "path": "/device/tools/call/{deviceId}", + "controller": "DeviceController", + "handler": "callDeviceTool", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java", + "line": 153, + "java_signature": "public Result callDeviceTool(@PathVariable String deviceId," + }, + { + "method": "POST", + "path": "/device/tools/list/{deviceId}", + "controller": "DeviceController", + "handler": "getDeviceTools", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java", + "line": 141, + "java_signature": "public Result getDeviceTools(@PathVariable String deviceId) {" + }, + { + "method": "POST", + "path": "/device/unbind", + "controller": "DeviceController", + "handler": "unbindDevice", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java", + "line": 106, + "java_signature": "public Result unbindDevice(@RequestBody DeviceUnBindDTO unDeviveBind) {" + }, + { + "method": "PUT", + "path": "/device/update/{id}", + "controller": "DeviceController", + "handler": "updateDeviceInfo", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java", + "line": 115, + "java_signature": "public Result updateDeviceInfo(@PathVariable String id, @Valid @RequestBody DeviceUpdateDTO deviceUpdateDTO) {" + }, + { + "method": "PUT", + "path": "/models/default/{id}", + "controller": "ModelController", + "handler": "setDefaultModel", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelController.java", + "line": 143, + "java_signature": "public Result setDefaultModel(@PathVariable String id) {" + }, + { + "method": "PUT", + "path": "/models/enable/{id}/{status}", + "controller": "ModelController", + "handler": "enableModelConfig", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelController.java", + "line": 124, + "java_signature": "public Result enableModelConfig(@PathVariable String id, @PathVariable Integer status) {" + }, + { + "method": "GET", + "path": "/models/list", + "controller": "ModelController", + "handler": "getModelConfigList", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelController.java", + "line": 72, + "java_signature": "public Result> getModelConfigList(" + }, + { + "method": "GET", + "path": "/models/llm/names", + "controller": "ModelController", + "handler": "getLlmModelCodeList", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelController.java", + "line": 56, + "java_signature": "public Result> getLlmModelCodeList(@RequestParam(required = false) String modelName) {" + }, + { + "method": "GET", + "path": "/models/names", + "controller": "ModelController", + "handler": "getModelNames", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelController.java", + "line": 47, + "java_signature": "public Result> getModelNames(@RequestParam String modelType," + }, + { + "method": "GET", + "path": "/models/provider", + "controller": "ModelProviderController", + "handler": "getListPage", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelProviderController.java", + "line": 34, + "java_signature": "public Result> getListPage(ModelProviderDTO modelProviderDTO," + }, + { + "method": "POST", + "path": "/models/provider", + "controller": "ModelProviderController", + "handler": "add", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelProviderController.java", + "line": 44, + "java_signature": "public Result add(@RequestBody @Validated ModelProviderDTO modelProviderDTO) {" + }, + { + "method": "PUT", + "path": "/models/provider", + "controller": "ModelProviderController", + "handler": "edit", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelProviderController.java", + "line": 52, + "java_signature": "public Result edit(@RequestBody @Validated(UpdateGroup.class) ModelProviderDTO modelProviderDTO) {" + }, + { + "method": "POST", + "path": "/models/provider/delete", + "controller": "ModelProviderController", + "handler": "delete", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelProviderController.java", + "line": 60, + "java_signature": "public Result delete(@RequestBody List ids) {" + }, + { + "method": "GET", + "path": "/models/provider/plugin/names", + "controller": "ModelProviderController", + "handler": "getPluginNameList", + "auth": "database-token", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelProviderController.java", + "line": 69, + "java_signature": "public Result> getPluginNameList() {" + }, + { + "method": "DELETE", + "path": "/models/{id}", + "controller": "ModelController", + "handler": "deleteModelConfig", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelController.java", + "line": 107, + "java_signature": "public Result deleteModelConfig(@PathVariable String id) {" + }, + { + "method": "GET", + "path": "/models/{id}", + "controller": "ModelController", + "handler": "getModelConfig", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelController.java", + "line": 115, + "java_signature": "public Result getModelConfig(@PathVariable String id) {" + }, + { + "method": "GET", + "path": "/models/{modelId}/voices", + "controller": "ModelController", + "handler": "getVoiceList", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelController.java", + "line": 166, + "java_signature": "public Result> getVoiceList(@PathVariable String modelId," + }, + { + "method": "GET", + "path": "/models/{modelType}/provideTypes", + "controller": "ModelController", + "handler": "getModelProviderList", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelController.java", + "line": 64, + "java_signature": "public Result> getModelProviderList(@PathVariable String modelType) {" + }, + { + "method": "POST", + "path": "/models/{modelType}/{provideCode}", + "controller": "ModelController", + "handler": "addModelConfig", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelController.java", + "line": 84, + "java_signature": "public Result addModelConfig(@PathVariable String modelType," + }, + { + "method": "PUT", + "path": "/models/{modelType}/{provideCode}/{id}", + "controller": "ModelController", + "handler": "editModelConfig", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelController.java", + "line": 95, + "java_signature": "public Result editModelConfig(@PathVariable String modelType," + }, + { + "method": "GET", + "path": "/ota/", + "controller": "OTAController", + "handler": "getOTA", + "auth": "anonymous", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAController.java", + "line": 77, + "java_signature": "public ResponseEntity getOTA() {" + }, + { + "method": "POST", + "path": "/ota/", + "controller": "OTAController", + "handler": "checkOTAVersion", + "auth": "anonymous", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAController.java", + "line": 43, + "java_signature": "public ResponseEntity checkOTAVersion(" + }, + { + "method": "POST", + "path": "/ota/activate", + "controller": "OTAController", + "handler": "activateDevice", + "auth": "anonymous", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAController.java", + "line": 63, + "java_signature": "public ResponseEntity activateDevice(" + }, + { + "method": "GET", + "path": "/otaMag", + "controller": "OTAMagController", + "handler": "page", + "auth": "database-token", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAMagController.java", + "line": 64, + "java_signature": "}) @RequiresPermissions(\"sys:role:superAdmin\") public Result> page(@Parameter(hidden = true) @RequestParam Map params) {" + }, + { + "method": "POST", + "path": "/otaMag", + "controller": "OTAMagController", + "handler": "save", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAMagController.java", + "line": 85, + "java_signature": "public Result save(@RequestBody OtaEntity entity) {" + }, + { + "method": "GET", + "path": "/otaMag/download/{uuid}", + "controller": "OTAMagController", + "handler": "downloadFirmware", + "auth": "anonymous", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAMagController.java", + "line": 145, + "java_signature": "public ResponseEntity downloadFirmware(@PathVariable(\"uuid\") String uuid) {" + }, + { + "method": "GET", + "path": "/otaMag/getDownloadUrl/{id}", + "controller": "OTAMagController", + "handler": "getDownloadUrl", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAMagController.java", + "line": 136, + "java_signature": "public Result getDownloadUrl(@PathVariable(\"id\") String id) {" + }, + { + "method": "POST", + "path": "/otaMag/upload", + "controller": "OTAMagController", + "handler": "uploadFirmware", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAMagController.java", + "line": 245, + "java_signature": "public Result uploadFirmware(@RequestParam(\"file\") MultipartFile file) {" + }, + { + "method": "POST", + "path": "/otaMag/uploadAssetsBin", + "controller": "OTAMagController", + "handler": "uploadAssetsBin", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAMagController.java", + "line": 296, + "java_signature": "public Result uploadAssetsBin(@RequestParam(\"file\") MultipartFile file) {" + }, + { + "method": "DELETE", + "path": "/otaMag/{id}", + "controller": "OTAMagController", + "handler": "delete", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAMagController.java", + "line": 109, + "java_signature": "public Result delete(@PathVariable(\"id\") String[] ids) {" + }, + { + "method": "GET", + "path": "/otaMag/{id}", + "controller": "OTAMagController", + "handler": "get", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAMagController.java", + "line": 77, + "java_signature": "public Result get(@PathVariable(\"id\") String id) {" + }, + { + "method": "PUT", + "path": "/otaMag/{id}", + "controller": "OTAMagController", + "handler": "update", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAMagController.java", + "line": 120, + "java_signature": "public Result update(@PathVariable(\"id\") String id, @RequestBody OtaEntity entity) {" + }, + { + "method": "GET", + "path": "/ttsVoice", + "controller": "TimbreController", + "handler": "page", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/timbre/controller/TimbreController.java", + "line": 42, + "java_signature": "}) public Result> page(" + }, + { + "method": "POST", + "path": "/ttsVoice", + "controller": "TimbreController", + "handler": "save", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/timbre/controller/TimbreController.java", + "line": 64, + "java_signature": "public Result save(@RequestBody TimbreDataDTO dto) {" + }, + { + "method": "POST", + "path": "/ttsVoice/delete", + "controller": "TimbreController", + "handler": "delete", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/timbre/controller/TimbreController.java", + "line": 84, + "java_signature": "public Result delete(@RequestBody String[] ids) {" + }, + { + "method": "PUT", + "path": "/ttsVoice/{id}", + "controller": "TimbreController", + "handler": "update", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/timbre/controller/TimbreController.java", + "line": 73, + "java_signature": "public Result update(" + }, + { + "method": "GET", + "path": "/user/captcha", + "controller": "LoginController", + "handler": "captcha", + "auth": "anonymous", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java", + "line": 61, + "java_signature": "public void captcha(HttpServletResponse response, String uuid) throws IOException {" + }, + { + "method": "PUT", + "path": "/user/change-password", + "controller": "LoginController", + "handler": "changePassword", + "auth": "database-token", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java", + "line": 166, + "java_signature": "public Result changePassword(@RequestBody PasswordDTO passwordDTO) {" + }, + { + "method": "GET", + "path": "/user/info", + "controller": "LoginController", + "handler": "info", + "auth": "database-token", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java", + "line": 157, + "java_signature": "public Result info() {" + }, + { + "method": "POST", + "path": "/user/login", + "controller": "LoginController", + "handler": "login", + "auth": "anonymous", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java", + "line": 89, + "java_signature": "public Result login(@RequestBody LoginDTO login) {" + }, + { + "method": "GET", + "path": "/user/pub-config", + "controller": "LoginController", + "handler": "pubConfig", + "auth": "anonymous", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java", + "line": 217, + "java_signature": "public Result> pubConfig() {" + }, + { + "method": "POST", + "path": "/user/register", + "controller": "LoginController", + "handler": "register", + "auth": "anonymous", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java", + "line": 113, + "java_signature": "public Result register(@RequestBody LoginDTO login) {" + }, + { + "method": "PUT", + "path": "/user/retrieve-password", + "controller": "LoginController", + "handler": "retrievePassword", + "auth": "anonymous", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java", + "line": 176, + "java_signature": "public Result retrievePassword(@RequestBody RetrievePasswordDTO dto) {" + }, + { + "method": "POST", + "path": "/user/smsVerification", + "controller": "LoginController", + "handler": "smsVerification", + "auth": "anonymous", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java", + "line": 70, + "java_signature": "public Result smsVerification(@RequestBody SmsVerificationDTO dto) {" + }, + { + "method": "GET", + "path": "/voiceClone", + "controller": "VoiceCloneController", + "handler": "page", + "auth": "database-token", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/voiceclone/controller/VoiceCloneController.java", + "line": 48, + "java_signature": "}) @RequiresPermissions(\"sys:role:normal\") public Result> page(" + }, + { + "method": "POST", + "path": "/voiceClone/audio/{id}", + "controller": "VoiceCloneController", + "handler": "getAudioId", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/voiceclone/controller/VoiceCloneController.java", + "line": 133, + "java_signature": "public Result getAudioId(@PathVariable(\"id\") String id) {" + }, + { + "method": "POST", + "path": "/voiceClone/cloneAudio", + "controller": "VoiceCloneController", + "handler": "cloneAudio", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/voiceclone/controller/VoiceCloneController.java", + "line": 180, + "java_signature": "public Result cloneAudio(@RequestBody Map params) {" + }, + { + "method": "GET", + "path": "/voiceClone/play/{uuid}", + "controller": "VoiceCloneController", + "handler": "playVoice", + "auth": "anonymous", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/voiceclone/controller/VoiceCloneController.java", + "line": 148, + "java_signature": "public void playVoice(@PathVariable(\"uuid\") String uuid, HttpServletResponse response) {" + }, + { + "method": "POST", + "path": "/voiceClone/updateName", + "controller": "VoiceCloneController", + "handler": "updateName", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/voiceclone/controller/VoiceCloneController.java", + "line": 108, + "java_signature": "public Result updateName(@RequestBody Map params) {" + }, + { + "method": "POST", + "path": "/voiceClone/upload", + "controller": "VoiceCloneController", + "handler": "uploadVoice", + "auth": "database-token", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/voiceclone/controller/VoiceCloneController.java", + "line": 64, + "java_signature": "}) @RequiresPermissions(\"sys:role:normal\") public Result uploadVoice(" + }, + { + "method": "GET", + "path": "/voiceResource", + "controller": "VoiceResourceController", + "handler": "page", + "auth": "database-token", + "permission": null, + "source": "main/manager-api/src/main/java/xiaozhi/modules/voiceclone/controller/VoiceResourceController.java", + "line": 42, + "java_signature": "}) @RequiresPermissions(\"sys:role:superAdmin\") public Result> page(" + }, + { + "method": "POST", + "path": "/voiceResource", + "controller": "VoiceResourceController", + "handler": "save", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/voiceclone/controller/VoiceResourceController.java", + "line": 64, + "java_signature": "public Result save(@RequestBody VoiceCloneDTO dto) {" + }, + { + "method": "GET", + "path": "/voiceResource/ttsPlatforms", + "controller": "VoiceResourceController", + "handler": "getTtsPlatformList", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/voiceclone/controller/VoiceResourceController.java", + "line": 109, + "java_signature": "public Result>> getTtsPlatformList() {" + }, + { + "method": "GET", + "path": "/voiceResource/user/{userId}", + "controller": "VoiceResourceController", + "handler": "getByUserId", + "auth": "database-token", + "permission": "sys:role:normal", + "source": "main/manager-api/src/main/java/xiaozhi/modules/voiceclone/controller/VoiceResourceController.java", + "line": 101, + "java_signature": "public Result> getByUserId(@PathVariable(\"userId\") Long userId) {" + }, + { + "method": "DELETE", + "path": "/voiceResource/{id}", + "controller": "VoiceResourceController", + "handler": "delete", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/voiceclone/controller/VoiceResourceController.java", + "line": 90, + "java_signature": "public Result delete(@PathVariable(\"id\") String[] ids) {" + }, + { + "method": "GET", + "path": "/voiceResource/{id}", + "controller": "VoiceResourceController", + "handler": "get", + "auth": "database-token", + "permission": "sys:role:superAdmin", + "source": "main/manager-api/src/main/java/xiaozhi/modules/voiceclone/controller/VoiceResourceController.java", + "line": 56, + "java_signature": "public Result get(@PathVariable(\"id\") String id) {" + } + ] +} diff --git a/main/manager-api-fastapi/compatibility/performance-results.json b/main/manager-api-fastapi/compatibility/performance-results.json new file mode 100644 index 00000000..c51001d7 --- /dev/null +++ b/main/manager-api-fastapi/compatibility/performance-results.json @@ -0,0 +1,164 @@ +{ + "schema_version": 1, + "generated_at": "2026-07-20T07:12:33.158826+00:00", + "parameters": { + "requests_per_service_per_scenario": 60, + "concurrency": 6, + "sequential_warmup_requests": 10, + "scenario_order": [ + "representative-read", + "representative-crud-update", + "runtime-configuration", + "ota-check-and-signing" + ], + "service_order": [ + "java", + "fastapi" + ] + }, + "results": [ + { + "service": "java", + "scenario": "representative-read", + "requests": 60, + "concurrency": 6, + "warmup_requests": 10, + "errors": 0, + "elapsed_seconds": 0.099812, + "throughput_requests_per_second": 601.128, + "latency_ms_min": 3.259, + "latency_ms_p50": 7.662, + "latency_ms_p95": 16.239, + "latency_ms_max": 20.352 + }, + { + "service": "fastapi", + "scenario": "representative-read", + "requests": 60, + "concurrency": 6, + "warmup_requests": 10, + "errors": 0, + "elapsed_seconds": 0.079836, + "throughput_requests_per_second": 751.538, + "latency_ms_min": 4.117, + "latency_ms_p50": 6.749, + "latency_ms_p95": 12.552, + "latency_ms_max": 16.057 + }, + { + "service": "java", + "scenario": "representative-crud-update", + "requests": 60, + "concurrency": 6, + "warmup_requests": 10, + "errors": 0, + "elapsed_seconds": 0.110495, + "throughput_requests_per_second": 543.013, + "latency_ms_min": 5.684, + "latency_ms_p50": 9.331, + "latency_ms_p95": 15.302, + "latency_ms_max": 19.008 + }, + { + "service": "fastapi", + "scenario": "representative-crud-update", + "requests": 60, + "concurrency": 6, + "warmup_requests": 10, + "errors": 0, + "elapsed_seconds": 0.12582, + "throughput_requests_per_second": 476.87, + "latency_ms_min": 6.996, + "latency_ms_p50": 11.252, + "latency_ms_p95": 20.599, + "latency_ms_max": 24.284 + }, + { + "service": "java", + "scenario": "runtime-configuration", + "requests": 60, + "concurrency": 6, + "warmup_requests": 10, + "errors": 0, + "elapsed_seconds": 0.105474, + "throughput_requests_per_second": 568.863, + "latency_ms_min": 3.901, + "latency_ms_p50": 8.746, + "latency_ms_p95": 16.127, + "latency_ms_max": 29.528 + }, + { + "service": "fastapi", + "scenario": "runtime-configuration", + "requests": 60, + "concurrency": 6, + "warmup_requests": 10, + "errors": 0, + "elapsed_seconds": 0.078418, + "throughput_requests_per_second": 765.126, + "latency_ms_min": 3.868, + "latency_ms_p50": 6.488, + "latency_ms_p95": 13.722, + "latency_ms_max": 18.923 + }, + { + "service": "java", + "scenario": "ota-check-and-signing", + "requests": 60, + "concurrency": 6, + "warmup_requests": 10, + "errors": 0, + "elapsed_seconds": 0.131968, + "throughput_requests_per_second": 454.655, + "latency_ms_min": 7.543, + "latency_ms_p50": 11.301, + "latency_ms_p95": 19.854, + "latency_ms_max": 21.729 + }, + { + "service": "fastapi", + "scenario": "ota-check-and-signing", + "requests": 60, + "concurrency": 6, + "warmup_requests": 10, + "errors": 0, + "elapsed_seconds": 0.169106, + "throughput_requests_per_second": 354.807, + "latency_ms_min": 7.066, + "latency_ms_p50": 16.208, + "latency_ms_p95": 20.344, + "latency_ms_max": 28.953 + } + ], + "comparisons": [ + { + "scenario": "representative-read", + "p50_ratio_fastapi_over_java": 0.881, + "p95_ratio_fastapi_over_java": 0.773, + "throughput_ratio_fastapi_over_java": 1.25 + }, + { + "scenario": "representative-crud-update", + "p50_ratio_fastapi_over_java": 1.206, + "p95_ratio_fastapi_over_java": 1.346, + "throughput_ratio_fastapi_over_java": 0.878 + }, + { + "scenario": "runtime-configuration", + "p50_ratio_fastapi_over_java": 0.742, + "p95_ratio_fastapi_over_java": 0.851, + "throughput_ratio_fastapi_over_java": 1.345 + }, + { + "scenario": "ota-check-and-signing", + "p50_ratio_fastapi_over_java": 1.434, + "p95_ratio_fastapi_over_java": 1.025, + "throughput_ratio_fastapi_over_java": 0.78 + } + ], + "summary": { + "measurements": 8, + "requests_measured": 480, + "errors": 0 + } +} diff --git a/main/manager-api-fastapi/compatibility/route-surface-results.json b/main/manager-api-fastapi/compatibility/route-surface-results.json new file mode 100644 index 00000000..2ff1a950 --- /dev/null +++ b/main/manager-api-fastapi/compatibility/route-surface-results.json @@ -0,0 +1,6749 @@ +{ + "schema_version": 1, + "generated_at": "2026-07-20T07:12:28.099864+00:00", + "services": { + "java": "http://127.0.0.1:18082/xiaozhi", + "fastapi": "http://127.0.0.1:18083/xiaozhi" + }, + "fixture_ids": { + "admin": "9007199254740993", + "normal": "9007199254740994" + }, + "summary": { + "total": 154, + "passed": 154, + "failed": 0, + "skipped": 0 + }, + "categories": { + "route-surface:database-token": { + "passed": 133, + "failed": 0 + }, + "route-surface:anonymous": { + "passed": 14, + "failed": 0 + }, + "route-surface:server-secret": { + "passed": 7, + "failed": 0 + } + }, + "results": [ + { + "name": "GET /admin/device/all", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /admin/dict/data/delete", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /admin/dict/data/page", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /admin/dict/data/save", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /admin/dict/data/type/{dictType}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "PUT /admin/dict/data/update", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /admin/dict/data/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /admin/dict/type/delete", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /admin/dict/type/page", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /admin/dict/type/save", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "PUT /admin/dict/type/update", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /admin/dict/type/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /admin/params", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "PUT /admin/params", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /admin/params/delete", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /admin/params/page", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /admin/params/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /admin/server/emit-action", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /admin/server/server-list", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /admin/users", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "PUT /admin/users/changeStatus/{status}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "DELETE /admin/users/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "PUT /admin/users/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /agent", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /agent/all", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /agent/audio/{audioId}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /agent/chat-history/download/{uuid}/current", + "category": "route-surface:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10136, + "msg": "下载链接已过期或无效", + "data": null + }, + "body_sha256": "60a622942beb5a8c9f9ce7457c8f8fd8bfc141c3fadf295aa576912703bd8ad4", + "body_excerpt": "{\"code\": 10136, \"data\": null, \"msg\": \"下载链接已过期或无效\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "65" + }, + "body": { + "code": 10136, + "msg": "下载链接已过期或无效", + "data": null + }, + "body_sha256": "60a622942beb5a8c9f9ce7457c8f8fd8bfc141c3fadf295aa576912703bd8ad4", + "body_excerpt": "{\"code\": 10136, \"data\": null, \"msg\": \"下载链接已过期或无效\"}" + } + }, + { + "name": "GET /agent/chat-history/download/{uuid}/previous", + "category": "route-surface:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10136, + "msg": "下载链接已过期或无效", + "data": null + }, + "body_sha256": "60a622942beb5a8c9f9ce7457c8f8fd8bfc141c3fadf295aa576912703bd8ad4", + "body_excerpt": "{\"code\": 10136, \"data\": null, \"msg\": \"下载链接已过期或无效\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "65" + }, + "body": { + "code": 10136, + "msg": "下载链接已过期或无效", + "data": null + }, + "body_sha256": "60a622942beb5a8c9f9ce7457c8f8fd8bfc141c3fadf295aa576912703bd8ad4", + "body_excerpt": "{\"code\": 10136, \"data\": null, \"msg\": \"下载链接已过期或无效\"}" + } + }, + { + "name": "POST /agent/chat-history/getDownloadUrl/{agentId}/{sessionId}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /agent/chat-history/report", + "category": "route-surface:server-secret", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "60" + }, + "body": { + "code": 401, + "msg": "服务器密钥不能为空", + "data": null + }, + "body_sha256": "de9f43e4af222a5918a2d7f70a6e9900c4a9d224961bf6e4161a8335efe6ab44", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"服务器密钥不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "60" + }, + "body": { + "code": 401, + "msg": "服务器密钥不能为空", + "data": null + }, + "body_sha256": "de9f43e4af222a5918a2d7f70a6e9900c4a9d224961bf6e4161a8335efe6ab44", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"服务器密钥不能为空\"}" + } + }, + { + "name": "POST /agent/chat-summary/{sessionId}/save", + "category": "route-surface:server-secret", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "60" + }, + "body": { + "code": 401, + "msg": "服务器密钥不能为空", + "data": null + }, + "body_sha256": "de9f43e4af222a5918a2d7f70a6e9900c4a9d224961bf6e4161a8335efe6ab44", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"服务器密钥不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "60" + }, + "body": { + "code": 401, + "msg": "服务器密钥不能为空", + "data": null + }, + "body_sha256": "de9f43e4af222a5918a2d7f70a6e9900c4a9d224961bf6e4161a8335efe6ab44", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"服务器密钥不能为空\"}" + } + }, + { + "name": "POST /agent/chat-title/{sessionId}/generate", + "category": "route-surface:server-secret", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "60" + }, + "body": { + "code": 401, + "msg": "服务器密钥不能为空", + "data": null + }, + "body_sha256": "de9f43e4af222a5918a2d7f70a6e9900c4a9d224961bf6e4161a8335efe6ab44", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"服务器密钥不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "60" + }, + "body": { + "code": 401, + "msg": "服务器密钥不能为空", + "data": null + }, + "body_sha256": "de9f43e4af222a5918a2d7f70a6e9900c4a9d224961bf6e4161a8335efe6ab44", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"服务器密钥不能为空\"}" + } + }, + { + "name": "GET /agent/list", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /agent/mcp/address/{agentId}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /agent/mcp/tools/{agentId}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /agent/play/{uuid}", + "category": "route-surface:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 404, + "headers": { + "content-type": null, + "content-disposition": null, + "content-length": "0" + }, + "body": "", + "body_sha256": "12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "body_excerpt": "\"\"" + }, + "fastapi": { + "status": 404, + "headers": { + "content-type": null, + "content-disposition": null, + "content-length": "0" + }, + "body": "", + "body_sha256": "12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "body_excerpt": "\"\"" + } + }, + { + "name": "PUT /agent/saveMemory/{macAddress}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /agent/tag", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /agent/tag/list", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "DELETE /agent/tag/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /agent/template", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /agent/template", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "PUT /agent/template", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /agent/template/batch-remove", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /agent/template/page", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "DELETE /agent/template/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /agent/template/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /agent/voice-print", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "PUT /agent/voice-print", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /agent/voice-print/list/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "DELETE /agent/voice-print/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /agent/{agentId}/snapshots", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "DELETE /agent/{agentId}/snapshots/{snapshotId}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /agent/{agentId}/snapshots/{snapshotId}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /agent/{agentId}/snapshots/{snapshotId}/restore", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "DELETE /agent/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /agent/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "PUT /agent/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /agent/{id}/chat-history/audio", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /agent/{id}/chat-history/user", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /agent/{id}/chat-history/{sessionId}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /agent/{id}/sessions", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /agent/{id}/tags", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "PUT /agent/{id}/tags", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /config/agent-models", + "category": "route-surface:server-secret", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "60" + }, + "body": { + "code": 401, + "msg": "服务器密钥不能为空", + "data": null + }, + "body_sha256": "de9f43e4af222a5918a2d7f70a6e9900c4a9d224961bf6e4161a8335efe6ab44", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"服务器密钥不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "60" + }, + "body": { + "code": 401, + "msg": "服务器密钥不能为空", + "data": null + }, + "body_sha256": "de9f43e4af222a5918a2d7f70a6e9900c4a9d224961bf6e4161a8335efe6ab44", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"服务器密钥不能为空\"}" + } + }, + { + "name": "POST /config/correct-words", + "category": "route-surface:server-secret", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "60" + }, + "body": { + "code": 401, + "msg": "服务器密钥不能为空", + "data": null + }, + "body_sha256": "de9f43e4af222a5918a2d7f70a6e9900c4a9d224961bf6e4161a8335efe6ab44", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"服务器密钥不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "60" + }, + "body": { + "code": 401, + "msg": "服务器密钥不能为空", + "data": null + }, + "body_sha256": "de9f43e4af222a5918a2d7f70a6e9900c4a9d224961bf6e4161a8335efe6ab44", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"服务器密钥不能为空\"}" + } + }, + { + "name": "POST /config/server-base", + "category": "route-surface:server-secret", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "60" + }, + "body": { + "code": 401, + "msg": "服务器密钥不能为空", + "data": null + }, + "body_sha256": "de9f43e4af222a5918a2d7f70a6e9900c4a9d224961bf6e4161a8335efe6ab44", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"服务器密钥不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "60" + }, + "body": { + "code": 401, + "msg": "服务器密钥不能为空", + "data": null + }, + "body_sha256": "de9f43e4af222a5918a2d7f70a6e9900c4a9d224961bf6e4161a8335efe6ab44", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"服务器密钥不能为空\"}" + } + }, + { + "name": "POST /correct-word/file", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /correct-word/file/batch-delete", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /correct-word/file/download/{fileId}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /correct-word/file/list", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /correct-word/file/select", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "DELETE /correct-word/file/{fileId}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "PUT /correct-word/file/{fileId}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /datasets", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /datasets", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "DELETE /datasets/batch", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /datasets/rag-models", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "DELETE /datasets/{dataset_id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /datasets/{dataset_id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "PUT /datasets/{dataset_id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /datasets/{dataset_id}/chunks", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "DELETE /datasets/{dataset_id}/documents", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /datasets/{dataset_id}/documents", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /datasets/{dataset_id}/documents", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /datasets/{dataset_id}/documents/status/{status}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "DELETE /datasets/{dataset_id}/documents/{document_id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /datasets/{dataset_id}/documents/{document_id}/chunks", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /datasets/{dataset_id}/retrieval-test", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "PUT /device/address-book/alias", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /device/address-book/call", + "category": "route-surface:server-secret", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "60" + }, + "body": { + "code": 401, + "msg": "服务器密钥不能为空", + "data": null + }, + "body_sha256": "de9f43e4af222a5918a2d7f70a6e9900c4a9d224961bf6e4161a8335efe6ab44", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"服务器密钥不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "60" + }, + "body": { + "code": 401, + "msg": "服务器密钥不能为空", + "data": null + }, + "body_sha256": "de9f43e4af222a5918a2d7f70a6e9900c4a9d224961bf6e4161a8335efe6ab44", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"服务器密钥不能为空\"}" + } + }, + { + "name": "PUT /device/address-book/permission", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /device/address-book/{macAddress}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /device/bind/{agentId}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /device/bind/{agentId}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /device/bind/{agentId}/{deviceCode}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /device/manual-add", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /device/register", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /device/tools/call/{deviceId}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /device/tools/list/{deviceId}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /device/unbind", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "PUT /device/update/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "PUT /models/default/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "PUT /models/enable/{id}/{status}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /models/list", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /models/llm/names", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /models/names", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /models/provider", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /models/provider", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "PUT /models/provider", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /models/provider/delete", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /models/provider/plugin/names", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "DELETE /models/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /models/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /models/{modelId}/voices", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /models/{modelType}/provideTypes", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /models/{modelType}/{provideCode}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "PUT /models/{modelType}/{provideCode}/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /ota/", + "category": "route-surface:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "text/plain;charset=UTF-8", + "content-disposition": null, + "content-length": "49" + }, + "body": "OTA接口运行正常,websocket集群数量:1", + "body_sha256": "328287942eb320af0a21856fb6ccdeb1947ebe8f689ad6843654230856e0b101", + "body_excerpt": "\"OTA接口运行正常,websocket集群数量:1\"" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "text/plain;charset=UTF-8", + "content-disposition": null, + "content-length": "49" + }, + "body": "OTA接口运行正常,websocket集群数量:1", + "body_sha256": "328287942eb320af0a21856fb6ccdeb1947ebe8f689ad6843654230856e0b101", + "body_excerpt": "\"OTA接口运行正常,websocket集群数量:1\"" + } + }, + { + "name": "POST /ota/", + "category": "route-surface:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "POST /ota/activate", + "category": "route-surface:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "GET /otaMag", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /otaMag", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /otaMag/download/{uuid}", + "category": "route-surface:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 404, + "headers": { + "content-type": null, + "content-disposition": null, + "content-length": "0" + }, + "body": "", + "body_sha256": "12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "body_excerpt": "\"\"" + }, + "fastapi": { + "status": 404, + "headers": { + "content-type": null, + "content-disposition": null, + "content-length": "0" + }, + "body": "", + "body_sha256": "12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "body_excerpt": "\"\"" + } + }, + { + "name": "GET /otaMag/getDownloadUrl/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /otaMag/upload", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /otaMag/uploadAssetsBin", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "DELETE /otaMag/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /otaMag/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "PUT /otaMag/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /ttsVoice", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /ttsVoice", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /ttsVoice/delete", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "PUT /ttsVoice/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /user/captcha", + "category": "route-surface:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 10006, + "msg": "唯一标识不能为空", + "data": null + }, + "body_sha256": "2398fd58b0eb89f9523328054ad97a9189ca3a861ab0ab2088a3a2a37272e514", + "body_excerpt": "{\"code\": 10006, \"data\": null, \"msg\": \"唯一标识不能为空\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "59" + }, + "body": { + "code": 10006, + "msg": "唯一标识不能为空", + "data": null + }, + "body_sha256": "2398fd58b0eb89f9523328054ad97a9189ca3a861ab0ab2088a3a2a37272e514", + "body_excerpt": "{\"code\": 10006, \"data\": null, \"msg\": \"唯一标识不能为空\"}" + } + }, + { + "name": "PUT /user/change-password", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /user/info", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /user/login", + "category": "route-surface:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "GET /user/pub-config", + "category": "route-surface:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "beianIcpNum": "null", + "systemWebMenu": { + "groups": { + "voiceManagement": [ + "vad", + "asr" + ], + "featureManagement": [ + "voiceprintRecognition", + "voiceClone", + "knowledgeBase", + "mcpAccessPoint" + ] + }, + "features": { + "asr": { + "name": "feature.asr.name", + "enabled": true, + "description": "feature.asr.description" + }, + "vad": { + "name": "feature.vad.name", + "enabled": true, + "description": "feature.vad.description" + }, + "voiceClone": { + "name": "feature.voiceClone.name", + "enabled": false, + "description": "feature.voiceClone.description" + }, + "knowledgeBase": { + "name": "feature.knowledgeBase.name", + "enabled": false, + "description": "feature.knowledgeBase.description" + }, + "mcpAccessPoint": { + "name": "feature.mcpAccessPoint.name", + "enabled": false, + "description": "feature.mcpAccessPoint.description" + }, + "voiceprintRecognition": { + "name": "feature.voiceprintRecognition.name", + "enabled": false, + "description": "feature.voiceprintRecognition.description" + } + }, + "addressBook": { + "name": "feature.addressBook.name", + "enabled": false, + "description": "feature.addressBook.description" + } + }, + "enableMobileRegister": false, + "year": "©2026", + "beianGaNum": "null", + "name": "xiaozhi-esp32-server", + "sm2PublicKey": "04409d3dc074e2b0217ee931ba5d167d85fed556ac5bf5a1c2ca0c294ab57e15b7aa55b06fd49bb337b23f88e1feaebd9ae4a5206c72ad8832f40c1a0374f83999", + "allowUserRegister": false, + "version": "0.9.5", + "mobileAreaList": [ + { + "name": "中国大陆", + "key": "+86" + }, + { + "name": "中国香港", + "key": "+852" + }, + { + "name": "中国澳门", + "key": "+853" + }, + { + "name": "中国台湾", + "key": "+886" + }, + { + "name": "美国/加拿大", + "key": "+1" + }, + { + "name": "英国", + "key": "+44" + }, + { + "name": "法国", + "key": "+33" + }, + { + "name": "意大利", + "key": "+39" + }, + { + "name": "德国", + "key": "+49" + }, + { + "name": "波兰", + "key": "+48" + }, + { + "name": "瑞士", + "key": "+41" + }, + { + "name": "西班牙", + "key": "+34" + }, + { + "name": "丹麦", + "key": "+45" + }, + { + "name": "马来西亚", + "key": "+60" + }, + { + "name": "澳大利亚", + "key": "+61" + }, + { + "name": "印度尼西亚", + "key": "+62" + }, + { + "name": "菲律宾", + "key": "+63" + }, + { + "name": "新西兰", + "key": "+64" + }, + { + "name": "新加坡", + "key": "+65" + }, + { + "name": "泰国", + "key": "+66" + }, + { + "name": "日本", + "key": "+81" + }, + { + "name": "韩国", + "key": "+82" + }, + { + "name": "越南", + "key": "+84" + }, + { + "name": "印度", + "key": "+91" + }, + { + "name": "巴基斯坦", + "key": "+92" + }, + { + "name": "尼日利亚", + "key": "+234" + }, + { + "name": "孟加拉国", + "key": "+880" + }, + { + "name": "沙特阿拉伯", + "key": "+966" + }, + { + "name": "阿联酋", + "key": "+971" + }, + { + "name": "巴西", + "key": "+55" + }, + { + "name": "墨西哥", + "key": "+52" + }, + { + "name": "智利", + "key": "+56" + }, + { + "name": "阿根廷", + "key": "+54" + }, + { + "name": "埃及", + "key": "+20" + }, + { + "name": "南非", + "key": "+27" + }, + { + "name": "肯尼亚", + "key": "+254" + }, + { + "name": "坦桑尼亚", + "key": "+255" + }, + { + "name": "哈萨克斯坦", + "key": "+7" + } + ] + } + }, + "body_sha256": "c41809c50c6e6bef5e7695d1755d9d6a9cb391d884d2dafb45c32fe410ce096f", + "body_excerpt": "{\"code\": 0, \"data\": {\"allowUserRegister\": false, \"beianGaNum\": \"null\", \"beianIcpNum\": \"null\", \"enableMobileRegister\": false, \"mobileAreaList\": [{\"key\": \"+86\", \"name\": \"中国大陆\"}, {\"key\": \"+852\", \"name\": \"中国香港\"}, {\"key\": \"+853\", \"name\": \"中国澳门\"}, {\"key\": \"+886\", \"name\": \"中国台湾\"}, {\"key\": \"+1\", \"name\": \"美国/加拿大\"}, {\"key\": \"+44\", \"name\": \"英国\"}, {\"key\": \"+33\", \"name\": \"法国\"}, {\"key\": \"+39\", \"name\": \"意大利\"}, {\"key\": \"+49\", \"name\": \"德国\"}, {\"key\": \"+48\", \"name\": \"波兰\"}, {\"key\": \"+41\", \"name\": \"瑞士\"}, {\"key\": \"+34\", \"name\": \"西班牙\"}, {\"key\": \"+45\", \"name\": \"丹麦\"}, {\"key\": \"+60\", \"name\": \"马来西亚\"}, {\"key\": \"+61\", \"name\": \"澳大利亚\"}, {\"key\": \"+62\", \"name\": \"印度尼西亚\"}, {\"key\": \"+63\", \"name\": \"菲律宾\"}, {\"key\": \"+64\", \"name\": \"新西兰\"}, {\"key\": \"+65\", \"name\": \"新加坡\"}, {\"key\": \"+66\", \"name\": \"泰国\"}, {\"key\": \"+81\", \"name\": \"日本\"}, {\"key\": \"+82\", \"name\": \"韩国\"}, {\"key\": \"+84\", \"name\": \"越南\"}, {\"key\": \"+91\", \"name\": \"印度\"}, {\"key\": \"+92\", \"name\": \"巴基斯坦\"}, {\"key\": \"+234\", \"name\": \"尼日利亚\"}, {\"key\": \"+880\", \"name\": \"孟加拉国\"}, {\"key\": \"+96" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "2589" + }, + "body": { + "code": 0, + "msg": "success", + "data": { + "enableMobileRegister": false, + "version": "0.9.5", + "year": "©2026", + "allowUserRegister": false, + "mobileAreaList": [ + { + "name": "中国大陆", + "key": "+86" + }, + { + "name": "中国香港", + "key": "+852" + }, + { + "name": "中国澳门", + "key": "+853" + }, + { + "name": "中国台湾", + "key": "+886" + }, + { + "name": "美国/加拿大", + "key": "+1" + }, + { + "name": "英国", + "key": "+44" + }, + { + "name": "法国", + "key": "+33" + }, + { + "name": "意大利", + "key": "+39" + }, + { + "name": "德国", + "key": "+49" + }, + { + "name": "波兰", + "key": "+48" + }, + { + "name": "瑞士", + "key": "+41" + }, + { + "name": "西班牙", + "key": "+34" + }, + { + "name": "丹麦", + "key": "+45" + }, + { + "name": "马来西亚", + "key": "+60" + }, + { + "name": "澳大利亚", + "key": "+61" + }, + { + "name": "印度尼西亚", + "key": "+62" + }, + { + "name": "菲律宾", + "key": "+63" + }, + { + "name": "新西兰", + "key": "+64" + }, + { + "name": "新加坡", + "key": "+65" + }, + { + "name": "泰国", + "key": "+66" + }, + { + "name": "日本", + "key": "+81" + }, + { + "name": "韩国", + "key": "+82" + }, + { + "name": "越南", + "key": "+84" + }, + { + "name": "印度", + "key": "+91" + }, + { + "name": "巴基斯坦", + "key": "+92" + }, + { + "name": "尼日利亚", + "key": "+234" + }, + { + "name": "孟加拉国", + "key": "+880" + }, + { + "name": "沙特阿拉伯", + "key": "+966" + }, + { + "name": "阿联酋", + "key": "+971" + }, + { + "name": "巴西", + "key": "+55" + }, + { + "name": "墨西哥", + "key": "+52" + }, + { + "name": "智利", + "key": "+56" + }, + { + "name": "阿根廷", + "key": "+54" + }, + { + "name": "埃及", + "key": "+20" + }, + { + "name": "南非", + "key": "+27" + }, + { + "name": "肯尼亚", + "key": "+254" + }, + { + "name": "坦桑尼亚", + "key": "+255" + }, + { + "name": "哈萨克斯坦", + "key": "+7" + } + ], + "beianIcpNum": "null", + "beianGaNum": "null", + "name": "xiaozhi-esp32-server", + "sm2PublicKey": "04409d3dc074e2b0217ee931ba5d167d85fed556ac5bf5a1c2ca0c294ab57e15b7aa55b06fd49bb337b23f88e1feaebd9ae4a5206c72ad8832f40c1a0374f83999", + "systemWebMenu": { + "groups": { + "voiceManagement": [ + "vad", + "asr" + ], + "featureManagement": [ + "voiceprintRecognition", + "voiceClone", + "knowledgeBase", + "mcpAccessPoint" + ] + }, + "features": { + "asr": { + "name": "feature.asr.name", + "enabled": true, + "description": "feature.asr.description" + }, + "vad": { + "name": "feature.vad.name", + "enabled": true, + "description": "feature.vad.description" + }, + "voiceClone": { + "name": "feature.voiceClone.name", + "enabled": false, + "description": "feature.voiceClone.description" + }, + "knowledgeBase": { + "name": "feature.knowledgeBase.name", + "enabled": false, + "description": "feature.knowledgeBase.description" + }, + "mcpAccessPoint": { + "name": "feature.mcpAccessPoint.name", + "enabled": false, + "description": "feature.mcpAccessPoint.description" + }, + "voiceprintRecognition": { + "name": "feature.voiceprintRecognition.name", + "enabled": false, + "description": "feature.voiceprintRecognition.description" + } + }, + "addressBook": { + "name": "feature.addressBook.name", + "enabled": false, + "description": "feature.addressBook.description" + } + } + } + }, + "body_sha256": "c41809c50c6e6bef5e7695d1755d9d6a9cb391d884d2dafb45c32fe410ce096f", + "body_excerpt": "{\"code\": 0, \"data\": {\"allowUserRegister\": false, \"beianGaNum\": \"null\", \"beianIcpNum\": \"null\", \"enableMobileRegister\": false, \"mobileAreaList\": [{\"key\": \"+86\", \"name\": \"中国大陆\"}, {\"key\": \"+852\", \"name\": \"中国香港\"}, {\"key\": \"+853\", \"name\": \"中国澳门\"}, {\"key\": \"+886\", \"name\": \"中国台湾\"}, {\"key\": \"+1\", \"name\": \"美国/加拿大\"}, {\"key\": \"+44\", \"name\": \"英国\"}, {\"key\": \"+33\", \"name\": \"法国\"}, {\"key\": \"+39\", \"name\": \"意大利\"}, {\"key\": \"+49\", \"name\": \"德国\"}, {\"key\": \"+48\", \"name\": \"波兰\"}, {\"key\": \"+41\", \"name\": \"瑞士\"}, {\"key\": \"+34\", \"name\": \"西班牙\"}, {\"key\": \"+45\", \"name\": \"丹麦\"}, {\"key\": \"+60\", \"name\": \"马来西亚\"}, {\"key\": \"+61\", \"name\": \"澳大利亚\"}, {\"key\": \"+62\", \"name\": \"印度尼西亚\"}, {\"key\": \"+63\", \"name\": \"菲律宾\"}, {\"key\": \"+64\", \"name\": \"新西兰\"}, {\"key\": \"+65\", \"name\": \"新加坡\"}, {\"key\": \"+66\", \"name\": \"泰国\"}, {\"key\": \"+81\", \"name\": \"日本\"}, {\"key\": \"+82\", \"name\": \"韩国\"}, {\"key\": \"+84\", \"name\": \"越南\"}, {\"key\": \"+91\", \"name\": \"印度\"}, {\"key\": \"+92\", \"name\": \"巴基斯坦\"}, {\"key\": \"+234\", \"name\": \"尼日利亚\"}, {\"key\": \"+880\", \"name\": \"孟加拉国\"}, {\"key\": \"+96" + } + }, + { + "name": "POST /user/register", + "category": "route-surface:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "PUT /user/retrieve-password", + "category": "route-surface:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "POST /user/smsVerification", + "category": "route-surface:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": null + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json", + "content-disposition": null, + "content-length": "54" + }, + "body": { + "code": 500, + "msg": "服务器内部异常", + "data": null + }, + "body_sha256": "68e87bf48447199af76a7489b2fa2a2de52caeb76878edb315178f9777bea8d6", + "body_excerpt": "{\"code\": 500, \"data\": null, \"msg\": \"服务器内部异常\"}" + } + }, + { + "name": "GET /voiceClone", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /voiceClone/audio/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /voiceClone/cloneAudio", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /voiceClone/play/{uuid}", + "category": "route-surface:anonymous", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 404, + "headers": { + "content-type": null, + "content-disposition": null, + "content-length": "0" + }, + "body": "", + "body_sha256": "12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "body_excerpt": "\"\"" + }, + "fastapi": { + "status": 404, + "headers": { + "content-type": null, + "content-disposition": null, + "content-length": "0" + }, + "body": "", + "body_sha256": "12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "body_excerpt": "\"\"" + } + }, + { + "name": "POST /voiceClone/updateName", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /voiceClone/upload", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /voiceResource", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "POST /voiceResource", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /voiceResource/ttsPlatforms", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /voiceResource/user/{userId}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "DELETE /voiceResource/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + }, + { + "name": "GET /voiceResource/{id}", + "category": "route-surface:database-token", + "passed": true, + "checks": { + "http_status": true, + "body": true, + "header:content-type": true + }, + "difference": null, + "java": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + }, + "fastapi": { + "status": 200, + "headers": { + "content-type": "application/json;charset=utf-8", + "content-disposition": null, + "content-length": "42" + }, + "body": { + "code": 401, + "msg": "未授权", + "data": null + }, + "body_sha256": "b36a7313f7b7ea8481f5c434df5dace4fe85ee705bb684eeb82e39ba7407d2d7", + "body_excerpt": "{\"code\": 401, \"data\": null, \"msg\": \"未授权\"}" + } + } + ], + "coverage": { + "java_routes": 154, + "request_profile": "missing-auth-or-safe-invalid-input", + "side_effect_policy": "no successful write request is issued" + } +} diff --git a/main/manager-api-fastapi/deploy/nginx-entrypoint.sh b/main/manager-api-fastapi/deploy/nginx-entrypoint.sh new file mode 100755 index 00000000..ecd04d6d --- /dev/null +++ b/main/manager-api-fastapi/deploy/nginx-entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -eu + +UPSTREAM=${MANAGER_API_UPSTREAM:-manager-api-fastapi:8002} +case "${UPSTREAM}" in + ''|*[!A-Za-z0-9._:-]*) + echo "MANAGER_API_UPSTREAM must be a hostname-or-IP and port" >&2 + exit 2 + ;; +esac + +export MANAGER_API_UPSTREAM=${UPSTREAM} +envsubst '${MANAGER_API_UPSTREAM}' \ + < /etc/nginx/nginx.conf.template \ + > /tmp/manager-api-nginx.conf +exec nginx -c /tmp/manager-api-nginx.conf -g 'daemon off;' diff --git a/main/manager-api-fastapi/deploy/nginx.conf b/main/manager-api-fastapi/deploy/nginx.conf new file mode 100644 index 00000000..39c48605 --- /dev/null +++ b/main/manager-api-fastapi/deploy/nginx.conf @@ -0,0 +1,45 @@ +worker_processes auto; +pid /tmp/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + access_log /dev/stdout; + error_log /dev/stderr warn; + sendfile on; + keepalive_timeout 65; + client_max_body_size 100m; + + upstream manager_api_fastapi { + server ${MANAGER_API_UPSTREAM}; + keepalive 32; + } + + server { + listen 8002; + server_name _; + + location = /xiaozhi { + return 308 /xiaozhi/; + } + + location /xiaozhi/ { + proxy_pass http://manager_api_fastapi; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_connect_timeout 10s; + proxy_send_timeout 130s; + proxy_read_timeout 130s; + proxy_request_buffering off; + proxy_buffering off; + } + } +} diff --git a/main/manager-api-fastapi/docker-compose.yml b/main/manager-api-fastapi/docker-compose.yml new file mode 100644 index 00000000..e17bc556 --- /dev/null +++ b/main/manager-api-fastapi/docker-compose.yml @@ -0,0 +1,93 @@ +services: + manager-api-migrate: + image: xiaozhi/manager-api-migrate:fastapi-0.1.0 + build: + context: ../.. + dockerfile: main/manager-api-fastapi/Dockerfile.migrations + environment: + LIQUIBASE_URL: ${LIQUIBASE_URL:?Set a JDBC MySQL URL} + LIQUIBASE_USERNAME: ${MYSQL_USER:?Set MYSQL_USER} + LIQUIBASE_PASSWORD: ${MYSQL_PASSWORD:?Set MYSQL_PASSWORD} + MIGRATION_POM: /migration/pom.xml + JAVA_RESOURCES_DIR: /migration/java-resources + MAVEN_BIN: mvn + restart: "no" + + manager-api-fastapi: + image: xiaozhi/manager-api-fastapi:0.1.0 + build: + context: ../.. + dockerfile: main/manager-api-fastapi/Dockerfile + init: true + depends_on: + manager-api-migrate: + condition: service_completed_successfully + environment: + APP_ENVIRONMENT: production + APP_DATABASE_URL: ${FASTAPI_DATABASE_URL:?Set an asyncmy MySQL URL} + APP_REDIS_URL: ${REDIS_URL:?Set REDIS_URL} + APP_WORKERS: ${APP_WORKERS:-2} + APP_GRACEFUL_SHUTDOWN_SECONDS: ${APP_GRACEFUL_SHUTDOWN_SECONDS:-30} + APP_FORWARDED_ALLOW_IPS: ${APP_FORWARDED_ALLOW_IPS:-*} + APP_ALLOW_START_WITHOUT_DEPENDENCIES: "false" + expose: + - "8002" + volumes: + # During cutover this source can be the retained Java service's host + # uploadfile directory so both implementations see identical files. + - ${MANAGER_API_UPLOAD_SOURCE:-manager-api-uploads}:/data/uploads + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + restart: unless-stopped + stop_grace_period: 40s + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8002/xiaozhi/health/ready', timeout=2).read()"] + interval: 15s + timeout: 3s + retries: 4 + start_period: 20s + + manager-api-jobs: + image: xiaozhi/manager-api-fastapi:0.1.0 + init: true + depends_on: + manager-api-migrate: + condition: service_completed_successfully + command: ["python", "-m", "app.jobs.worker"] + environment: + APP_ENVIRONMENT: production + APP_DATABASE_URL: ${FASTAPI_DATABASE_URL:?Set an asyncmy MySQL URL} + APP_REDIS_URL: ${REDIS_URL:?Set REDIS_URL} + APP_GRACEFUL_SHUTDOWN_SECONDS: ${APP_GRACEFUL_SHUTDOWN_SECONDS:-30} + APP_ALLOW_START_WITHOUT_DEPENDENCIES: "false" + volumes: + - ${MANAGER_API_UPLOAD_SOURCE:-manager-api-uploads}:/data/uploads + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + restart: unless-stopped + stop_grace_period: 40s + + manager-api-nginx: + image: xiaozhi/manager-api-nginx:fastapi-0.1.0 + build: + context: ../.. + dockerfile: main/manager-api-fastapi/Dockerfile.nginx + depends_on: + manager-api-fastapi: + condition: service_healthy + ports: + - "8002:8002" + environment: + MANAGER_API_UPSTREAM: ${MANAGER_API_UPSTREAM:-manager-api-fastapi:8002} + read_only: true + tmpfs: + - /var/cache/nginx:size=32m + - /var/run:size=1m + - /tmp:size=16m + restart: unless-stopped + stop_grace_period: 15s + +volumes: + manager-api-uploads: diff --git a/main/manager-api-fastapi/migration-pom.xml b/main/manager-api-fastapi/migration-pom.xml new file mode 100644 index 00000000..46e99679 --- /dev/null +++ b/main/manager-api-fastapi/migration-pom.xml @@ -0,0 +1,84 @@ + + + 4.0.0 + xiaozhi + manager-api-liquibase-runner + 1.0.0 + + 21 + UTF-8 + ${project.basedir}/../manager-api/src/main/resources + 4.20.0 + 9.1.0 + 6.2.3 + + + + org.liquibase + liquibase-core + ${liquibase.version} + + + org.springframework + spring-jdbc + ${spring.version} + + + org.springframework + spring-context + ${spring.version} + + + com.mysql + mysql-connector-j + ${mysql.version} + + + org.slf4j + slf4j-simple + 2.0.16 + + + + ${project.basedir}/migration-src + + + ${java.resources.dir} + false + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + false + true + all + + + xiaozhi.migration.LiquibaseMigrationRunner + + + + + + + + + + diff --git a/main/manager-api-fastapi/migration-src/xiaozhi/migration/LiquibaseMigrationRunner.java b/main/manager-api-fastapi/migration-src/xiaozhi/migration/LiquibaseMigrationRunner.java new file mode 100644 index 00000000..46908e0a --- /dev/null +++ b/main/manager-api-fastapi/migration-src/xiaozhi/migration/LiquibaseMigrationRunner.java @@ -0,0 +1,57 @@ +package xiaozhi.migration; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; + +import javax.sql.DataSource; + +import liquibase.integration.spring.SpringLiquibase; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +/** Runs only the original Spring Liquibase changelog; it never starts manager-api or Redis. */ +public final class LiquibaseMigrationRunner { + private LiquibaseMigrationRunner() { + } + + public static void main(String[] args) throws Exception { + String url = requiredEnvironment("MIGRATION_JDBC_URL"); + String username = requiredEnvironment("MIGRATION_USERNAME"); + String password = requiredEnvironment("MIGRATION_PASSWORD"); + + DriverManagerDataSource dataSource = new DriverManagerDataSource(url, username, password); + dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver"); + runLiquibase(dataSource); + System.out.println("Liquibase migration complete; applied changeSets=" + appliedChangeSetCount(dataSource)); + } + + private static void runLiquibase(DataSource dataSource) throws Exception { + SpringLiquibase liquibase = new SpringLiquibase(); + liquibase.setDataSource(dataSource); + liquibase.setChangeLog("classpath:db/changelog/db.changelog-master.yaml"); + liquibase.setResourceLoader(new DefaultResourceLoader(Thread.currentThread().getContextClassLoader())); + liquibase.setDropFirst(false); + liquibase.setShouldRun(true); + liquibase.afterPropertiesSet(); + } + + private static int appliedChangeSetCount(DataSource dataSource) throws Exception { + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery("SELECT COUNT(*) FROM DATABASECHANGELOG")) { + if (!result.next()) { + throw new IllegalStateException("DATABASECHANGELOG count query returned no row"); + } + return result.getInt(1); + } + } + + private static String requiredEnvironment(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Required environment variable is missing: " + name); + } + return value; + } +} diff --git a/main/manager-api-fastapi/pyproject.toml b/main/manager-api-fastapi/pyproject.toml new file mode 100644 index 00000000..35a8d16f --- /dev/null +++ b/main/manager-api-fastapi/pyproject.toml @@ -0,0 +1,75 @@ +[project] +name = "xiaozhi-manager-api-fastapi" +version = "0.1.0" +description = "FastAPI-compatible replacement for xiaozhi manager-api" +readme = "README.md" +requires-python = ">=3.10,<3.13" +dependencies = [ + "aiosqlite==0.21.0", + "asyncmy==0.2.10", + "bcrypt==4.3.0", + "cryptography==45.0.5", + "fastapi==0.116.1", + "gmssl==3.2.2", + "httpx==0.28.1", + "pillow==11.3.0", + # FastAPI 0.116 reconstructs request fields through TypeAdapter. Pydantic + # 2.13 warns that aliases on that compatibility path are ineffective; pin + # the contemporary 2.11 line so request aliases remain warning-free. + "pydantic==2.11.7", + "pydantic-settings==2.10.1", + "python-multipart==0.0.20", + "pyyaml==6.0.2", + "redis[hiredis]==6.2.0", + "sqlalchemy[asyncio]==2.0.41", + "uvicorn[standard]==0.35.0", + "websockets==15.0.1", +] + +[dependency-groups] +dev = [ + "mypy==1.17.0", + "pytest==8.4.1", + "pytest-asyncio==1.1.0", + "pytest-cov==6.2.1", + "respx==0.22.0", + "ruff==0.12.4", +] + +[tool.uv] +default-groups = ["dev"] + +[tool.pytest.ini_options] +addopts = "-ra --strict-config --strict-markers" +asyncio_mode = "auto" +testpaths = ["tests"] +markers = [ + "integration: requires isolated MySQL and Redis", + "contract: compares the Java and FastAPI services", + "performance: runs the representative performance comparison", +] + +[tool.ruff] +target-version = "py310" +line-length = 120 +exclude = ["tests/fixtures/generated"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "ASYNC", "S"] +ignore = ["S101"] + +[tool.ruff.lint.per-file-ignores] +"app/routers/*.py" = ["B008"] + +[tool.mypy] +python_version = "3.10" +strict = true +plugins = ["pydantic.mypy"] +exclude = ["tests/fixtures/generated"] + +[build-system] +requires = ["hatchling==1.27.0"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] diff --git a/main/manager-api-fastapi/scripts/container-entrypoint.sh b/main/manager-api-fastapi/scripts/container-entrypoint.sh new file mode 100755 index 00000000..26571c73 --- /dev/null +++ b/main/manager-api-fastapi/scripts/container-entrypoint.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +if [ "$#" -gt 0 ]; then + exec "$@" +fi + +exec uvicorn app.main:app \ + --host "${APP_HOST:-0.0.0.0}" \ + --port "${APP_PORT:-8002}" \ + --workers "${APP_WORKERS:-2}" \ + --timeout-graceful-shutdown "${APP_GRACEFUL_SHUTDOWN_SECONDS:-30}" \ + --proxy-headers \ + --forwarded-allow-ips "${APP_FORWARDED_ALLOW_IPS:-127.0.0.1}" diff --git a/main/manager-api-fastapi/scripts/extract_consumer_routes.py b/main/manager-api-fastapi/scripts/extract_consumer_routes.py new file mode 100644 index 00000000..8ddea740 --- /dev/null +++ b/main/manager-api-fastapi/scripts/extract_consumer_routes.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +"""Extract manager-api HTTP call sites from all three in-repository consumers.""" + +from __future__ import annotations + +import json +import re +from collections import Counter +from pathlib import Path +from typing import NamedTuple + +TARGET_ROOT = Path(__file__).resolve().parents[1] +REPO_ROOT = TARGET_ROOT.parents[1] +MAIN_ROOT = REPO_ROOT / "main" + +WEB_ROOT = MAIN_ROOT / "manager-web" / "src" +MOBILE_ROOT = MAIN_ROOT / "manager-mobile" / "src" +SERVER_ROOT = MAIN_ROOT / "xiaozhi-server" + +WEB_CHAIN = re.compile( + r"\.url\(\s*(?P[`'\"])(?P.*?)(?P=quote)\s*\)" + r"(?:\s*//[^\n]*)?\s*\.method\(\s*['\"](?P[A-Za-z]+)['\"]\s*\)", + re.DOTALL, +) +WEB_CONFIG = re.compile( + r"\burl\s*:\s*(?P[`'\"])(?P.*?)(?P=quote)\s*," + r"\s*method\s*:\s*['\"](?P[A-Za-z]+)['\"]", + re.DOTALL, +) +WEB_TEMPLATE_URL = re.compile( + r"(?P`)(?P\$\{(?:(?:Api|api)\.)?getServiceUrl\(\)\}/.*?)" + r"(?P=quote)" +) +WEB_CONCAT_URL = re.compile( + r"(?:(?:Api|api)\.)?getServiceUrl\(\)\s*\+\s*(?P`)(?P/.*?)(?P=quote)" +) +MOBILE_HTTP = re.compile( + r"http\.(?PGet|Post|Put|Delete|Patch)(?:<[^\n(]*>)?\(\s*" + r"(?P[`'\"])(?P.*?)(?P=quote)" +) +MOBILE_UNI = re.compile( + r"uni\.request\(\s*\{(?:(?!\}\s*\)).)*?\burl\s*:\s*" + r"(?P[`'\"])(?P.*?)(?P=quote)\s*,\s*" + r"method\s*:\s*['\"](?P[A-Za-z]+)['\"]", + re.DOTALL, +) +SERVER_CLIENT = re.compile( + r"\._execute_async_request\(\s*['\"](?P[A-Za-z]+)['\"]\s*,\s*" + r"f?(?P[`'\"])(?P/.*?)(?P=quote)", + re.DOTALL, +) +SERVER_DIRECT = re.compile( + r"f(?P[`'\"])\{api_url\}(?P/device/address-book/call)(?P=quote)" +) +JS_EXPRESSION = re.compile(r"\$\{([^{}]+)\}") +PYTHON_EXPRESSION = re.compile(r"\{([^{}]+)\}") +PATH_PARAMETER = re.compile(r"\{[^/{}]+\}") + + +class CallSite(NamedTuple): + consumer: str + method: str + path: str + source: str + + +def _source(path: Path, text: str, offset: int) -> str: + line = text.count("\n", 0, offset) + 1 + return f"{path.relative_to(REPO_ROOT).as_posix()}:{line}" + + +def _parameter_name(expression: str) -> str: + identifiers = re.findall(r"[A-Za-z_][A-Za-z0-9_]*", expression) + ignored = {"getServiceUrl", "encodeURIComponent", "toString", "value"} + useful = [item for item in identifiers if item not in ignored] + return useful[-1] if useful else "value" + + +def normalize_path(raw: str) -> str: + value = raw.strip() + for marker in ( + "${getServiceUrl()}", + "${Api.getServiceUrl()}", + "${api.getServiceUrl()}", + "${baseUrlInput.value}", + "${getEnvBaseUrl()}", + "{api_url}", + ): + if value.startswith(marker): + value = value[len(marker) :] + break + value = value.split("?", 1)[0].split("#", 1)[0] + value = JS_EXPRESSION.sub(lambda match: "{" + _parameter_name(match.group(1)) + "}", value) + value = PYTHON_EXPRESSION.sub(lambda match: "{" + _parameter_name(match.group(1)) + "}", value) + if not value.startswith("/"): + raise ValueError(f"consumer URL does not resolve to a manager-api path: {raw!r}") + return re.sub(r"/{2,}", "/", value).rstrip("/") or "/" + + +def _iter_source_files(root: Path, suffixes: set[str]) -> list[Path]: + return sorted( + path + for path in root.rglob("*") + if path.is_file() + and path.suffix in suffixes + and "node_modules" not in path.parts + and "dist" not in path.parts + ) + + +def extract_web() -> list[CallSite]: + calls: list[CallSite] = [] + for path in _iter_source_files(WEB_ROOT, {".js", ".mjs", ".vue"}): + text = path.read_text(encoding="utf-8") + covered: list[tuple[int, int]] = [] + for pattern in (WEB_CHAIN, WEB_CONFIG): + for match in pattern.finditer(text): + raw_url = match.group("url") + if "getServiceUrl()" not in raw_url: + continue + calls.append( + CallSite( + "manager-web", + match.group("method").upper(), + normalize_path(raw_url), + _source(path, text, match.start("url")), + ) + ) + covered.append(match.span("url")) + + def already_covered(offset: int, spans: list[tuple[int, int]] = covered) -> bool: + return any(start <= offset < end for start, end in spans) + + for pattern in (WEB_TEMPLATE_URL, WEB_CONCAT_URL): + for match in pattern.finditer(text): + if already_covered(match.start("url")): + continue + line_start = text.rfind("\n", 0, match.start()) + 1 + line_end = text.find("\n", match.end()) + line = text[line_start : len(text) if line_end < 0 else line_end] + if "console.log" in line: + continue + calls.append( + CallSite( + "manager-web", + "GET", + normalize_path(match.group("url")), + _source(path, text, match.start("url")), + ) + ) + + literal_builders = len( + re.findall(r"\.url\(\s*[`'\"]\$\{getServiceUrl\(\)\}", text) + ) + parsed_builders = sum( + 1 + for match in WEB_CHAIN.finditer(text) + if "getServiceUrl()" in match.group("url") + ) + if literal_builders != parsed_builders: + raise RuntimeError( + f"unparsed manager-web request builder(s) in {path}: " + f"found={literal_builders}, parsed={parsed_builders}" + ) + return calls + + +def extract_mobile() -> list[CallSite]: + calls: list[CallSite] = [] + for path in _iter_source_files(MOBILE_ROOT, {".ts", ".vue"}): + text = path.read_text(encoding="utf-8") + parsed_http = list(MOBILE_HTTP.finditer(text)) + raw_http_count = len(re.findall(r"\bhttp\.(?:Get|Post|Put|Delete|Patch)(?:<|\()", text)) + if raw_http_count != len(parsed_http): + raise RuntimeError( + f"unparsed manager-mobile http call(s) in {path}: " + f"found={raw_http_count}, parsed={len(parsed_http)}" + ) + for match in parsed_http: + calls.append( + CallSite( + "manager-mobile", + match.group("method").upper(), + normalize_path(match.group("url")), + _source(path, text, match.start("url")), + ) + ) + for match in MOBILE_UNI.finditer(text): + raw_url = match.group("url") + if not raw_url.startswith(("${baseUrlInput.value}", "${getEnvBaseUrl()}")): + continue + calls.append( + CallSite( + "manager-mobile", + match.group("method").upper(), + normalize_path(raw_url), + _source(path, text, match.start("url")), + ) + ) + return calls + + +def extract_server() -> list[CallSite]: + calls: list[CallSite] = [] + path = SERVER_ROOT / "config" / "manage_api_client.py" + text = path.read_text(encoding="utf-8") + matches = list(SERVER_CLIENT.finditer(text)) + raw_count = len(re.findall(r"\._execute_async_request\(", text)) + if raw_count != len(matches): + raise RuntimeError( + f"unparsed xiaozhi-server manager client call(s): found={raw_count}, parsed={len(matches)}" + ) + for match in matches: + calls.append( + CallSite( + "xiaozhi-server", + match.group("method").upper(), + normalize_path(match.group("url")), + _source(path, text, match.start("url")), + ) + ) + + direct_path = SERVER_ROOT / "plugins_func" / "functions" / "call_device.py" + direct_text = direct_path.read_text(encoding="utf-8") + direct_matches = list(SERVER_DIRECT.finditer(direct_text)) + if len(direct_matches) != 1: + raise RuntimeError(f"expected one direct manager-api call in {direct_path}, found {len(direct_matches)}") + match = direct_matches[0] + calls.append( + CallSite( + "xiaozhi-server", + "GET", + normalize_path(match.group("url")), + _source(direct_path, direct_text, match.start("url")), + ) + ) + return calls + + +def canonical_route(method: str, path: str) -> tuple[str, str]: + return method, PATH_PARAMETER.sub("{}", path) + + +def build_manifest() -> dict[str, object]: + calls = sorted( + extract_web() + extract_mobile() + extract_server(), + key=lambda item: (item.consumer, item.source, item.method, item.path), + ) + consumers: dict[str, dict[str, object]] = {} + for consumer in ("manager-web", "manager-mobile", "xiaozhi-server"): + selected = [item for item in calls if item.consumer == consumer] + consumers[consumer] = { + "callSites": len(selected), + "uniqueRoutes": len({canonical_route(item.method, item.path) for item in selected}), + "methods": dict(sorted(Counter(item.method for item in selected).items())), + } + all_routes = {canonical_route(item.method, item.path) for item in calls} + return { + "count": len(calls), + "uniqueRoutes": len(all_routes), + "consumers": consumers, + "calls": [item._asdict() for item in calls], + } + + +if __name__ == "__main__": + print(json.dumps(build_manifest(), ensure_ascii=False, indent=2, sort_keys=False)) diff --git a/main/manager-api-fastapi/scripts/extract_java_routes.py b/main/manager-api-fastapi/scripts/extract_java_routes.py new file mode 100644 index 00000000..fa85930d --- /dev/null +++ b/main/manager-api-fastapi/scripts/extract_java_routes.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Extract the Spring MVC contract without starting the Java application.""" + +from __future__ import annotations + +import argparse +import fnmatch +import json +import re +from dataclasses import asdict, dataclass +from pathlib import Path + +MAPPING_RE = re.compile(r"@(Get|Post|Put|Delete|Patch)Mapping(?:\((.*)\))?") +CLASS_MAPPING_RE = re.compile(r"@RequestMapping\(\s*(?:value\s*=\s*)?[\"']([^\"']*)[\"']") +PATH_RE = re.compile(r"[\"']([^\"']*)[\"']") +METHOD_RE = re.compile(r"\bpublic\s+(?:<[^>]+>\s+)?[^=(;]+?\s+(\w+)\s*\(") +PERMISSION_RE = re.compile(r'@RequiresPermissions\(\s*"([^"]+)"') + +PUBLIC_PATTERNS = ( + "/ota/**", + "/otaMag/download/**", + "/webjars/**", + "/druid/**", + "/v3/api-docs/**", + "/doc.html", + "/favicon.ico", + "/user/captcha", + "/user/smsVerification", + "/user/login", + "/user/pub-config", + "/user/register", + "/user/retrieve-password", + "/agent/chat-history/download/**", + "/agent/play/**", + "/voiceClone/play/**", +) +SERVER_PATTERNS = ( + "/config/**", + "/device/address-book/call", + "/agent/chat-history/report", + "/agent/chat-summary/**", + "/agent/chat-title/**", +) + + +@dataclass(frozen=True, slots=True) +class Route: + method: str + path: str + controller: str + handler: str + auth: str + permission: str | None + source: str + line: int + java_signature: str + + +def _spring_match(path: str, pattern: str) -> bool: + return fnmatch.fnmatchcase(path, pattern.replace("**", "*")) + + +def classify_auth(path: str) -> str: + if any(_spring_match(path, pattern) for pattern in PUBLIC_PATTERNS): + return "anonymous" + if any(_spring_match(path, pattern) for pattern in SERVER_PATTERNS): + return "server-secret" + return "database-token" + + +def join_paths(base: str, child: str) -> str: + if not base: + base = "/" + if not base.startswith("/"): + base = "/" + base + if not child: + return base + if not child.startswith("/"): + child = "/" + child + if base == "/": + return child + return base.rstrip("/") + child + + +def extract_controller(path: Path, root: Path) -> list[Route]: + source = path.read_text(encoding="utf-8") + class_position = source.find(" class ") + class_header = source[:class_position] if class_position >= 0 else source + class_match = list(CLASS_MAPPING_RE.finditer(class_header)) + base = class_match[-1].group(1) if class_match else "" + controller_match = re.search(r"public\s+class\s+(\w+)", source) + controller = controller_match.group(1) if controller_match else path.stem + lines = source.splitlines() + routes: list[Route] = [] + for index, line in enumerate(lines): + mapping = MAPPING_RE.search(line) + if not mapping: + continue + method = mapping.group(1).upper() + arguments = mapping.group(2) or "" + path_match = PATH_RE.search(arguments) + child = path_match.group(1) if path_match else "" + decorator_block: list[str] = [line] + signature_lines: list[str] = [] + handler = "unknown" + for cursor in range(index + 1, min(index + 40, len(lines))): + candidate = lines[cursor] + if not signature_lines and candidate.lstrip().startswith("@"): + decorator_block.append(candidate) + continue + signature_lines.append(candidate.strip()) + signature = " ".join(signature_lines) + handler_match = METHOD_RE.search(signature) + if handler_match: + handler = handler_match.group(1) + break + if "{" in candidate and "public " not in signature: + break + permission_match = PERMISSION_RE.search("\n".join(decorator_block)) + route_path = join_paths(base, child) + routes.append( + Route( + method=method, + path=route_path, + controller=controller, + handler=handler, + auth=classify_auth(route_path), + permission=permission_match.group(1) if permission_match else None, + source=str(path.relative_to(root)), + line=index + 1, + java_signature=" ".join(signature_lines), + ) + ) + return routes + + +def extract_routes(java_root: Path, repository_root: Path) -> list[Route]: + routes: list[Route] = [] + for controller in sorted(java_root.rglob("*Controller.java")): + routes.extend(extract_controller(controller, repository_root)) + return sorted(routes, key=lambda route: (route.path, route.method, route.controller, route.handler)) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repository-root", type=Path, default=Path(__file__).resolve().parents[3]) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + repository_root = args.repository_root.resolve() + java_root = repository_root / "main" / "manager-api" / "src" / "main" / "java" + routes = extract_routes(java_root, repository_root) + payload = { + "source": "main/manager-api", + "contextPath": "/xiaozhi", + "count": len(routes), + "routes": [asdict(route) for route in routes], + } + serialized = json.dumps(payload, ensure_ascii=False, indent=2) + "\n" + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(serialized, encoding="utf-8") + else: + print(serialized, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/main/manager-api-fastapi/scripts/isolated-env.sh b/main/manager-api-fastapi/scripts/isolated-env.sh new file mode 100755 index 00000000..aeffbb8d --- /dev/null +++ b/main/manager-api-fastapi/scripts/isolated-env.sh @@ -0,0 +1,146 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +TARGET_DIR=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd) +REPOSITORY_ROOT=$(CDPATH= cd -- "${TARGET_DIR}/../.." && pwd) +RUNTIME="${REPOSITORY_ROOT}/.runtime" +STATE_DIR="${TARGET_DIR}/.test-runtime" +MYSQL_BASE="${RUNTIME}/mysql" +MYSQL_PORT="${TEST_MYSQL_PORT:-13316}" +MYSQL_DATA="${STATE_DIR}/mysql-data" +MYSQL_SOCKET="${STATE_DIR}/mysql.sock" +MYSQL_PID="${STATE_DIR}/mysql.pid" +MYSQL_LOG="${STATE_DIR}/mysql.log" +REDIS_PORT="${TEST_REDIS_PORT:-16379}" +REDIS_DIR="${STATE_DIR}/redis-data" +REDIS_PID="${STATE_DIR}/redis.pid" +REDIS_LOG="${STATE_DIR}/redis.log" +TEST_USER="xiaozhi_test" +TEST_PASSWORD="isolated-test-only" +JAVA_DATABASE="manager_java_test" +FASTAPI_DATABASE="manager_fastapi_test" + +require_binaries() { + for binary in "${MYSQL_BASE}/bin/mysqld" "${MYSQL_BASE}/bin/mysql" \ + "${RUNTIME}/redis/bin/redis-server" "${RUNTIME}/redis/bin/redis-cli"; do + if [ ! -x "${binary}" ]; then + echo "Missing isolated-test binary: ${binary}" >&2 + exit 1 + fi + done +} + +mysql_ready() { + "${MYSQL_BASE}/bin/mysqladmin" --protocol=SOCKET --socket="${MYSQL_SOCKET}" -uroot ping >/dev/null 2>&1 +} + +redis_ready() { + "${RUNTIME}/redis/bin/redis-cli" -h 127.0.0.1 -p "${REDIS_PORT}" ping >/dev/null 2>&1 +} + +wait_until() { + description=$1 + shift + attempts=0 + until "$@"; do + attempts=$((attempts + 1)) + if [ "${attempts}" -ge 100 ]; then + echo "Timed out waiting for ${description}" >&2 + exit 1 + fi + sleep 0.1 + done +} + +start_mysql() { + mkdir -p "${STATE_DIR}" "${MYSQL_DATA}" + if [ ! -d "${MYSQL_DATA}/mysql" ]; then + "${MYSQL_BASE}/bin/mysqld" --no-defaults --initialize-insecure \ + --basedir="${MYSQL_BASE}" --datadir="${MYSQL_DATA}" --log-error="${MYSQL_LOG}" + fi + if ! mysql_ready; then + "${MYSQL_BASE}/bin/mysqld" --no-defaults --daemonize \ + --basedir="${MYSQL_BASE}" --datadir="${MYSQL_DATA}" --port="${MYSQL_PORT}" \ + --socket="${MYSQL_SOCKET}" --pid-file="${MYSQL_PID}" --log-error="${MYSQL_LOG}" \ + --bind-address=127.0.0.1 --mysqlx=0 --skip-name-resolve \ + --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci \ + --default-time-zone=+08:00 + wait_until "isolated MySQL" mysql_ready + fi + "${MYSQL_BASE}/bin/mysql" --protocol=SOCKET --socket="${MYSQL_SOCKET}" -uroot \ + -e "CREATE DATABASE IF NOT EXISTS ${JAVA_DATABASE} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE DATABASE IF NOT EXISTS ${FASTAPI_DATABASE} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER IF NOT EXISTS '${TEST_USER}'@'127.0.0.1' IDENTIFIED BY '${TEST_PASSWORD}'; GRANT ALL PRIVILEGES ON ${JAVA_DATABASE}.* TO '${TEST_USER}'@'127.0.0.1'; GRANT ALL PRIVILEGES ON ${FASTAPI_DATABASE}.* TO '${TEST_USER}'@'127.0.0.1'; FLUSH PRIVILEGES;" +} + +start_redis() { + mkdir -p "${REDIS_DIR}" + if ! redis_ready; then + "${RUNTIME}/redis/bin/redis-server" --daemonize yes --bind 127.0.0.1 \ + --port "${REDIS_PORT}" --pidfile "${REDIS_PID}" --dir "${REDIS_DIR}" \ + --logfile "${REDIS_LOG}" --save "" --appendonly no --databases 16 + wait_until "isolated Redis" redis_ready + fi +} + +start() { + require_binaries + start_mysql + start_redis + echo "Isolated MySQL ${MYSQL_PORT} and Redis ${REDIS_PORT} are ready." +} + +stop() { + if redis_ready; then + "${RUNTIME}/redis/bin/redis-cli" -h 127.0.0.1 -p "${REDIS_PORT}" shutdown nosave >/dev/null + fi + if mysql_ready; then + "${MYSQL_BASE}/bin/mysqladmin" --protocol=SOCKET --socket="${MYSQL_SOCKET}" -uroot shutdown + fi + echo "Isolated services stopped." +} + +reset() { + start + "${MYSQL_BASE}/bin/mysql" --protocol=SOCKET --socket="${MYSQL_SOCKET}" -uroot \ + -e "DROP DATABASE IF EXISTS ${JAVA_DATABASE}; DROP DATABASE IF EXISTS ${FASTAPI_DATABASE}; CREATE DATABASE ${JAVA_DATABASE} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE DATABASE ${FASTAPI_DATABASE} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; GRANT ALL PRIVILEGES ON ${JAVA_DATABASE}.* TO '${TEST_USER}'@'127.0.0.1'; GRANT ALL PRIVILEGES ON ${FASTAPI_DATABASE}.* TO '${TEST_USER}'@'127.0.0.1';" + "${RUNTIME}/redis/bin/redis-cli" -h 127.0.0.1 -p "${REDIS_PORT}" flushall >/dev/null + echo "Only the isolated test schemas and isolated Redis instance were reset." +} + +migrate_one() { + database=$1 + LIQUIBASE_URL="jdbc:mysql://127.0.0.1:${MYSQL_PORT}/${database}?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowMultiQueries=true" \ + LIQUIBASE_USERNAME="${TEST_USER}" \ + LIQUIBASE_PASSWORD="${TEST_PASSWORD}" \ + MAVEN_BIN="${RUNTIME}/maven/bin/mvn" \ + MAVEN_LOCAL_REPOSITORY="${RUNTIME}/m2" \ + JAVA_RESOURCES_DIR="${REPOSITORY_ROOT}/main/manager-api/src/main/resources" \ + "${SCRIPT_DIR}/run-migrations.sh" +} + +migrate() { + start + migrate_one "${JAVA_DATABASE}" + migrate_one "${FASTAPI_DATABASE}" +} + +print_env() { + cat <&2; exit 2 ;; +esac diff --git a/main/manager-api-fastapi/scripts/render_compatibility_doc.py b/main/manager-api-fastapi/scripts/render_compatibility_doc.py new file mode 100644 index 00000000..dc57c967 --- /dev/null +++ b/main/manager-api-fastapi/scripts/render_compatibility_doc.py @@ -0,0 +1,640 @@ +#!/usr/bin/env python3 +"""Render the auditable Java/FastAPI compatibility matrix from checked-in inventories.""" + +from __future__ import annotations + +import argparse +import json +import re +from collections import Counter +from pathlib import Path +from typing import Any + +TARGET_ROOT = Path(__file__).resolve().parents[1] +REPOSITORY_ROOT = TARGET_ROOT.parents[1] +JAVA_ROOT = REPOSITORY_ROOT / "main" / "manager-api" +JAVA_SOURCE_ROOT = JAVA_ROOT / "src" / "main" / "java" +JAVA_RESOURCE_ROOT = JAVA_ROOT / "src" / "main" / "resources" +JAVA_MANIFEST = TARGET_ROOT / "compatibility" / "java-routes.json" +CONSUMER_MANIFEST = TARGET_ROOT / "compatibility" / "consumer-routes.json" +CONTRACT_RESULTS = TARGET_ROOT / "compatibility" / "contract-results.json" +ROUTE_SURFACE_RESULTS = TARGET_ROOT / "compatibility" / "route-surface-results.json" +AUTHENTICATED_ROUTE_RESULTS = TARGET_ROOT / "compatibility" / "authenticated-route-results.json" +PATH_PARAMETER = re.compile(r"\{[^/{}]+\}") + + +DIFFERENTIAL_CASES: dict[tuple[str, str], str] = { + ("GET", "/user/pub-config"): "1", + ("GET", "/user/info"): "9(七语言/过期 Token/Long)", + ("GET", "/admin/users"): "3(权限/序列化/非法分页)", + ("GET", "/agent/list"): "1", + ("GET", "/device/bind/{}"): "1", + ("GET", "/models/provider"): "1", + ("GET", "/correct-word/file/list"): "1", + ("GET", "/correct-word/file/download/{}"): "2(二进制/更新后下载)", + ("PUT", "/device/update/{}"): "3(上下界/UTF-16 长度)", + ("POST", "/models/provider"): "1(约束集合)", + ("POST", "/config/server-base"): "3(缺失/错误/正确 secret)", + ("GET", "/ota/"): "1(MIME/body)", + ("POST", "/ota/"): "4(必填/格式/凭证/密码学)", + ("POST", "/ota/activate"): "3", + ("POST", "/device/tools/list/{}"): "2(响应/外呼格式)", + ("POST", "/correct-word/file"): "2(响应/DB)", + ("PUT", "/correct-word/file/{}"): "2(响应/DB)", + ("DELETE", "/correct-word/file/{}"): "1(级联副作用)", + ("POST", "/otaMag/upload"): "2(上传/扩展名错误)", + ("POST", "/otaMag"): "2(响应/DB)", + ("GET", "/otaMag/download/{}"): "4(次数限制及二进制)", +} + +DOMAIN_TESTS = { + "AdminController": "sys", + "SysParamsController": "sys", + "SysDictDataController": "sys", + "SysDictTypeController": "sys", + "ServerSideManageController": "sys", + "LoginController": "security", + "ConfigController": "config", + "AgentController": "agent", + "AgentChatHistoryController": "agent", + "AgentMcpAccessPointController": "agent", + "AgentSnapshotController": "agent", + "AgentTemplateController": "agent", + "AgentVoicePrintController": "agent", + "CorrectWordController": "correctword", + "DeviceController": "device", + "KnowledgeBaseController": "knowledge", + "KnowledgeFilesController": "knowledge", + "ModelController": "model", + "ModelProviderController": "model", + "OTAController": "device", + "OTAMagController": "device", + "TimbreController": "timbre", + "VoiceCloneController": "voiceclone", + "VoiceResourceController": "voiceclone", +} + + +def _canonical(path: str) -> str: + return PATH_PARAMETER.sub("{}", path) + + +def _declaration(route: dict[str, Any]) -> str: + source_path = REPOSITORY_ROOT / route["source"] + lines = source_path.read_text(encoding="utf-8").splitlines() + excerpt = "\n".join(lines[int(route["line"]) - 1 : int(route["line"]) + 60]) + match = re.search(r"\bpublic\s+", excerpt) + if match is None: + raise ValueError(f"public declaration not found for {route['controller']}.{route['handler']}") + declaration = excerpt[match.start() :] + depth = 0 + saw_parenthesis = False + for offset, character in enumerate(declaration): + if character == "(": + depth += 1 + saw_parenthesis = True + elif character == ")": + depth -= 1 + elif character == "{" and saw_parenthesis and depth == 0: + return " ".join(declaration[:offset].split()) + raise ValueError(f"unterminated declaration for {route['controller']}.{route['handler']}") + + +def _parameter_text(declaration: str, handler: str) -> str: + marker = re.search(rf"\b{re.escape(handler)}\s*\(", declaration) + if marker is None: + return "" + start = marker.end() - 1 + depth = 0 + for offset in range(start, len(declaration)): + character = declaration[offset] + if character == "(": + depth += 1 + elif character == ")": + depth -= 1 + if depth == 0: + return declaration[start + 1 : offset] + return "" + + +def _split_parameters(value: str) -> list[str]: + result: list[str] = [] + start = 0 + round_depth = 0 + angle_depth = 0 + in_quote: str | None = None + escaped = False + for offset, character in enumerate(value): + if escaped: + escaped = False + continue + if character == "\\": + escaped = True + continue + if in_quote is not None: + if character == in_quote: + in_quote = None + continue + if character in {'"', "'"}: + in_quote = character + elif character == "(": + round_depth += 1 + elif character == ")": + round_depth -= 1 + elif character == "<": + angle_depth += 1 + elif character == ">": + angle_depth -= 1 + elif character == "," and round_depth == 0 and angle_depth == 0: + result.append(value[start:offset].strip()) + start = offset + 1 + tail = value[start:].strip() + if tail: + result.append(tail) + return result + + +def _without_annotations(value: str) -> str: + output: list[str] = [] + offset = 0 + while offset < len(value): + if value[offset] != "@": + output.append(value[offset]) + offset += 1 + continue + offset += 1 + while offset < len(value) and (value[offset].isalnum() or value[offset] in "._$"): + offset += 1 + while offset < len(value) and value[offset].isspace(): + offset += 1 + if offset < len(value) and value[offset] == "(": + depth = 1 + offset += 1 + in_quote: str | None = None + while offset < len(value) and depth: + character = value[offset] + if in_quote is not None: + if character == in_quote and value[offset - 1] != "\\": + in_quote = None + elif character in {'"', "'"}: + in_quote = character + elif character == "(": + depth += 1 + elif character == ")": + depth -= 1 + offset += 1 + while offset < len(value) and value[offset].isspace(): + offset += 1 + return " ".join("".join(output).split()) + + +def _type_and_name(parameter: str) -> tuple[str, str]: + cleaned = _without_annotations(parameter).removeprefix("final ").strip() + pieces = cleaned.rsplit(" ", 1) + if len(pieces) != 2: + return cleaned, cleaned + return pieces[0], pieces[1] + + +def _request_surface(route: dict[str, Any], declaration: str) -> str: + path_names = re.findall(r"\{([^/{}]+)\}", route["path"]) + headers: list[str] = [] + queries: list[str] = [] + bodies: list[str] = [] + multipart: list[str] = [] + for parameter in _split_parameters(_parameter_text(declaration, route["handler"])): + parameter_type, name = _type_and_name(parameter) + if "HttpServletResponse" in parameter_type or "HttpServletRequest" in parameter_type: + continue + if "@PathVariable" in parameter: + continue + if "@RequestHeader" in parameter: + quoted = re.search(r'@RequestHeader(?:\([^)]*)?["\']([^"\']+)["\']', parameter) + headers.append(quoted.group(1) if quoted else name) + elif "MultipartFile" in parameter_type: + multipart.append(name) + elif "@RequestBody" in parameter: + bodies.append(parameter_type) + elif "@RequestParam" in parameter or "@ParameterObject" in parameter: + queries.append(f"{name}:{parameter_type}" if parameter_type != name else name) + elif route["method"] == "GET" or route["controller"] == "ModelProviderController": + queries.append(f"{name}:{parameter_type}" if parameter_type != name else name) + parts: list[str] = [] + if path_names: + parts.append("Path:" + ",".join(path_names)) + if headers: + parts.append("Header:" + ",".join(headers)) + if queries: + parts.append("Query:" + ",".join(queries)) + if bodies: + parts.append("Body:" + ",".join(bodies)) + if multipart: + parts.append("Multipart:" + ",".join(multipart)) + return "; ".join(parts) if parts else "—" + + +def _response_type(route: dict[str, Any], declaration: str) -> str: + path = route["path"] + if path == "/user/captcha": + return "image/gif 二进制" + if path == "/ota/" and route["method"] == "GET": + return "裸 text/plain" + if path.startswith("/ota/"): + return "裸 application/json" + if path in { + "/agent/play/{uuid}", + "/agent/chat-history/download/{uuid}/current", + "/agent/chat-history/download/{uuid}/previous", + "/correct-word/file/download/{fileId}", + "/otaMag/download/{uuid}", + "/voiceClone/play/{uuid}", + }: + return "流式/二进制 + 原下载 headers" + match = re.search(rf"public\s+(.+?)\s+{re.escape(route['handler'])}\s*\(", declaration) + return_type = match.group(1) if match else "unknown" + if return_type.startswith("Result<"): + return "envelope " + return_type.removeprefix("Result") + return return_type + + +def _permission(route: dict[str, Any]) -> str | None: + source_path = REPOSITORY_ROOT / route["source"] + lines = source_path.read_text(encoding="utf-8").splitlines() + excerpt = "\n".join(lines[int(route["line"]) - 1 : int(route["line"]) + 60]) + declaration_offset = excerpt.find("public ") + decorators = excerpt if declaration_offset < 0 else excerpt[:declaration_offset] + match = re.search(r'@RequiresPermissions\(\s*"([^"]+)"', decorators) + return match.group(1) if match else None + + +def _side_effect(route: dict[str, Any]) -> str: + method = route["method"] + path = route["path"] + handler = route["handler"] + controller = route["controller"] + if path == "/user/captcha": + return "Redis-W(captcha TTL); GIF" + if path == "/user/login": + return "DB-R/W(token); Redis-R/DEL(captcha)" + if path == "/user/smsVerification": + return "Redis-R/W(TTL/频控); 外部-Aliyun SMS" + if path in {"/user/register", "/user/retrieve-password", "/user/change-password"}: + return "DB-W(user/token); Redis-R/DEL(SMS)" + if path in {"/user/info", "/user/pub-config"}: + return "DB-R; Redis-R/W(cache)" + if path.startswith("/admin/server/"): + return "DB/Redis-R(secret/WS); Redis-W(one-shot); 外部-WebSocket" if method == "POST" else "DB/Redis-R" + if path.startswith("/admin/params"): + if method == "GET": + return "DB-R" + if method == "PUT": + return "DB-W; Redis-W; 外部-配置端点探测(按 paramCode)" + return "DB-W; Redis-W/DEL" + if path.startswith("/admin/dict"): + return "DB-R; Redis-R/W(dict cache)" if method == "GET" else "DB-W; Redis-DEL(dict cache)" + if path == "/admin/device/all" or path == "/admin/users": + return "DB-R" + if path == "/admin/users/{id}" and method == "DELETE": + return "DB-W(用户/token/device/agent 级联)" + if path.startswith("/admin/users"): + return "DB-W(user/password/status/token)" + if path.startswith("/config/"): + return "DB-R; Redis-R/W(runtime/model/timbre cache)" + if path.startswith("/agent/mcp/tools"): + return "DB/Redis-R; 外部-WebSocket MCP" + if path.startswith("/agent/mcp/address"): + return "DB/Redis-R; AES token 生成" + if path.startswith("/agent/voice-print"): + return "DB-R" if method == "GET" else "DB-W; 外部-voiceprint HTTP" + if "/chat-summary/" in path or "/chat-title/" in path: + return "DB-R/W(chat); 外部-OpenAI-compatible LLM" + if path == "/agent/chat-history/report": + return "DB-W(chat/session); server-secret" + if path.startswith("/agent/chat-history/getDownloadUrl/"): + return "DB-R(chat/session); Redis-W(download token TTL)" + if "/chat-history/download/" in path or path.startswith("/agent/play/"): + return "DB/Redis-R(one-shot); 文件-R/流式" + if path.startswith("/agent/audio/"): + return "DB-R(audio); Redis-W(one-shot URL)" + if path.startswith("/agent/") or path == "/agent": + return "DB-R; Redis-R" if method == "GET" else "DB-W(含快照/映射/标签事务); Redis-DEL" + if path.startswith("/correct-word/"): + if "download" in path: + return "DB-R(content); 二进制" + return "DB-R" if method == "GET" else "DB-W(file/items/mapping 事务)" + if path.startswith("/datasets"): + if path == "/datasets/rag-models": + return "DB-R(model config)" + if method == "GET" and not path.endswith("/chunks"): + return "DB-R" + return "DB-R/W; 外部-RAGFlow HTTP(upload/dataset/document/chunk/retrieval)" + if path.startswith("/device/tools/") or (path == "/device/bind/{agentId}" and method == "POST"): + return "DB/Redis-R; 外部-MQTT gateway HTTP + daily auth" + if path in {"/device/address-book/call", "/device/address-book/lookup"}: + return "DB-R; 外部-MQTT gateway HTTP; server-secret" + if path.startswith("/device/"): + return "DB-R; Redis-R" if method == "GET" else "DB-W(device/bind/address-book); Redis-R/W" + if path == "/ota/": + if method == "GET": + return "—" + return "DB/Redis-R(设备/固件/配置); HMAC/Base64/时间戳凭证" + if path == "/ota/activate": + return "DB-R/W(device activation); Redis-R/W(TTL)" + if path.startswith("/otaMag/upload"): + return "文件-W(MD5/扩展名/大小)" + if path.startswith("/otaMag/download"): + return "Redis-R/W(一次性/次数); 文件-R/流式" + if path.startswith("/otaMag/getDownloadUrl"): + return "DB-R; Redis-W(download token TTL)" + if path.startswith("/otaMag"): + if method == "GET": + return "DB-R" + if method == "DELETE": + return "DB-W(OTA metadata); 文件-DEL" + return "DB-W(OTA metadata)" + if path.startswith("/models"): + return "DB-R; Redis-R/W(model cache)" if method == "GET" else "DB-W; Redis-DEL(model/config cache)" + if path.startswith("/ttsVoice"): + return "DB-R; Redis-R/W(timbre cache)" if method == "GET" else "DB-W; Redis-DEL(timbre/config cache)" + if path.startswith("/voiceClone"): + if path.startswith("/voiceClone/play"): + return "Redis-R/DEL(one-shot); 文件/外部音频-R" + if handler == "getAudioId": + return "DB-R; Redis-W(one-shot URL)" + if handler == "updateName": + return "DB-W(train record name)" + if method == "GET": + return "DB-R" + return "DB-R/W(train state); 文件-W; 外部-火山语音克隆 HTTP" + if path.startswith("/voiceResource"): + return "DB-R" if method == "GET" else "DB-W(voice resource)" + operation = "DB-R" if method == "GET" else "DB-W" + return f"{operation} ({controller}.{handler})" + + +def _verification(route: dict[str, Any]) -> str: + domain = DOMAIN_TESTS.get(route["controller"]) + domain_status = f"领域✓({domain},域级)" if domain else "领域—" + diff = DIFFERENTIAL_CASES.get((route["method"], _canonical(route["path"]))) + diff_status = f"差分✓{diff}" if diff else "差分—" + if route["path"] == "/otaMag/getDownloadUrl/{id}": + diff_status = "差分间接✓(供下载链路)" + return f"结构✓;请求面差分✓1;认证业务面差分✓1;{domain_status};{diff_status}" + + +def _escape(value: str) -> str: + return value.replace("|", "\\|").replace("\n", " ") + + +def _inventory_section(java_routes: list[dict[str, Any]]) -> str: + controller_counts = Counter(route["controller"] for route in java_routes) + mapper_files = sorted((JAVA_RESOURCE_ROOT / "mapper").rglob("*.xml")) + changelog_root = JAVA_RESOURCE_ROOT / "db" / "changelog" + sql_files = sorted(changelog_root.rglob("*.sql")) + master = changelog_root / "db.changelog-master.yaml" + changeset_refs = master.read_text(encoding="utf-8").count("changeSet:") + entity_files = list(JAVA_SOURCE_ROOT.rglob("entity/*.java")) + dto_files = [path for path in JAVA_SOURCE_ROOT.rglob("*.java") if "dto" in path.parts] + vo_files = list(JAVA_SOURCE_ROOT.rglob("vo/*.java")) + dao_files = list(JAVA_SOURCE_ROOT.rglob("dao/*.java")) + service_files = list(JAVA_SOURCE_ROOT.rglob("service/**/*.java")) + service_impl_files = list(JAVA_SOURCE_ROOT.rglob("service/impl/*.java")) + assert len(controller_counts) == 24 + assert len(mapper_files) == 20 + assert len(sql_files) == 101 + assert changeset_refs == 101 + assert len(entity_files) == 29 + assert len(dto_files) == 58 + assert len(vo_files) == 14 + assert len(dao_files) == 29 + mapper_names = "、".join(f"`{path.relative_to(JAVA_RESOURCE_ROOT).as_posix()}`" for path in mapper_files) + controller_names = "、".join( + f"`{controller}`({controller_counts[controller]})" for controller in sorted(controller_counts) + ) + return f"""## Java 基线静态盘点 + +- Controller:24 个、154 条映射。按 Controller 的路由数为:{controller_names}。 +- 数据分层:`entity/` 29 个 Java 文件(28 个 `*Entity.java` 加 `BaseEntity`)、`dto/` 58 个、 + `vo/` 14 个、`dao/` 29 个、`service/` 树 {len(service_files)} 个文件(其中 + `service/impl/` {len(service_impl_files)} 个)。FastAPI 对应落在 `schemas/`、`repositories/`、 + `services/`、`routers/`、`integrations/` 与 `jobs/`,没有把跨表事务放进路由。 +- MyBatis XML:20 个,分别是 {mapper_names}。 +- Liquibase:`db.changelog-master.yaml` 含 {changeset_refs} 个 `changeSet` 引用,目录中恰有 + {len(sql_files)} 个 SQL;Python 部署继续执行这 101 个原始 SQL,不改写历史。 +- 定时工作:`DocumentStatusSyncTask` 每次完成后延迟 30 秒,扫描 RAGFlow RUNNING 文档并 + 回写 SUCCESS/FAIL/CANCEL 与统计;当前 Java 源码另有 `AgentSnapshotRedactionRunner`,启动时 + 执行一次并在滚动部署期每 15 秒补偿脱敏旧快照。FastAPI 将工作移到独立 jobs 进程,并以 + Redis 分布式锁/watchdog 防止多 worker 重复执行。 +- 外部集成:RAGFlow dataset/document/chunk/retrieval/upload;阿里云短信;火山语音克隆训练与 + 音频;声纹 HTTP;OpenAI-compatible LLM 摘要/标题;MQTT gateway HTTP;MCP/管理动作 + WebSocket;OTA/WS/MQTT 的 HMAC、Base64、时间戳与下载文件存储。自动测试只访问可重复 mock, + 未使用真实付费凭证。 +""" + + +def _require_complete_route_report( + report: dict[str, Any], + routes: list[dict[str, Any]], + *, + request_profile: str, + side_effect_policy: str, +) -> None: + expected_summary = {"total": 154, "passed": 154, "failed": 0, "skipped": 0} + expected_names = [f"{route['method']} {route['path']}" for route in routes] + assert report["summary"] == expected_summary + assert report["coverage"] == { + "java_routes": 154, + "request_profile": request_profile, + "side_effect_policy": side_effect_policy, + } + assert [result["name"] for result in report["results"]] == expected_names + assert all(result["passed"] is True and result["difference"] is None for result in report["results"]) + + +def render() -> str: + java_manifest = json.loads(JAVA_MANIFEST.read_text(encoding="utf-8")) + consumer_manifest = json.loads(CONSUMER_MANIFEST.read_text(encoding="utf-8")) + contract = json.loads(CONTRACT_RESULTS.read_text(encoding="utf-8")) + route_surface = json.loads(ROUTE_SURFACE_RESULTS.read_text(encoding="utf-8")) + authenticated_route = json.loads(AUTHENTICATED_ROUTE_RESULTS.read_text(encoding="utf-8")) + routes: list[dict[str, Any]] = java_manifest["routes"] + assert len(routes) == 154 + assert contract["summary"] == {"total": 49, "passed": 49, "failed": 0, "skipped": 0} + _require_complete_route_report( + route_surface, + routes, + request_profile="missing-auth-or-safe-invalid-input", + side_effect_policy="no successful write request is issued", + ) + _require_complete_route_report( + authenticated_route, + routes, + request_profile="authenticated-safe-business-or-validation", + side_effect_policy="no intentional successful writes", + ) + assert len(DIFFERENTIAL_CASES) == 21 + + lines = [ + "# manager-api FastAPI 兼容性矩阵", + "", + "> 生成依据:`main/manager-api-fastapi/compatibility/java-routes.json`、", + "> `main/manager-api-fastapi/compatibility/consumer-routes.json`、`route-surface-results.json`、", + "> `authenticated-route-results.json`、`contract-results.json` 和当前 Java 源码。接口路径均省略", + "> 共同前缀 `/xiaozhi`。", + "", + "## 结论与状态口径", + "", + "Java 基线共有 **154** 条 Spring MVC 路由;FastAPI 已注册 **154/154(100%)**,并由", + "`tests/test_java_route_manifest.py` 对源码清单 freshness、数量和 method/path 注册闭合进行检查。", + "此外实现 3 条仅由仓库消费者使用、Java Controller 中不存在的兼容路由,因此这 3 条不计入", + "154 条 Java 覆盖率。三端 188 个调用点均能解析到 FastAPI 路由。", + "", + "矩阵状态必须按下列含义阅读:", + "", + "- `结构✓`:method/path 已注册且清单闭合;它不等于业务行为逐接口实测。", + "- `请求面差分✓1`:本行已向隔离 Java/FastAPI 各发送一次缺少鉴权或安全非法输入,精确比较", + " HTTP status、body 与 Content-Type;最终为 **154/154 通过、0 失败、0 跳过**,且不发送成功写请求。", + "- `认证业务面差分✓1`:本行已使用有效 DB Token、server-secret 或匿名身份,再向隔离", + " Java/FastAPI 各发送一次安全业务/校验请求,精确比较 HTTP status、body 与 Content-Type;", + " 最终为 **154/154 通过、0 失败、0 跳过**,且不主动发送成功写请求。该状态不等于每条路由的", + " 完整成功生命周期均已差分,完整副作用证据仍以 `差分✓N` 为准。", + "- `领域✓(x,域级)`:该领域有 service/repository/协议自动测试,但不保证本行每条成功与错误路径", + " 都被直接请求。`领域—` 表示除结构测试外没有可归属的域级直接测试证据。", + "- `差分✓N`:本行除安全请求面外,还参与了成功、主要错误、协议或数据库副作用的深度对照;", + " 括号说明覆盖面。深度结果为 **49/49 checks 通过、0 失败、0 跳过**,覆盖 **21/154** 条路由。", + " `差分间接✓` 表示 J125 作为下载链路的 URL 生成步骤被间接覆盖;`差分—` 表示没有深度对照,", + " 不能把 154/154 请求面差分误读成 154 条全部成功路径与副作用都已逐接口对照。", + "- 所有 `Result` 均表示 `{code,msg,data}` envelope;原 Java 为 HTTP 200 的认证、权限、业务和", + " 参数错误由全局兼容层维持 HTTP 200。二进制/OTA 裸响应在“响应类型”列单独标明。", + "", + "## 三端消费者闭合", + "", + "| 消费者 | 调用点 | 唯一结构路由 | 方法分布 |", + "|---|---:|---:|---|", + ] + for consumer in ("manager-web", "manager-mobile", "xiaozhi-server"): + item = consumer_manifest["consumers"][consumer] + methods = "、".join(f"{method} {count}" for method, count in item["methods"].items()) + lines.append(f"| `{consumer}` | {item['callSites']} | {item['uniqueRoutes']} | {methods} |") + lines.extend( + [ + f"| **合计** | **{consumer_manifest['count']}** | **{consumer_manifest['uniqueRoutes']}** | — |", + "", + "### 3 条消费者孤儿兼容路由", + "", + "| Method/path | 来源 | FastAPI 语义 | 鉴权 | 状态 |", + "|---|---|---|---|---|", + ( + "| `GET /api/ping` | manager-mobile 环境设置探活 | " + "`{code:0,msg:\"success\",data:\"pong\"}` | 匿名 | 实现✓;consumer resolve✓ |" + ), + ( + "| `PUT /user/configDevice/{device_id}` | manager-web 遗留设备配置调用 | " + "按现有设备更新契约处理 body | DB Token | 实现✓;consumer resolve✓ |" + ), + ( + "| `GET /device/address-book/lookup` | xiaozhi-server 管理客户端 | " + "`callerMac/nickname/answer` 地址簿查询/呼叫兼容别名 | server-secret | " + "实现✓;consumer resolve✓;device 域测试✓ |" + ), + "", + "`GET /admin/dict/data/type/FIRMWARE_TYPE` 是动态 Java 路由", + "`GET /admin/dict/data/type/{dictType}` 的一个字面调用,不是第四条孤儿路由。", + "", + _inventory_section(routes).rstrip(), + "", + "## 154 条 Java→FastAPI 逐接口矩阵", + "", + "副作用缩写:`DB-R/W`=数据库读/写,`Redis-R/W/DEL`=缓存读/写/失效,`文件-R/W`=文件", + "读取/写入;外部调用均在 service/integration 层。权限为空时表示只需对应鉴权身份。", + "", + ( + "| # | Method/path | Java Controller.handler | 请求面 | 响应类型 | 鉴权 / 权限 | " + "DB/Redis/文件/外部副作用 | 实现与测试状态 |" + ), + "|---:|---|---|---|---|---|---|---|", + ] + ) + for index, route in enumerate(routes, start=1): + declaration = _declaration(route) + permission = _permission(route) or "—" + auth = { + "anonymous": "匿名", + "database-token": "DB Token", + "server-secret": "server-secret", + }[route["auth"]] + values = [ + f"J{index:03d}", + f"`{route['method']} {route['path']}`", + f"`{route['controller']}.{route['handler']}`", + _request_surface(route, declaration), + _response_type(route, declaration), + f"{auth} / `{permission}`" if permission != "—" else f"{auth} / —", + _side_effect(route), + _verification(route), + ] + lines.append("| " + " | ".join(_escape(value) for value in values) + " |") + + lines.extend( + [ + "", + "## 已观测差异与未覆盖面", + "", + "- 154 条安全请求面差分最终全部一致。首轮曾发现 5 个空 Body 映射差异;修复 FastAPI 对", + " Spring `HttpMessageNotReadableException` 的 code-500 语义后,重新从零执行才得到 154/154。", + "- 154 条认证业务面差分最终全部一致;该轮使用有效鉴权与安全业务/校验输入,在不主动成功", + " 写入的前提下逐路由对照。证据是 `authenticated-route-results.json`,渲染器会在结果不是", + " 154/154、存在失败或跳过时硬失败。", + "- 2026-07-20 的隔离差分报告未在 49 个 checks 中观测到响应/所选 headers/数据库副作用", + " 不一致;证据是 `main/manager-api-fastapi/compatibility/contract-results.json`,不是人工推断。", + "- Hibernate Validator 的 `ConstraintViolation Set` 首条消息无稳定顺序;模型 provider 必填", + " 用例比较“消息属于 Java 声明约束集合”与相同错误码,而不伪造一个固定顺序。", + "- OTA 时间戳/token 是动态值,差分先比较归一化结构,再分别校验两端 HMAC/Base64 密码学", + " 有效性;这属于有意的测试归一化,不是声称字节恒等。", + "- 深度差分未直接命中的 133 条中,J125 是下载链路间接覆盖,另 132 条标为 `差分—`;", + " 它们有请求面、认证业务面与所属领域测试,但尚无逐路由成功+主要错误+副作用深度对照,不能据此宣称", + " 每一种业务状态均已逐接口行为等价。", + "- FastAPI 额外提供上述 3 条消费者兼容路由与 live/ready 健康检查;它们没有 Java", + " Controller 基线,属于明确、可回退的加法差异。", + "- Java 把定时任务放在 Spring 进程;FastAPI 使用独立 jobs 进程和 Redis 分布式锁。这是", + " 部署拓扑差异,业务状态和幂等目标保持一致。", + "- RAGFlow、阿里云短信、火山语音克隆、真实声纹、真实 LLM、真实 MQTT/MCP/WS 均未用", + " 生产凭证联调;自动化只证明 mock 请求格式、超时/错误映射/重试中的已覆盖场景。", + "", + "## 可复现检查", + "", + "```bash", + "cd main/manager-api-fastapi", + ".venv/bin/python scripts/extract_java_routes.py --output compatibility/java-routes.json", + ".venv/bin/python scripts/extract_consumer_routes.py > /tmp/consumer-routes.json", + ( + ".venv/bin/pytest -q tests/test_java_route_manifest.py " + "tests/test_consumer_route_manifest.py tests/test_compatibility_document.py" + ), + "```", + "", + "逐接口差分的启动、隔离库、mock 与执行命令见 `docs/manager-api-fastapi-test-report.md`;", + "本文件只陈述已落盘的结果,不把缺少真实密钥的外部联调列为通过。", + ] + ) + rendered = "\n".join(lines) + "\n" + if len(re.findall(r"^\| J\d{3} \|", rendered, flags=re.MULTILINE)) != 154: + raise AssertionError("rendered route matrix is not closed") + return rendered + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--output", type=Path) + args = parser.parse_args() + rendered = render() + if args.output is None: + print(rendered, end="") + else: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(rendered, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/main/manager-api-fastapi/scripts/run-isolated-contract-tests.sh b/main/manager-api-fastapi/scripts/run-isolated-contract-tests.sh new file mode 100755 index 00000000..73c52566 --- /dev/null +++ b/main/manager-api-fastapi/scripts/run-isolated-contract-tests.sh @@ -0,0 +1,220 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +TARGET_DIR=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd) +REPOSITORY_ROOT=$(CDPATH= cd -- "${TARGET_DIR}/../.." && pwd) +RUNTIME="${REPOSITORY_ROOT}/.runtime" +JAVA_DIR="${REPOSITORY_ROOT}/main/manager-api" +STATE_DIR="${TARGET_DIR}/.test-runtime/contract" +JAVA_PORT="${CONTRACT_JAVA_PORT:-18082}" +FASTAPI_PORT="${CONTRACT_FASTAPI_PORT:-18083}" +MOCK_PORT="${CONTRACT_MOCK_PORT:-18084}" +JAVA_PID="" +FASTAPI_PID="" +MOCK_PID="" + +mkdir -p "${STATE_DIR}" + +stop_process() { + pid=$1 + if [ -n "${pid}" ] && kill -0 "${pid}" 2>/dev/null; then + children=$(pgrep -P "${pid}" 2>/dev/null || true) + if [ -n "${children}" ]; then + kill ${children} 2>/dev/null || true + fi + kill "${pid}" 2>/dev/null || true + wait "${pid}" 2>/dev/null || true + fi +} + +cleanup() { + stop_process "${FASTAPI_PID}" + stop_process "${JAVA_PID}" + stop_process "${MOCK_PID}" +} +trap cleanup EXIT INT TERM + +wait_for_url() { + name=$1 + url=$2 + attempts=0 + until curl --fail --silent --show-error "${url}" >/dev/null 2>&1; do + attempts=$((attempts + 1)) + if [ "${attempts}" -ge 240 ]; then + echo "Timed out waiting for ${name}; inspect ${STATE_DIR}." >&2 + return 1 + fi + sleep 0.25 + done +} + +assert_clean_runtime_logs() { + for log in "${STATE_DIR}/fastapi.log" "${STATE_DIR}/external-mock.log"; do + if rg -n -i 'UnsupportedFieldAttributeWarning|Traceback|\bERROR\b|\bWARNING\b' "${log}"; then + echo "Unexpected warning or error in ${log}." >&2 + return 1 + fi + done + + # Logback's own bootstrap diagnostics contain tokens such as ERROR_FILE. + # Runtime application records start with the configured full ISO date. + java_errors=$(rg -n '^[0-9]{4}-[0-9]{2}-[0-9]{2} .*ERROR.* - ' "${STATE_DIR}/java.log" || true) + if [ -n "${java_errors}" ]; then + java_error_count=$(printf '%s\n' "${java_errors}" | wc -l | tr -d ' ') + missing_body_errors=$(printf '%s\n' "${java_errors}" | rg -F 'Required request body is missing:' | wc -l | tr -d ' ') + missing_device_errors=$(printf '%s\n' "${java_errors}" | rg -F "Required request header 'Device-Id'" | wc -l | tr -d ' ') + object_array_errors=$(printf '%s\n' "${java_errors}" | rg -F 'JSON parse error: Cannot deserialize value of type' | wc -l | tr -d ' ') + missing_query_errors=$(printf '%s\n' "${java_errors}" | rg -e "Required request parameter '(ids|modelType|id)' for method parameter type String is not present" | wc -l | tr -d ' ') + multipart_errors=$(printf '%s\n' "${java_errors}" | rg -F 'Current request is not a multipart request' | wc -l | tr -d ' ') + caller_mac_errors=$(printf '%s\n' "${java_errors}" | rg -F 'Cannot invoke "String.toLowerCase()" because "callerMac" is null' | wc -l | tr -d ' ') + null_message_errors=$(printf '%s\n' "${java_errors}" | rg -e ' - null$' | wc -l | tr -d ' ') + blank_message_errors=$(printf '%s\n' "${java_errors}" | rg -e ' - $' | wc -l | tr -d ' ') + if [ "${java_error_count}" -ne 36 ] \ + || [ "${missing_body_errors}" -ne 13 ] \ + || [ "${missing_device_errors}" -ne 5 ] \ + || [ "${object_array_errors}" -ne 8 ] \ + || [ "${missing_query_errors}" -ne 4 ] \ + || [ "${multipart_errors}" -ne 3 ] \ + || [ "${caller_mac_errors}" -ne 1 ] \ + || [ "${null_message_errors}" -ne 1 ] \ + || [ "${blank_message_errors}" -ne 1 ]; then + printf '%s\n' "${java_errors}" >&2 + echo "The two 154-route safe surfaces did not produce the exact expected Java baseline error profile." >&2 + return 1 + fi + unexpected_java_errors=$( + printf '%s\n' "${java_errors}" | + rg -v -e "Required request header 'Device-Id' for method parameter type String is not present" \ + -e 'Required request body is missing:' \ + -e 'JSON parse error: Cannot deserialize value of type .* from Object value \(token .*START_OBJECT.*\)' \ + -e "Required request parameter '(ids|modelType|id)' for method parameter type String is not present" \ + -e 'Current request is not a multipart request' \ + -e 'Cannot invoke "String.toLowerCase\(\)" because "callerMac" is null' \ + -e ' - null$' \ + -e ' - $' || true + ) + if [ -n "${unexpected_java_errors}" ]; then + printf '%s\n' "${unexpected_java_errors}" >&2 + echo "Unexpected Java baseline error; only the counted safe surface-validation paths are allowed." >&2 + return 1 + fi + fi +} + +start_java() { + ( + cd "${JAVA_DIR}" + JAVA_HOME="${RUNTIME}/jdk" \ + PATH="${RUNTIME}/jdk/bin:${RUNTIME}/maven/bin:${PATH}" \ + exec "${RUNTIME}/maven/bin/mvn" \ + -Dmaven.repo.local="${RUNTIME}/m2" \ + -DskipTests spring-boot:run \ + -Dspring-boot.run.arguments="--server.port=${JAVA_PORT} \ +--spring.datasource.druid.url=jdbc:mysql://127.0.0.1:${TEST_MYSQL_PORT}/manager_java_test?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true&allowMultiQueries=true \ +--spring.datasource.druid.username=xiaozhi_test \ +--spring.datasource.druid.password=isolated-test-only \ +--spring.data.redis.host=127.0.0.1 \ +--spring.data.redis.port=${TEST_REDIS_PORT} \ +--spring.data.redis.database=1 \ +--spring.data.redis.password=" + ) >"${STATE_DIR}/java.log" 2>&1 & + JAVA_PID=$! + wait_for_url "Java baseline" "http://127.0.0.1:${JAVA_PORT}/xiaozhi/ota/" +} + +start_mock() { + ( + cd "${TARGET_DIR}" + exec .venv/bin/uvicorn tests.compatibility.external_mock:app \ + --host 127.0.0.1 --port "${MOCK_PORT}" --log-level warning + ) >"${STATE_DIR}/external-mock.log" 2>&1 & + MOCK_PID=$! + wait_for_url "external-service mock" "http://127.0.0.1:${MOCK_PORT}/health" +} + +start_fastapi() { + ( + cd "${TARGET_DIR}" + APP_ENVIRONMENT=test \ + APP_DATABASE_URL="${TEST_FASTAPI_DATABASE_URL}" \ + APP_REDIS_URL="${TEST_FASTAPI_REDIS_URL}" \ + APP_SERVER_SECRET_OVERRIDE=contract-server-secret \ + APP_UPLOAD_DIR="${STATE_DIR}/uploads" \ + APP_LOG_LEVEL=WARNING \ + exec .venv/bin/uvicorn app.main:app \ + --host 127.0.0.1 --port "${FASTAPI_PORT}" --log-level warning + ) >"${STATE_DIR}/fastapi.log" 2>&1 & + FASTAPI_PID=$! + wait_for_url "FastAPI target" "http://127.0.0.1:${FASTAPI_PORT}/xiaozhi/health/ready" +} + +cd "${TARGET_DIR}" + +./scripts/isolated-env.sh reset +./scripts/isolated-env.sh migrate +eval "$(./scripts/isolated-env.sh env)" + +start_mock + +# The retained Java startup creates the SM2 key pair on an empty schema. +start_java +.venv/bin/python -m tests.compatibility.seed_contract_data \ + --mysql-port "${TEST_MYSQL_PORT}" --mock-port "${MOCK_PORT}" + +# Reload Java after the deterministic fixture replaced server params. FLUSHALL +# is safe here because this is the dedicated Redis on TEST_REDIS_PORT and the +# FastAPI target has not started yet. +stop_process "${JAVA_PID}" +JAVA_PID="" +"${RUNTIME}/redis/bin/redis-cli" -h 127.0.0.1 -p "${TEST_REDIS_PORT}" FLUSHALL >/dev/null +start_java +start_fastapi + +# Restore fixed dates after Java startup and allow no stale async baseline write +# to leak into the first read-only comparison. +.venv/bin/python -m tests.compatibility.seed_contract_data \ + --mysql-port "${TEST_MYSQL_PORT}" --mock-port "${MOCK_PORT}" +sleep 1 +.venv/bin/python -m tests.compatibility.seed_contract_data \ + --mysql-port "${TEST_MYSQL_PORT}" --mock-port "${MOCK_PORT}" + +TEST_FASTAPI_DATABASE_URL="${TEST_FASTAPI_DATABASE_URL}" \ +TEST_FASTAPI_REDIS_URL="${TEST_FASTAPI_REDIS_URL}" \ +APP_DATABASE_URL="${TEST_FASTAPI_DATABASE_URL}" \ +APP_REDIS_URL="${TEST_FASTAPI_REDIS_URL}" \ +APP_ENVIRONMENT=test \ + .venv/bin/pytest -q tests/integration/test_isolated_runtime.py + +.venv/bin/python -m tests.compatibility.route_surface_runner \ + --java-base "http://127.0.0.1:${JAVA_PORT}/xiaozhi" \ + --fastapi-base "http://127.0.0.1:${FASTAPI_PORT}/xiaozhi" \ + --mock-base "http://127.0.0.1:${MOCK_PORT}" \ + --mysql-port "${TEST_MYSQL_PORT}" \ + --output compatibility/route-surface-results.json + +.venv/bin/python -m tests.compatibility.authenticated_route_runner \ + --java-base "http://127.0.0.1:${JAVA_PORT}/xiaozhi" \ + --fastapi-base "http://127.0.0.1:${FASTAPI_PORT}/xiaozhi" \ + --mock-base "http://127.0.0.1:${MOCK_PORT}" \ + --mysql-port "${TEST_MYSQL_PORT}" \ + --output compatibility/authenticated-route-results.json + +.venv/bin/python -m tests.compatibility.differential_runner \ + --java-base "http://127.0.0.1:${JAVA_PORT}/xiaozhi" \ + --fastapi-base "http://127.0.0.1:${FASTAPI_PORT}/xiaozhi" \ + --mock-base "http://127.0.0.1:${MOCK_PORT}" \ + --mysql-port "${TEST_MYSQL_PORT}" \ + --output compatibility/contract-results.json + +.venv/bin/python -m tests.compatibility.seed_contract_data \ + --mysql-port "${TEST_MYSQL_PORT}" --mock-port "${MOCK_PORT}" +.venv/bin/python -m tests.compatibility.performance_runner \ + --java-base "http://127.0.0.1:${JAVA_PORT}/xiaozhi" \ + --fastapi-base "http://127.0.0.1:${FASTAPI_PORT}/xiaozhi" \ + --requests 60 --concurrency 6 --warmup 10 \ + --output compatibility/performance-results.json + +assert_clean_runtime_logs + +echo "Isolated integration, 154-route unauthenticated and authenticated surfaces, deep differential, and performance tests passed." diff --git a/main/manager-api-fastapi/scripts/run-migrations.sh b/main/manager-api-fastapi/scripts/run-migrations.sh new file mode 100755 index 00000000..11428aa0 --- /dev/null +++ b/main/manager-api-fastapi/scripts/run-migrations.sh @@ -0,0 +1,51 @@ +#!/bin/sh +set -eu + +: "${LIQUIBASE_URL:?Set LIQUIBASE_URL to the isolated or deployment JDBC URL}" +: "${LIQUIBASE_USERNAME:?Set LIQUIBASE_USERNAME}" +: "${LIQUIBASE_PASSWORD:?Set LIQUIBASE_PASSWORD}" + +JDBC_URL=${LIQUIBASE_URL} +JDBC_USERNAME=${LIQUIBASE_USERNAME} +JDBC_PASSWORD=${LIQUIBASE_PASSWORD} +unset LIQUIBASE_URL LIQUIBASE_USERNAME LIQUIBASE_PASSWORD +export MIGRATION_JDBC_URL=${JDBC_URL} +export MIGRATION_USERNAME=${JDBC_USERNAME} +export MIGRATION_PASSWORD=${JDBC_PASSWORD} + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +PROJECT_DIR=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd) +POM="${MIGRATION_POM:-${PROJECT_DIR}/migration-pom.xml}" +JAVA_RESOURCES="${JAVA_RESOURCES_DIR:-${PROJECT_DIR}/../manager-api/src/main/resources}" +if [ -n "${MIGRATION_RUNNER_JAR:-}" ]; then + exec java -jar "${MIGRATION_RUNNER_JAR}" +fi + +RUNTIME_ROOT="${PROJECT_DIR}/../../.runtime" +if [ -n "${MAVEN_BIN:-}" ]; then + MAVEN="${MAVEN_BIN}" +elif [ -x "${RUNTIME_ROOT}/maven/bin/mvn" ]; then + MAVEN="${RUNTIME_ROOT}/maven/bin/mvn" +else + MAVEN="mvn" +fi + +MAVEN_REPOSITORY_ARGS="" +if [ -n "${MAVEN_LOCAL_REPOSITORY:-}" ]; then + MAVEN_REPOSITORY_ARGS="-Dmaven.repo.local=${MAVEN_LOCAL_REPOSITORY}" +elif [ -x "${RUNTIME_ROOT}/maven/bin/mvn" ]; then + MAVEN_REPOSITORY_ARGS="-Dmaven.repo.local=${RUNTIME_ROOT}/m2" +fi + +"${MAVEN}" -B -f "${POM}" ${MAVEN_REPOSITORY_ARGS} \ + -Djava.resources.dir="${JAVA_RESOURCES}" package +if [ -n "${JAVA_BIN:-}" ]; then + JAVA="${JAVA_BIN}" +elif [ -n "${JAVA_HOME:-}" ] && [ -x "${JAVA_HOME}/bin/java" ]; then + JAVA="${JAVA_HOME}/bin/java" +elif [ -x "${RUNTIME_ROOT}/jdk/bin/java" ]; then + JAVA="${RUNTIME_ROOT}/jdk/bin/java" +else + JAVA="java" +fi +exec "${JAVA}" -jar "${PROJECT_DIR}/target/manager-api-liquibase-runner-1.0.0-all.jar" diff --git a/main/manager-api-fastapi/scripts/start-api.sh b/main/manager-api-fastapi/scripts/start-api.sh new file mode 100755 index 00000000..20d4f927 --- /dev/null +++ b/main/manager-api-fastapi/scripts/start-api.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +PROJECT_DIR=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd) +PYTHON="${PYTHON_BIN:-${PROJECT_DIR}/.venv/bin/python}" + +if [ ! -x "${PYTHON}" ]; then + echo "Python environment is missing; run 'uv sync --locked' in ${PROJECT_DIR}" >&2 + exit 1 +fi + +cd "${PROJECT_DIR}" +exec "${PYTHON}" -m uvicorn app.main:app \ + --host "${APP_HOST:-0.0.0.0}" \ + --port "${APP_PORT:-8002}" \ + --workers "${APP_WORKERS:-1}" \ + --timeout-graceful-shutdown "${APP_GRACEFUL_SHUTDOWN_SECONDS:-30}" \ + --proxy-headers \ + --forwarded-allow-ips "${APP_FORWARDED_ALLOW_IPS:-127.0.0.1}" diff --git a/main/manager-api-fastapi/scripts/start-jobs.sh b/main/manager-api-fastapi/scripts/start-jobs.sh new file mode 100755 index 00000000..13970155 --- /dev/null +++ b/main/manager-api-fastapi/scripts/start-jobs.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +PROJECT_DIR=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd) +PYTHON="${PYTHON_BIN:-${PROJECT_DIR}/.venv/bin/python}" + +if [ ! -x "${PYTHON}" ]; then + echo "Python environment is missing; run 'uv sync --locked' in ${PROJECT_DIR}" >&2 + exit 1 +fi + +cd "${PROJECT_DIR}" +exec "${PYTHON}" -m app.jobs.worker diff --git a/main/manager-api-fastapi/tests/__init__.py b/main/manager-api-fastapi/tests/__init__.py new file mode 100644 index 00000000..dd495b76 --- /dev/null +++ b/main/manager-api-fastapi/tests/__init__.py @@ -0,0 +1 @@ +"""Manager API FastAPI automated tests.""" diff --git a/main/manager-api-fastapi/tests/compatibility/__init__.py b/main/manager-api-fastapi/tests/compatibility/__init__.py new file mode 100644 index 00000000..793cb501 --- /dev/null +++ b/main/manager-api-fastapi/tests/compatibility/__init__.py @@ -0,0 +1 @@ +"""Executable Java/FastAPI compatibility-test support.""" diff --git a/main/manager-api-fastapi/tests/compatibility/authenticated_route_runner.py b/main/manager-api-fastapi/tests/compatibility/authenticated_route_runner.py new file mode 100644 index 00000000..90ca9aa8 --- /dev/null +++ b/main/manager-api-fastapi/tests/compatibility/authenticated_route_runner.py @@ -0,0 +1,341 @@ +from __future__ import annotations + +import argparse +import json +import re +import string +import sys +import uuid +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any, cast + +from tests.compatibility.differential_runner import ContractRunner, _build_report +from tests.compatibility.seed_contract_data import ADMIN_TOKEN, SERVER_SECRET + +TARGET_ROOT = Path(__file__).resolve().parents[2] +JAVA_MANIFEST = TARGET_ROOT / "compatibility" / "java-routes.json" +PATH_PARAMETER = re.compile(r"\{([^/{}]+)\}") +MISSING_LONG_ID = "9223372036854775806" +PASSWORD_CHARACTERS = frozenset(string.ascii_letters + string.digits + "!@#$%^&*()") + +# These create endpoints accept an object whose fields are largely optional. An +# empty JSON object could therefore create an isolated row instead of exercising +# validation. Omitting the body is deterministic on both implementations and +# keeps this runner read-only with respect to intentional business writes. +NO_BODY_ROUTES = { + ("POST", "/admin/dict/data/save"), + ("POST", "/admin/dict/type/save"), + ("POST", "/agent/template"), + ("POST", "/agent/voice-print"), + ("POST", "/datasets"), + ("POST", "/device/manual-add"), + ("POST", "/device/register"), +} + +MULTIPART_ROUTES = { + ("POST", "/datasets/{dataset_id}/documents"), + ("POST", "/otaMag/upload"), + ("POST", "/otaMag/uploadAssetsBin"), + ("POST", "/voiceClone/upload"), +} + +NUMERIC_ID_CONTROLLERS = { + "AdminController", + "SysDictDataController", + "SysDictTypeController", + "SysParamsController", +} + +# Hibernate Validator exposes constraint violations as a Set, so an empty body +# can legitimately select a different first message on two otherwise identical +# processes. Each payload below leaves exactly one declared constraint invalid; +# the resulting full response remains suitable for an exact comparison and the +# request cannot reach a successful write. +SINGLE_CONSTRAINT_PAYLOADS: dict[tuple[str, str], dict[str, Any]] = { + ("POST", "/admin/params"): { + "paramCode": "contract.missing.type", + "paramValue": "contract-value", + }, + ("PUT", "/admin/params"): { + "id": MISSING_LONG_ID, + "paramCode": "contract.missing.value", + "valueType": "string", + }, + ("POST", "/admin/server/emit-action"): { + "targetWs": "contract-missing-ws", + }, + ("POST", "/agent/chat-history/report"): { + "macAddress": "AA:BB:CC:DD:EE:FC", + "sessionId": "contract-missing-content", + "chatType": 1, + }, + ("POST", "/config/agent-models"): { + "macAddress": "AA:BB:CC:DD:EE:FB", + "clientId": "contract-missing-selected-module", + }, + ("POST", "/correct-word/file"): { + "fileName": "contract-invalid.txt", + }, + ("PUT", "/correct-word/file/{fileId}"): { + "fileName": "contract-invalid.txt", + }, + ("PUT", "/device/address-book/alias"): { + "targetMac": "AA:BB:CC:DD:EE:FD", + }, + ("PUT", "/device/address-book/permission"): { + "macAddress": "AA:BB:CC:DD:EE:FE", + }, + ("POST", "/models/provider"): { + "providerCode": "contract-provider-code", + "name": "Contract Provider", + "fields": "[]", + "sort": 0, + }, + ("POST", "/ttsVoice"): { + "languages": "zh", + "name": "Contract Voice", + "ttsModelId": "contract-missing-model", + }, + ("PUT", "/ttsVoice/{id}"): { + "languages": "zh", + "ttsModelId": "contract-missing-model", + "ttsVoice": "contract-voice", + }, +} + +RANDOM_PASSWORD_ROUTE = ("PUT", "/admin/users/{id}") +OTA_DOWNLOAD_UUID_ROUTE = ("GET", "/otaMag/getDownloadUrl/{id}") + + +@dataclass(frozen=True, slots=True) +class AuthenticatedRouteCase: + method: str + template: str + path: str + auth: str + request_kwargs: dict[str, Any] + + @property + def key(self) -> tuple[str, str]: + return self.method, self.template + + +def _load_routes() -> list[dict[str, Any]]: + manifest = json.loads(JAVA_MANIFEST.read_text(encoding="utf-8")) + routes = cast(list[dict[str, Any]], manifest["routes"]) + if manifest["count"] != 154 or len(routes) != 154: + raise RuntimeError("Java route manifest is not closed at 154 routes") + return routes + + +def _path_value(route: dict[str, Any], name: str) -> str: + controller = str(route["controller"]) + if name == "userId" or (name == "id" and controller in NUMERIC_ID_CONTROLLERS): + return MISSING_LONG_ID + if name == "status": + return "0" if controller in {"AdminController", "ModelController"} else "contract-missing-status" + if name == "deviceCode": + return "999999" + if name == "macAddress": + return "AA:BB:CC:DD:EE:FE" + if name == "dictType": + return "CONTRACT_MISSING_DICT_TYPE" + if name == "modelType": + return "CONTRACT_MISSING_MODEL_TYPE" + if name == "provideCode": + return "contract-missing-provider" + return f"contract-missing-{name.lower()}" + + +def _safe_path(route: dict[str, Any]) -> str: + template = str(route["path"]) + return PATH_PARAMETER.sub(lambda match: _path_value(route, match.group(1)), template) + + +def _headers(auth: str) -> dict[str, str]: + if auth == "database-token": + return {"Authorization": f"Bearer {ADMIN_TOKEN}"} + if auth == "server-secret": + return {"Authorization": f"Bearer {SERVER_SECRET}"} + return {} + + +def build_cases() -> list[AuthenticatedRouteCase]: + cases: list[AuthenticatedRouteCase] = [] + for route in _load_routes(): + method = str(route["method"]) + template = str(route["path"]) + auth = str(route["auth"]) + kwargs: dict[str, Any] = {} + headers = _headers(auth) + if headers: + kwargs["headers"] = headers + key = (method, template) + if key in SINGLE_CONSTRAINT_PAYLOADS: + kwargs["json"] = SINGLE_CONSTRAINT_PAYLOADS[key] + elif method in {"POST", "PUT"} and key not in MULTIPART_ROUTES and key not in NO_BODY_ROUTES: + kwargs["json"] = {} + cases.append( + AuthenticatedRouteCase( + method=method, + template=template, + path=_safe_path(route), + auth=auth, + request_kwargs=kwargs, + ) + ) + return cases + + +def _valid_random_password(value: Any) -> bool: + return ( + isinstance(value, str) + and len(value) == 12 + and set(value) <= PASSWORD_CHARACTERS + and any(character in string.digits for character in value) + and any(character in string.ascii_lowercase for character in value) + and any(character in string.ascii_uppercase for character in value) + and any(character in "!@#$%^&*()" for character in value) + ) + + +def _valid_uuid4(value: Any) -> bool: + if not isinstance(value, str): + return False + try: + parsed = uuid.UUID(value) + except (ValueError, AttributeError): + return False + return parsed.version == 4 and str(parsed) == value + + +def _normalize_dynamic_data( + value: Any, + *, + validator: Callable[[Any], bool], + placeholder: str, +) -> Any: + if ( + isinstance(value, dict) + and value.get("code") == 0 + and validator(value.get("data")) + ): + return {**value, "data": placeholder} + return value + + +def _normalize_random_password(value: Any) -> Any: + return _normalize_dynamic_data( + value, + validator=_valid_random_password, + placeholder="", + ) + + +def _normalize_ota_download_uuid(value: Any) -> Any: + return _normalize_dynamic_data( + value, + validator=_valid_uuid4, + placeholder="", + ) + + +DYNAMIC_RESPONSES: dict[ + tuple[str, str], tuple[Callable[[Any], Any], Callable[[Any], bool], str] +] = { + RANDOM_PASSWORD_ROUTE: ( + _normalize_random_password, + _valid_random_password, + "random_password", + ), + OTA_DOWNLOAD_UUID_ROUTE: ( + _normalize_ota_download_uuid, + _valid_uuid4, + "uuid_v4", + ), +} + + +def _validate_dynamic_response( + runner: ContractRunner, + *, + java_body: Any, + fastapi_body: Any, + validator: Callable[[Any], bool], + label: str, +) -> None: + result = runner.results[-1] + validity = { + f"java:{label}": isinstance(java_body, dict) + and java_body.get("code") == 0 + and validator(java_body.get("data")), + f"fastapi:{label}": isinstance(fastapi_body, dict) + and fastapi_body.get("code") == 0 + and validator(fastapi_body.get("data")), + } + result.checks.update(validity) + result.passed = all(result.checks.values()) + failed = [name for name, passed in validity.items() if not passed] + if failed and result.difference is None: + result.difference = "invalid dynamic response format: " + ", ".join(failed) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--java-base", default="http://127.0.0.1:18082/xiaozhi") + parser.add_argument("--fastapi-base", default="http://127.0.0.1:18083/xiaozhi") + parser.add_argument("--mock-base", default="http://127.0.0.1:18084") + parser.add_argument("--mysql-port", type=int, default=13316) + parser.add_argument( + "--output", + type=Path, + default=Path("compatibility/authenticated-route-results.json"), + ) + args = parser.parse_args() + runner = ContractRunner(args.java_base, args.fastapi_base, args.mock_base, args.mysql_port) + try: + for case in build_cases(): + dynamic = DYNAMIC_RESPONSES.get(case.key) + java, fastapi = runner.request_pair( + f"{case.method} {case.template}", + f"authenticated-route:{case.auth}", + case.method, + case.path, + normalize=dynamic[0] if dynamic else None, + exact_headers=("content-type",), + **case.request_kwargs, + ) + if dynamic: + _validate_dynamic_response( + runner, + java_body=java.body, + fastapi_body=fastapi.body, + validator=dynamic[1], + label=dynamic[2], + ) + finally: + runner.close() + + report = _build_report(runner) + report["coverage"] = { + "java_routes": len(runner.results), + "request_profile": "authenticated-safe-business-or-validation", + "side_effect_policy": "no intentional successful writes", + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(report, ensure_ascii=False, indent=2, default=str) + "\n", + encoding="utf-8", + ) + summary = report["summary"] + print(json.dumps(summary, ensure_ascii=False)) + for result in runner.results: + if not result.passed: + print(f"FAIL {result.name}: {result.difference}") + sys.exit(1 if summary["failed"] else 0) + + +if __name__ == "__main__": + main() diff --git a/main/manager-api-fastapi/tests/compatibility/differential_runner.py b/main/manager-api-fastapi/tests/compatibility/differential_runner.py new file mode 100644 index 00000000..a0af09e7 --- /dev/null +++ b/main/manager-api-fastapi/tests/compatibility/differential_runner.py @@ -0,0 +1,952 @@ +from __future__ import annotations + +import argparse +import base64 +import hashlib +import hmac +import json +import re +import sys +from collections.abc import Callable, Mapping +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, cast + +import asyncmy # type: ignore[import-untyped] +import httpx +from asyncmy.cursors import DictCursor # type: ignore[import-untyped] + +from tests.compatibility.seed_contract_data import ( + ADMIN_ID, + ADMIN_TOKEN, + EXPIRED_TOKEN, + MQTT_SIGNATURE_KEY, + NORMAL_ID, + NORMAL_TOKEN, + SERVER_SECRET, +) + +JSONValue = dict[str, Any] | list[Any] | str | int | float | bool | None +Normalizer = Callable[[JSONValue], JSONValue] +SENSITIVE_REPORT_KEYS = { + "accesstoken", + "authorization", + "mqttsignaturekey", + "password", + "privatekey", + "refreshtoken", + "secret", + "serversecret", + "token", + "websockettoken", +} + + +@dataclass +class ContractResult: + name: str + category: str + passed: bool + checks: dict[str, bool] + difference: str | None + java: dict[str, Any] + fastapi: dict[str, Any] + + +@dataclass +class CapturedResponse: + status: int + headers: dict[str, str | None] + body: JSONValue + body_sha256: str + + +class ContractRunner: + def __init__( + self, + java_base: str, + fastapi_base: str, + mock_base: str, + mysql_port: int, + ): + self.bases = {"java": java_base.rstrip("/"), "fastapi": fastapi_base.rstrip("/")} + self.mock_base = mock_base.rstrip("/") + self.mysql_port = mysql_port + self.client = httpx.Client(timeout=20.0, follow_redirects=False) + self.results: list[ContractResult] = [] + + def close(self) -> None: + self.client.close() + + @staticmethod + def _capture(response: httpx.Response) -> CapturedResponse: + content_type = response.headers.get("content-type") + try: + body: JSONValue = response.json() + except (json.JSONDecodeError, UnicodeDecodeError): + try: + body = response.content.decode("utf-8") + except UnicodeDecodeError: + body = {"base64": base64.b64encode(response.content).decode()} + return CapturedResponse( + status=response.status_code, + headers={ + "content-type": content_type, + "content-disposition": response.headers.get("content-disposition"), + "content-length": response.headers.get("content-length"), + }, + body=body, + body_sha256=hashlib.sha256(response.content).hexdigest(), + ) + + def request_pair( + self, + name: str, + category: str, + method: str, + path: str, + *, + normalize: Normalizer | None = None, + exact_headers: tuple[str, ...] = (), + **kwargs: Any, + ) -> tuple[CapturedResponse, CapturedResponse]: + captured: dict[str, CapturedResponse] = {} + for side, base in self.bases.items(): + captured[side] = self._capture(self.client.request(method, base + path, **kwargs)) + java, fastapi = captured["java"], captured["fastapi"] + java_body = normalize(java.body) if normalize else java.body + fastapi_body = normalize(fastapi.body) if normalize else fastapi.body + checks = { + "http_status": java.status == fastapi.status, + "body": java_body == fastapi_body, + } + for header in exact_headers: + checks[f"header:{header}"] = java.headers.get(header) == fastapi.headers.get(header) + difference = _first_difference(java_body, fastapi_body) + if difference is None and not checks["http_status"]: + difference = f"HTTP status Java={java.status} FastAPI={fastapi.status}" + if difference is None: + for check, passed in checks.items(): + if not passed: + difference = ( + f"{check} Java={java.headers.get(check.removeprefix('header:'))!r} " + f"FastAPI={fastapi.headers.get(check.removeprefix('header:'))!r}" + ) + break + self.results.append( + ContractResult( + name=name, + category=category, + passed=all(checks.values()), + checks=checks, + difference=difference, + java=_response_summary(java, java_body), + fastapi=_response_summary(fastapi, fastapi_body), + ) + ) + return java, fastapi + + def add_custom( + self, + name: str, + category: str, + checks: Mapping[str, bool], + *, + difference: str | None = None, + java: Mapping[str, Any] | None = None, + fastapi: Mapping[str, Any] | None = None, + ) -> None: + failed_checks = [check for check, passed in checks.items() if not passed] + self.results.append( + ContractResult( + name=name, + category=category, + passed=not failed_checks, + checks=dict(checks), + difference=difference or (", ".join(failed_checks) if failed_checks else None), + java=dict(java or {}), + fastapi=dict(fastapi or {}), + ) + ) + + def run_read_only_cases(self) -> None: + self.request_pair("public configuration", "configuration", "GET", "/user/pub-config") + languages: list[str | None] = [None, "zh-CN", "zh-TW", "en-US", "de-DE", "vi-VN", "pt-BR"] + for language in languages: + headers = {} if language is None else {"Accept-Language": language} + self.request_pair( + f"unauthenticated localized response [{language or 'default'}]", + "authentication-i18n", + "GET", + "/user/info", + headers=headers, + exact_headers=("content-type",), + ) + self.request_pair( + "expired database token", + "authentication", + "GET", + "/user/info", + headers=_bearer(EXPIRED_TOKEN), + exact_headers=("content-type",), + ) + self.request_pair( + "normal user forbidden from admin endpoint", + "authorization", + "GET", + "/admin/users", + headers=_bearer(NORMAL_TOKEN), + ) + self.request_pair( + "Long user ID and token response", + "serialization", + "GET", + "/user/info", + headers=_bearer(NORMAL_TOKEN), + ) + self.request_pair( + "user page Long/date/null structure", + "serialization", + "GET", + "/admin/users?page=1&limit=10", + headers=_bearer(ADMIN_TOKEN), + ) + self.request_pair( + "agent list null/date fields", + "agent", + "GET", + "/agent/list", + headers=_bearer(NORMAL_TOKEN), + ) + self.request_pair( + "device list UTC display and epoch fields", + "device", + "GET", + "/device/bind/contract-agent-1", + headers=_bearer(NORMAL_TOKEN), + ) + self.request_pair( + "filtered model provider page", + "model", + "GET", + "/models/provider?page=1&limit=10&name=Contract%20Provider", + headers=_bearer(ADMIN_TOKEN), + ) + self.request_pair( + "correct-word page", + "correct-word", + "GET", + "/correct-word/file/list?page=1&limit=10", + headers=_bearer(NORMAL_TOKEN), + ) + self.request_pair( + "correct-word binary download headers and bytes", + "binary-download", + "GET", + "/correct-word/file/download/contract-words", + headers=_bearer(NORMAL_TOKEN), + exact_headers=("content-type", "content-disposition", "content-length"), + ) + + def run_validation_cases(self) -> None: + normal = _bearer(NORMAL_TOKEN) + admin = _bearer(ADMIN_TOKEN) + self.request_pair( + "minimum boundary validation", + "validation", + "PUT", + "/device/update/contract-device-1", + headers=normal, + json={"autoUpdate": -1}, + ) + self.request_pair( + "maximum boundary validation", + "validation", + "PUT", + "/device/update/contract-device-1", + headers=normal, + json={"autoUpdate": 2}, + ) + self.request_pair( + "UTF-16 string-length validation boundary", + "validation", + "PUT", + "/device/update/contract-device-1", + headers=normal, + json={"alias": "x" * 65}, + ) + # Hibernate Validator returns a Set of constraint violations, so the + # first of these five declared annotations is intentionally unstable. + # Validate the exact declared set and envelope, not one random order. + declared_messages = { + "modelType不能为空", + "providerCode不能为空", + "name不能为空", + "fields(JSON格式)不能为空", + "sort不能为空", + } + java_responses = [ + self._capture( + self.client.post( + self.bases["java"] + "/models/provider", + headers=admin, + json={}, + ) + ) + for _ in range(10) + ] + fastapi_response = self._capture( + self.client.post( + self.bases["fastapi"] + "/models/provider", + headers=admin, + json={}, + ) + ) + java_messages = { + str(response.body.get("msg")) + for response in java_responses + if isinstance(response.body, dict) + } + fastapi_message = ( + str(fastapi_response.body.get("msg")) if isinstance(fastapi_response.body, dict) else None + ) + self.add_custom( + "required model-provider fields (unordered Java ConstraintViolation Set)", + "validation", + { + "java_http_200": all(response.status == 200 for response in java_responses), + "java_code_10034": all( + isinstance(response.body, dict) and response.body.get("code") == 10034 + for response in java_responses + ), + "java_messages_are_declared_constraints": bool(java_messages) + and java_messages <= declared_messages, + "fastapi_http_200": fastapi_response.status == 200, + "fastapi_code_10034": isinstance(fastapi_response.body, dict) + and fastapi_response.body.get("code") == 10034, + "fastapi_message_is_declared_constraint": fastapi_message in declared_messages, + }, + java={"observed_messages": sorted(java_messages), "declared_messages": sorted(declared_messages)}, + fastapi={"body": fastapi_response.body}, + ) + self.request_pair( + "invalid pagination number format", + "validation", + "GET", + "/admin/users?page=bad&limit=10", + headers=admin, + ) + + def run_server_auth_and_config(self) -> None: + self.request_pair( + "missing server secret", + "server-secret", + "POST", + "/config/server-base", + exact_headers=("content-type",), + ) + self.request_pair( + "invalid server secret", + "server-secret", + "POST", + "/config/server-base", + headers=_bearer("invalid-contract-secret"), + exact_headers=("content-type",), + ) + self.request_pair( + "valid server secret runtime configuration", + "server-secret", + "POST", + "/config/server-base", + headers=_bearer(SERVER_SECRET), + ) + + def run_ota_cases(self) -> None: + self.request_pair( + "OTA health MIME and body", + "ota", + "GET", + "/ota/", + exact_headers=("content-type",), + ) + self.request_pair( + "OTA missing required Device-Id", + "ota-validation", + "POST", + "/ota/", + json={"application": {"version": "1.2.3"}, "board": {"type": "esp32s3"}}, + ) + self.request_pair( + "OTA invalid Device-Id", + "ota-validation", + "POST", + "/ota/", + headers={"Device-Id": "not-a-mac"}, + json={"application": {"version": "1.2.3"}, "board": {"type": "esp32s3"}}, + exact_headers=("content-type", "content-length"), + ) + java, fastapi = self.request_pair( + "OTA WebSocket/MQTT credentials", + "ota-signing", + "POST", + "/ota/", + headers={"Device-Id": "AA:BB:CC:DD:EE:01", "Client-Id": "contract-client"}, + json={"application": {"version": "1.2.3"}, "board": {"type": "esp32s3"}}, + normalize=_normalize_ota_dynamic, + exact_headers=("content-type", "content-length"), + ) + self.add_custom( + "OTA HMAC/Base64 cryptographic validity", + "ota-signing", + { + "java_credentials": _valid_ota_credentials(java.body), + "fastapi_credentials": _valid_ota_credentials(fastapi.body), + }, + java={"credential_shape": _ota_credential_shape(java.body)}, + fastapi={"credential_shape": _ota_credential_shape(fastapi.body)}, + ) + self.request_pair( + "activation missing Device-Id", + "activation", + "POST", + "/ota/activate", + ) + self.request_pair( + "activation unknown device", + "activation", + "POST", + "/ota/activate", + headers={"Device-Id": "FF:EE:DD:CC:BB:AA"}, + ) + self.request_pair( + "activation bound device MIME", + "activation", + "POST", + "/ota/activate", + headers={"Device-Id": "AA:BB:CC:DD:EE:01"}, + exact_headers=("content-type",), + ) + + def run_external_mock_case(self) -> None: + self.client.delete(self.mock_base + "/__requests") + java, fastapi = self.request_pair( + "MQTT tools external mock response", + "external-mock", + "POST", + "/device/tools/list/contract-device-1", + headers=_bearer(NORMAL_TOKEN), + ) + records = self.client.get(self.mock_base + "/__requests").json().get("requests", []) + recent = [record for record in records if str(record.get("path", "")).startswith("/api/commands/")] + expected_token = hashlib.sha256( + f"{datetime.now(tz=timezone.utc).date().isoformat()}{MQTT_SIGNATURE_KEY}".encode() + ).hexdigest() + auth_values = [record.get("headers", {}).get("authorization") for record in recent] + bodies = [record.get("body") for record in recent] + self.add_custom( + "MQTT external request format and daily auth", + "external-mock", + { + "both_services_called_mock": len(recent) == 2, + "request_bodies_equal": len(bodies) == 2 and bodies[0] == bodies[1], + "daily_tokens_valid": len(auth_values) == 2 + and all(value == f"Bearer {expected_token}" for value in auth_values), + "responses_compatible": java.body == fastapi.body, + }, + java={"request": recent[0] if recent else None}, + fastapi={"request": recent[1] if len(recent) > 1 else None}, + ) + + def run_correct_word_crud(self) -> None: + payload = { + "fileName": "contract-run-created.txt", + "content": ["xiaozhi|小智", "ESP32|esp32"], + "fileSize": 31, + } + responses: dict[str, CapturedResponse] = {} + identifiers: dict[str, str] = {} + for side, base in self.bases.items(): + response = self._capture( + self.client.post(base + "/correct-word/file", headers=_bearer(NORMAL_TOKEN), json=payload) + ) + responses[side] = response + if isinstance(response.body, dict): + data = response.body.get("data") + if isinstance(data, dict) and isinstance(data.get("id"), str): + identifiers[side] = data["id"] + java_normalized = _normalize_created_correct_word(responses["java"].body) + fastapi_normalized = _normalize_created_correct_word(responses["fastapi"].body) + self.add_custom( + "correct-word create response", + "crud", + { + "http_status": responses["java"].status == responses["fastapi"].status, + "body": java_normalized == fastapi_normalized, + "ids_returned": set(identifiers) == {"java", "fastapi"}, + }, + difference=_first_difference(java_normalized, fastapi_normalized), + java={"body": java_normalized}, + fastapi={"body": fastapi_normalized}, + ) + if set(identifiers) != {"java", "fastapi"}: + return + side_effects = { + side: _database_row( + self.mysql_port, + "manager_java_test" if side == "java" else "manager_fastapi_test", + "SELECT file_name,word_count,content,creator FROM ai_agent_correct_word_file WHERE id=%s", + (identifier,), + ) + for side, identifier in identifiers.items() + } + self.add_custom( + "correct-word create database side effect", + "database-side-effect", + {"row_equal": side_effects["java"] == side_effects["fastapi"]}, + difference=_first_difference(side_effects["java"], side_effects["fastapi"]), + java=side_effects["java"], + fastapi=side_effects["fastapi"], + ) + + update = {"fileName": "contract-run-updated.txt", "content": ["A|B"], "fileSize": 3} + update_responses: dict[str, CapturedResponse] = {} + for side, identifier in identifiers.items(): + update_responses[side] = self._capture( + self.client.put( + self.bases[side] + f"/correct-word/file/{identifier}", + headers=_bearer(NORMAL_TOKEN), + json=update, + ) + ) + self.add_custom( + "correct-word update response", + "crud", + { + "status": update_responses["java"].status == update_responses["fastapi"].status, + "body": update_responses["java"].body == update_responses["fastapi"].body, + }, + difference=_first_difference(update_responses["java"].body, update_responses["fastapi"].body), + java={"body": update_responses["java"].body}, + fastapi={"body": update_responses["fastapi"].body}, + ) + updated_rows = { + side: _database_row( + self.mysql_port, + "manager_java_test" if side == "java" else "manager_fastapi_test", + "SELECT file_name,word_count,content,creator,updater FROM ai_agent_correct_word_file WHERE id=%s", + (identifier,), + ) + for side, identifier in identifiers.items() + } + self.add_custom( + "correct-word update database side effect", + "database-side-effect", + {"row_equal": updated_rows["java"] == updated_rows["fastapi"]}, + difference=_first_difference(updated_rows["java"], updated_rows["fastapi"]), + java=updated_rows["java"], + fastapi=updated_rows["fastapi"], + ) + downloads = { + side: self._capture( + self.client.get( + self.bases[side] + f"/correct-word/file/download/{identifier}", + headers=_bearer(NORMAL_TOKEN), + ) + ) + for side, identifier in identifiers.items() + } + self.add_custom( + "correct-word updated binary download", + "binary-download", + { + "status": downloads["java"].status == downloads["fastapi"].status, + "bytes": downloads["java"].body_sha256 == downloads["fastapi"].body_sha256, + "content_type": downloads["java"].headers["content-type"] + == downloads["fastapi"].headers["content-type"], + "content_disposition": downloads["java"].headers["content-disposition"] + == downloads["fastapi"].headers["content-disposition"], + "content_length": downloads["java"].headers["content-length"] + == downloads["fastapi"].headers["content-length"], + }, + java=asdict(downloads["java"]), + fastapi=asdict(downloads["fastapi"]), + ) + for side, identifier in identifiers.items(): + self.client.delete( + self.bases[side] + f"/correct-word/file/{identifier}", + headers=_bearer(NORMAL_TOKEN), + ) + counts = { + side: _database_scalar( + self.mysql_port, + "manager_java_test" if side == "java" else "manager_fastapi_test", + "SELECT COUNT(*) FROM ai_agent_correct_word_file WHERE id=%s", + (identifier,), + ) + for side, identifier in identifiers.items() + } + self.add_custom( + "correct-word delete cascade side effect", + "database-side-effect", + {"both_deleted": counts == {"java": 0, "fastapi": 0}}, + java={"remaining": counts["java"]}, + fastapi={"remaining": counts["fastapi"]}, + ) + + def run_ota_upload_download(self) -> None: + firmware = b"manager-api-contract-firmware\x00\xff" + upload_responses: dict[str, CapturedResponse] = {} + paths: dict[str, str] = {} + for side, base in self.bases.items(): + response = self._capture( + self.client.post( + base + "/otaMag/upload", + headers=_bearer(ADMIN_TOKEN), + files={"file": ("contract.bin", firmware, "application/octet-stream")}, + ) + ) + upload_responses[side] = response + if isinstance(response.body, dict) and isinstance(response.body.get("data"), str): + paths[side] = response.body["data"] + self.add_custom( + "OTA multipart upload response", + "upload", + { + "status": upload_responses["java"].status == upload_responses["fastapi"].status, + "body": upload_responses["java"].body == upload_responses["fastapi"].body, + "paths_returned": set(paths) == {"java", "fastapi"}, + }, + difference=_first_difference(upload_responses["java"].body, upload_responses["fastapi"].body), + java={"body": upload_responses["java"].body}, + fastapi={"body": upload_responses["fastapi"].body}, + ) + self.request_pair( + "OTA multipart extension validation", + "upload-validation", + "POST", + "/otaMag/upload", + headers=_bearer(ADMIN_TOKEN), + files={"file": ("contract.txt", b"invalid", "text/plain")}, + ) + if set(paths) != {"java", "fastapi"}: + return + create_responses: dict[str, CapturedResponse] = {} + for side, base in self.bases.items(): + create_responses[side] = self._capture( + self.client.post( + base + "/otaMag", + headers=_bearer(ADMIN_TOKEN), + json={ + "id": "contract-ota-run", + "firmwareName": "Contract Firmware", + "type": "contract-run", + "version": "1.0.0", + "size": len(firmware), + "remark": None, + "firmwarePath": paths[side], + "sort": 9, + }, + ) + ) + self.add_custom( + "OTA metadata create response", + "crud", + { + "status": create_responses["java"].status == create_responses["fastapi"].status, + "body": create_responses["java"].body == create_responses["fastapi"].body, + }, + difference=_first_difference(create_responses["java"].body, create_responses["fastapi"].body), + java={"body": create_responses["java"].body}, + fastapi={"body": create_responses["fastapi"].body}, + ) + rows = { + side: _database_row( + self.mysql_port, + "manager_java_test" if side == "java" else "manager_fastapi_test", + "SELECT id,firmware_name,type,version,size,remark,firmware_path,sort FROM ai_ota WHERE id=%s", + ("contract-ota-run",), + ) + for side in self.bases + } + self.add_custom( + "OTA metadata database side effect", + "database-side-effect", + {"row_equal": rows["java"] == rows["fastapi"]}, + difference=_first_difference(rows["java"], rows["fastapi"]), + java=rows["java"], + fastapi=rows["fastapi"], + ) + download_ids: dict[str, str] = {} + for side, base in self.bases.items(): + response = self.client.get( + base + "/otaMag/getDownloadUrl/contract-ota-run", + headers=_bearer(ADMIN_TOKEN), + ).json() + if isinstance(response.get("data"), str): + download_ids[side] = response["data"] + if set(download_ids) != {"java", "fastapi"}: + self.add_custom( + "OTA download token generation", + "binary-download", + {"tokens_returned": False}, + java={"id": download_ids.get("java")}, + fastapi={"id": download_ids.get("fastapi")}, + ) + return + for attempt in range(1, 5): + responses = { + side: self._capture(self.client.get(self.bases[side] + f"/otaMag/download/{identifier}")) + for side, identifier in download_ids.items() + } + self.add_custom( + f"OTA binary download attempt {attempt}", + "binary-download", + { + "status": responses["java"].status == responses["fastapi"].status, + "bytes": responses["java"].body_sha256 == responses["fastapi"].body_sha256, + "content_type": responses["java"].headers["content-type"] + == responses["fastapi"].headers["content-type"], + "content_disposition": responses["java"].headers["content-disposition"] + == responses["fastapi"].headers["content-disposition"], + "content_length": responses["java"].headers["content-length"] + == responses["fastapi"].headers["content-length"], + }, + java=asdict(responses["java"]), + fastapi=asdict(responses["fastapi"]), + ) + + +def _bearer(value: str) -> dict[str, str]: + return {"Authorization": f"Bearer {value}"} + + +def _response_summary(response: CapturedResponse, normalized_body: JSONValue) -> dict[str, Any]: + report_body = _redact_report_value(normalized_body) + body_text = json.dumps(report_body, ensure_ascii=False, sort_keys=True, default=str) + return { + "status": response.status, + "headers": response.headers, + "body": report_body if len(body_text) <= 8_000 else None, + "body_sha256": hashlib.sha256(body_text.encode()).hexdigest(), + "body_excerpt": body_text[:1_000], + } + + +def _redact_report_value(value: Any) -> Any: + if isinstance(value, dict): + redacted: dict[str, Any] = {} + parameter_code = next( + ( + item + for key, item in value.items() + if re.sub(r"[^a-z0-9]", "", str(key).lower()) == "paramcode" + ), + None, + ) + sensitive_parameter = ( + isinstance(parameter_code, str) + and re.sub(r"[^a-z0-9]", "", parameter_code.lower()) in SENSITIVE_REPORT_KEYS + ) + for key, item in value.items(): + normalized_key = re.sub(r"[^a-z0-9]", "", str(key).lower()) + redacted[str(key)] = ( + "" + if normalized_key in SENSITIVE_REPORT_KEYS + or sensitive_parameter and normalized_key == "paramvalue" + else _redact_report_value(item) + ) + return redacted + if isinstance(value, list): + return [_redact_report_value(item) for item in value] + if isinstance(value, tuple): + return [_redact_report_value(item) for item in value] + return value + + +def _first_difference(left: Any, right: Any, path: str = "$") -> str | None: + if type(left) is not type(right): + return f"{path}: type Java={type(left).__name__} FastAPI={type(right).__name__}" + if isinstance(left, dict): + left_keys, right_keys = set(left), set(right) + if left_keys != right_keys: + return ( + f"{path}: keys Java-only={sorted(left_keys - right_keys)} " + f"FastAPI-only={sorted(right_keys - left_keys)}" + ) + for key in sorted(left): + difference = _first_difference(left[key], right[key], f"{path}.{key}") + if difference: + return difference + return None + if isinstance(left, list): + if len(left) != len(right): + return f"{path}: length Java={len(left)} FastAPI={len(right)}" + for index, (left_item, right_item) in enumerate(zip(left, right, strict=True)): + difference = _first_difference(left_item, right_item, f"{path}[{index}]") + if difference: + return difference + return None + if left != right: + return f"{path}: Java={left!r} FastAPI={right!r}" + return None + + +def _normalize_ota_dynamic(value: JSONValue) -> JSONValue: + copied = json.loads(json.dumps(value, ensure_ascii=False)) + if isinstance(copied, dict): + server_time = copied.get("server_time") + if isinstance(server_time, dict) and isinstance(server_time.get("timestamp"), int): + server_time["timestamp"] = "" + websocket = copied.get("websocket") + if isinstance(websocket, dict) and isinstance(websocket.get("token"), str): + websocket["token"] = "" # noqa: S105 - redaction marker + return cast(JSONValue, copied) + + +def _valid_ota_credentials(value: JSONValue) -> bool: + if not isinstance(value, dict): + return False + websocket, mqtt = value.get("websocket"), value.get("mqtt") + if not isinstance(websocket, dict) or not isinstance(mqtt, dict): + return False + token = websocket.get("token") + if not isinstance(token, str) or "." not in token: + return False + encoded, raw_timestamp = token.rsplit(".", 1) + if not raw_timestamp.isdigit(): + return False + expected = base64.urlsafe_b64encode( + hmac.new( + SERVER_SECRET.encode(), + f"contract-client|AA:BB:CC:DD:EE:01|{raw_timestamp}".encode(), + hashlib.sha256, + ).digest() + ).decode().rstrip("=") + if not hmac.compare_digest(encoded, expected): + return False + client_id, username, password = mqtt.get("client_id"), mqtt.get("username"), mqtt.get("password") + if not all(isinstance(item, str) for item in (client_id, username, password)): + return False + expected_password = base64.b64encode( + hmac.new( + MQTT_SIGNATURE_KEY.encode(), + f"{client_id}|{username}".encode(), + hashlib.sha256, + ).digest() + ).decode() + try: + user_data = json.loads(base64.b64decode(str(username)).decode()) + except (ValueError, UnicodeDecodeError, json.JSONDecodeError): + return False + return hmac.compare_digest(str(password), expected_password) and isinstance(user_data.get("ip"), str) + + +def _ota_credential_shape(value: JSONValue) -> dict[str, Any]: + if not isinstance(value, dict): + return {"valid": False} + websocket, mqtt = value.get("websocket"), value.get("mqtt") + return { + "websocket_token_pattern": bool( + isinstance(websocket, dict) + and re.fullmatch(r"[A-Za-z0-9_-]{43}\.\d{10}", str(websocket.get("token", ""))) + ), + "mqtt_fields": sorted(mqtt) if isinstance(mqtt, dict) else [], + } + + +def _normalize_created_correct_word(value: JSONValue) -> JSONValue: + copied = json.loads(json.dumps(value, ensure_ascii=False)) + if isinstance(copied, dict) and isinstance(copied.get("data"), dict): + data = copied["data"] + if isinstance(data.get("id"), str): + data["id"] = "" + for field in ("createdAt", "updatedAt"): + if data.get(field) is not None: + data[field] = "" + return cast(JSONValue, copied) + + +def _database_config(port: int, database: str) -> dict[str, Any]: + return { + "host": "127.0.0.1", + "port": port, + "user": "xiaozhi_test", + "password": "isolated-test-only", + "db": database, + "charset": "utf8mb4", + "autocommit": True, + } + + +def _database_row(port: int, database: str, sql: str, params: tuple[Any, ...]) -> dict[str, Any]: + async def query() -> dict[str, Any]: + connection = await asyncmy.connect(**_database_config(port, database)) + try: + async with connection.cursor(DictCursor) as cursor: + await cursor.execute(sql, params) + row = await cursor.fetchone() + return {} if row is None else {str(key): value for key, value in row.items()} + finally: + connection.close() + + import asyncio + + return asyncio.run(query()) + + +def _database_scalar(port: int, database: str, sql: str, params: tuple[Any, ...]) -> int: + row = _database_row(port, database, sql, params) + return int(next(iter(row.values()), 0)) + + +def _build_report(runner: ContractRunner) -> dict[str, Any]: + passed = sum(result.passed for result in runner.results) + failed = len(runner.results) - passed + categories: dict[str, dict[str, int]] = {} + for result in runner.results: + category = categories.setdefault(result.category, {"passed": 0, "failed": 0}) + category["passed" if result.passed else "failed"] += 1 + return { + "schema_version": 1, + "generated_at": datetime.now(tz=timezone.utc).isoformat(), + "services": runner.bases, + "fixture_ids": {"admin": str(ADMIN_ID), "normal": str(NORMAL_ID)}, + "summary": {"total": len(runner.results), "passed": passed, "failed": failed, "skipped": 0}, + "categories": categories, + "results": [_redact_report_value(asdict(result)) for result in runner.results], + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--java-base", default="http://127.0.0.1:18082/xiaozhi") + parser.add_argument("--fastapi-base", default="http://127.0.0.1:18083/xiaozhi") + parser.add_argument("--mock-base", default="http://127.0.0.1:18084") + parser.add_argument("--mysql-port", type=int, default=13316) + parser.add_argument("--output", type=Path, default=Path("compatibility/contract-results.json")) + args = parser.parse_args() + runner = ContractRunner(args.java_base, args.fastapi_base, args.mock_base, args.mysql_port) + try: + runner.run_read_only_cases() + runner.run_validation_cases() + runner.run_server_auth_and_config() + runner.run_ota_cases() + runner.run_external_mock_case() + runner.run_correct_word_crud() + runner.run_ota_upload_download() + finally: + runner.close() + report = _build_report(runner) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str) + "\n", encoding="utf-8") + summary = report["summary"] + print(json.dumps(summary, ensure_ascii=False)) + for result in runner.results: + if not result.passed: + print(f"FAIL {result.name}: {result.difference}") + sys.exit(1 if summary["failed"] else 0) + + +if __name__ == "__main__": + main() diff --git a/main/manager-api-fastapi/tests/compatibility/external_mock.py b/main/manager-api-fastapi/tests/compatibility/external_mock.py new file mode 100644 index 00000000..dc60e7ea --- /dev/null +++ b/main/manager-api-fastapi/tests/compatibility/external_mock.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import json +from collections.abc import Mapping +from typing import Annotated, Any + +from fastapi import FastAPI, File, Request, UploadFile +from fastapi.responses import JSONResponse + +app = FastAPI(title="manager-api repeatable external-service mock") + +_requests: list[dict[str, Any]] = [] +_datasets: dict[str, dict[str, Any]] = {} +_documents: dict[str, dict[str, Any]] = {} + + +async def _capture(request: Request, body: Any = None) -> None: + _requests.append( + { + "method": request.method, + "path": request.url.path, + "query": dict(request.query_params), + "headers": { + key.lower(): value + for key, value in request.headers.items() + if key.lower() in {"authorization", "content-type"} + }, + "body": body, + } + ) + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"status": "UP"} + + +@app.get("/__requests") +async def requests() -> dict[str, Any]: + return {"requests": list(_requests)} + + +@app.delete("/__requests") +async def reset_requests() -> dict[str, bool]: + _requests.clear() + return {"reset": True} + + +@app.post("/api/commands/{client_id}") +async def mqtt_command(client_id: str, request: Request) -> JSONResponse: + body = await request.json() + await _capture(request, body) + method = body.get("payload", {}).get("method") if isinstance(body, Mapping) else None + if method == "tools/list": + response = { + "success": True, + "data": { + "tools": [ + { + "name": "contract.echo", + "description": "repeatable contract fixture", + "inputSchema": {"type": "object"}, + } + ] + }, + } + elif method == "tools/call": + response = {"success": True, "data": {"content": [{"type": "text", "text": '{"echo":true}'}]}} + else: + response = {"success": True, "data": {"clientId": client_id}} + return JSONResponse(response) + + +@app.post("/api/devices/status") +async def mqtt_status(request: Request) -> JSONResponse: + body = await request.json() + await _capture(request, body) + client_ids = body.get("clientIds", []) if isinstance(body, Mapping) else [] + return JSONResponse({"success": True, "data": {str(value): True for value in client_ids}}) + + +@app.post("/api/call/{operation}") +async def mqtt_call(operation: str, request: Request) -> JSONResponse: + body = await request.json() + await _capture(request, body) + return JSONResponse({"status": "success", "message": operation}) + + +@app.post("/v1/chat/completions") +async def chat_completions(request: Request) -> JSONResponse: + body = await request.json() + await _capture(request, body) + return JSONResponse( + { + "id": "chatcmpl-contract", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "contract summary"}}], + } + ) + + +@app.get("/api/v1/datasets") +async def list_datasets(request: Request) -> JSONResponse: + await _capture(request) + dataset_id = request.query_params.get("id") + values = list(_datasets.values()) + if dataset_id: + values = [value for value in values if value["id"] == dataset_id] + return JSONResponse({"code": 0, "data": values}) + + +@app.post("/api/v1/datasets") +async def create_dataset(request: Request) -> JSONResponse: + body = await request.json() + await _capture(request, body) + identifier = "rag-contract-" + hashlib.sha256( + json.dumps(body, ensure_ascii=False, sort_keys=True).encode() + ).hexdigest()[:12] + value = { + "id": identifier, + "name": body.get("name"), + "description": body.get("description"), + "tenant_id": "tenant-contract", + "avatar": body.get("avatar"), + "document_count": 0, + "chunk_count": 0, + "token_num": 0, + } + _datasets[identifier] = value + return JSONResponse({"code": 0, "data": value}) + + +@app.put("/api/v1/datasets/{dataset_id}") +async def update_dataset(dataset_id: str, request: Request) -> JSONResponse: + body = await request.json() + await _capture(request, body) + value = _datasets.setdefault(dataset_id, {"id": dataset_id}) + value.update(body) + return JSONResponse({"code": 0, "data": value}) + + +@app.delete("/api/v1/datasets/{dataset_id}") +async def delete_dataset(dataset_id: str, request: Request) -> JSONResponse: + await _capture(request) + _datasets.pop(dataset_id, None) + return JSONResponse({"code": 0, "data": True}) + + +@app.post("/api/v1/datasets/{dataset_id}/documents") +async def upload_document( + dataset_id: str, + request: Request, + file: Annotated[UploadFile, File()], +) -> JSONResponse: + content = await file.read() + body: dict[str, Any] = { + "filename": file.filename, + "size": len(content), + "sha256": hashlib.sha256(content).hexdigest(), + } + await _capture(request, body) + identifier = "doc-contract-" + str(body["sha256"])[:12] + value = { + "id": identifier, + "dataset_id": dataset_id, + "name": file.filename, + "size": len(content), + "run": "UNSTART", + "progress": 0.0, + "chunk_count": 0, + "token_count": 0, + } + _documents[identifier] = value + return JSONResponse({"code": 0, "data": [value]}) + + +@app.get("/api/v1/datasets/{dataset_id}/documents") +async def list_documents(dataset_id: str, request: Request) -> JSONResponse: + await _capture(request) + document_id = request.query_params.get("id") + values = [value for value in _documents.values() if value["dataset_id"] == dataset_id] + if document_id: + values = [value for value in values if value["id"] == document_id] + return JSONResponse({"code": 0, "data": {"docs": values, "total": len(values)}}) + + +@app.post("/api/v1/datasets/{dataset_id}/chunks") +async def parse_documents(dataset_id: str, request: Request) -> JSONResponse: + body = await request.json() + await _capture(request, body) + for document_id in body.get("document_ids", []): + if document_id in _documents: + _documents[document_id].update(run="DONE", progress=1.0, chunk_count=2, token_count=17) + return JSONResponse({"code": 0, "data": True}) + + +@app.delete("/api/v1/datasets/{dataset_id}/documents") +async def delete_documents(dataset_id: str, request: Request) -> JSONResponse: + body = await request.json() + await _capture(request, body) + for document_id in body.get("ids", body.get("document_ids", [])): + _documents.pop(document_id, None) + return JSONResponse({"code": 0, "data": True}) + + +@app.post("/voice-clone") +async def clone_voice(request: Request) -> JSONResponse: + content = await request.body() + await _capture(request, {"size": len(content), "sha256": hashlib.sha256(content).hexdigest()}) + return JSONResponse({"voice_id": "voice-contract", "status": "success"}) + + +@app.post("/transient/{failures}") +async def transient(failures: int, request: Request) -> JSONResponse: + await _capture(request) + count = sum(1 for item in _requests if item["path"] == request.url.path) + if count <= failures: + await asyncio.sleep(0) + return JSONResponse({"error": "retryable"}, status_code=503) + return JSONResponse({"ok": True}) + + +@app.get("/binary") +async def binary(request: Request) -> JSONResponse: + await _capture(request) + content = b"contract-binary\x00\xff" + return JSONResponse({"base64": base64.b64encode(content).decode(), "size": len(content)}) diff --git a/main/manager-api-fastapi/tests/compatibility/performance_runner.py b/main/manager-api-fastapi/tests/compatibility/performance_runner.py new file mode 100644 index 00000000..01684c0f --- /dev/null +++ b/main/manager-api-fastapi/tests/compatibility/performance_runner.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import math +import time +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import httpx + +from tests.compatibility.seed_contract_data import ADMIN_TOKEN, NORMAL_TOKEN, SERVER_SECRET + + +@dataclass(frozen=True) +class Scenario: + name: str + method: str + path: str + headers: dict[str, str] + json_body: dict[str, Any] | None = None + raw_ota: bool = False + + +@dataclass +class Measurement: + service: str + scenario: str + requests: int + concurrency: int + warmup_requests: int + errors: int + elapsed_seconds: float + throughput_requests_per_second: float + latency_ms_min: float + latency_ms_p50: float + latency_ms_p95: float + latency_ms_max: float + + +def _bearer(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +SCENARIOS = ( + Scenario("representative-read", "GET", "/user/info", _bearer(ADMIN_TOKEN)), + Scenario( + "representative-crud-update", + "PUT", + "/device/update/contract-device-1", + _bearer(NORMAL_TOKEN), + {"autoUpdate": 1, "alias": None}, + ), + Scenario("runtime-configuration", "POST", "/config/server-base", _bearer(SERVER_SECRET)), + Scenario( + "ota-check-and-signing", + "POST", + "/ota/", + {"Device-Id": "AA:BB:CC:DD:EE:01", "Client-Id": "contract-performance"}, + {"application": {"version": "1.2.3"}, "board": {"type": "esp32s3"}}, + True, + ), +) + + +def _percentile(values: list[float], percentile: float) -> float: + ordered = sorted(values) + if not ordered: + return 0.0 + position = (len(ordered) - 1) * percentile + lower, upper = math.floor(position), math.ceil(position) + if lower == upper: + return ordered[lower] + return ordered[lower] + (ordered[upper] - ordered[lower]) * (position - lower) + + +async def _request_once(client: httpx.AsyncClient, base: str, scenario: Scenario) -> tuple[float, bool]: + started = time.perf_counter() + try: + response = await client.request( + scenario.method, + base + scenario.path, + headers=scenario.headers, + json=scenario.json_body, + ) + valid = response.status_code == 200 + if valid: + payload = response.json() + valid = ( + isinstance(payload, dict) + and ("server_time" in payload if scenario.raw_ota else payload.get("code") == 0) + ) + except (httpx.HTTPError, json.JSONDecodeError): + valid = False + return (time.perf_counter() - started) * 1000, valid + + +async def measure( + service: str, + base: str, + scenario: Scenario, + *, + requests: int, + concurrency: int, + warmup: int, +) -> Measurement: + limits = httpx.Limits(max_connections=concurrency, max_keepalive_connections=concurrency) + timeout = httpx.Timeout(30.0) + async with httpx.AsyncClient(limits=limits, timeout=timeout) as client: + for _ in range(warmup): + _, valid = await _request_once(client, base, scenario) + if not valid: + raise RuntimeError(f"warmup failed for {service}/{scenario.name}") + semaphore = asyncio.Semaphore(concurrency) + + async def bounded_request() -> tuple[float, bool]: + async with semaphore: + return await _request_once(client, base, scenario) + + started = time.perf_counter() + values = await asyncio.gather(*(bounded_request() for _ in range(requests))) + elapsed = time.perf_counter() - started + latencies = [latency for latency, _ in values] + errors = sum(not valid for _, valid in values) + return Measurement( + service=service, + scenario=scenario.name, + requests=requests, + concurrency=concurrency, + warmup_requests=warmup, + errors=errors, + elapsed_seconds=round(elapsed, 6), + throughput_requests_per_second=round(requests / elapsed, 3), + latency_ms_min=round(min(latencies), 3), + latency_ms_p50=round(_percentile(latencies, 0.50), 3), + latency_ms_p95=round(_percentile(latencies, 0.95), 3), + latency_ms_max=round(max(latencies), 3), + ) + + +async def run(args: argparse.Namespace) -> dict[str, Any]: + services = { + "java": args.java_base.rstrip("/"), + "fastapi": args.fastapi_base.rstrip("/"), + } + results: list[Measurement] = [] + for scenario in SCENARIOS: + for service, base in services.items(): + results.append( + await measure( + service, + base, + scenario, + requests=args.requests, + concurrency=args.concurrency, + warmup=args.warmup, + ) + ) + comparisons: list[dict[str, Any]] = [] + for scenario in SCENARIOS: + java = next(value for value in results if value.service == "java" and value.scenario == scenario.name) + fastapi = next(value for value in results if value.service == "fastapi" and value.scenario == scenario.name) + comparisons.append( + { + "scenario": scenario.name, + "p50_ratio_fastapi_over_java": round(fastapi.latency_ms_p50 / java.latency_ms_p50, 3), + "p95_ratio_fastapi_over_java": round(fastapi.latency_ms_p95 / java.latency_ms_p95, 3), + "throughput_ratio_fastapi_over_java": round( + fastapi.throughput_requests_per_second / java.throughput_requests_per_second, + 3, + ), + } + ) + return { + "schema_version": 1, + "generated_at": datetime.now(tz=timezone.utc).isoformat(), + "parameters": { + "requests_per_service_per_scenario": args.requests, + "concurrency": args.concurrency, + "sequential_warmup_requests": args.warmup, + "scenario_order": [scenario.name for scenario in SCENARIOS], + "service_order": ["java", "fastapi"], + }, + "results": [asdict(result) for result in results], + "comparisons": comparisons, + "summary": { + "measurements": len(results), + "requests_measured": sum(result.requests for result in results), + "errors": sum(result.errors for result in results), + }, + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--java-base", default="http://127.0.0.1:18082/xiaozhi") + parser.add_argument("--fastapi-base", default="http://127.0.0.1:18083/xiaozhi") + parser.add_argument("--requests", type=int, default=60) + parser.add_argument("--concurrency", type=int, default=6) + parser.add_argument("--warmup", type=int, default=10) + parser.add_argument("--output", type=Path, default=Path("compatibility/performance-results.json")) + args = parser.parse_args() + if args.requests <= 0 or args.concurrency <= 0 or args.warmup < 0: + parser.error("requests/concurrency must be positive and warmup must be non-negative") + report = asyncio.run(run(args)) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(report["summary"], ensure_ascii=False)) + if report["summary"]["errors"]: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/main/manager-api-fastapi/tests/compatibility/route_surface_runner.py b/main/manager-api-fastapi/tests/compatibility/route_surface_runner.py new file mode 100644 index 00000000..2a46d65a --- /dev/null +++ b/main/manager-api-fastapi/tests/compatibility/route_surface_runner.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any, cast + +from tests.compatibility.differential_runner import ContractRunner, _build_report + +TARGET_ROOT = Path(__file__).resolve().parents[2] +JAVA_MANIFEST = TARGET_ROOT / "compatibility" / "java-routes.json" +PATH_PARAMETER = re.compile(r"\{([^/{}]+)\}") + +SAFE_PATH_VALUES = { + "agentId": "contract-agent-1", + "audioId": "contract-audio", + "deviceCode": "000000", + "deviceId": "contract-device-1", + "dictType": "FIRMWARE_TYPE", + "document_id": "contract-document", + "dataset_id": "contract-dataset", + "fileId": "contract-words", + "id": "contract-id", + "macAddress": "AA:BB:CC:DD:EE:01", + "modelId": "contract-model", + "modelType": "LLM", + "provideCode": "OpenAI", + "sessionId": "contract-session", + "snapshotId": "contract-snapshot", + "status": "1", + "userId": "contract-user", + "uuid": "contract-unknown-token", +} + + +def _safe_path(template: str) -> str: + return PATH_PARAMETER.sub(lambda match: SAFE_PATH_VALUES.get(match.group(1), "contract-value"), template) + + +def _load_routes() -> list[dict[str, Any]]: + manifest = json.loads(JAVA_MANIFEST.read_text(encoding="utf-8")) + routes = cast(list[dict[str, Any]], manifest["routes"]) + if manifest["count"] != 154 or len(routes) != 154: + raise RuntimeError("Java route manifest is not closed at 154 routes") + return routes + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--java-base", default="http://127.0.0.1:18082/xiaozhi") + parser.add_argument("--fastapi-base", default="http://127.0.0.1:18083/xiaozhi") + parser.add_argument("--mock-base", default="http://127.0.0.1:18084") + parser.add_argument("--mysql-port", type=int, default=13316) + parser.add_argument( + "--output", + type=Path, + default=Path("compatibility/route-surface-results.json"), + ) + args = parser.parse_args() + runner = ContractRunner(args.java_base, args.fastapi_base, args.mock_base, args.mysql_port) + try: + for route in _load_routes(): + method = str(route["method"]) + template = str(route["path"]) + runner.request_pair( + f"{method} {template}", + f"route-surface:{route['auth']}", + method, + _safe_path(template), + exact_headers=("content-type",), + ) + finally: + runner.close() + + report = _build_report(runner) + report["coverage"] = { + "java_routes": len(runner.results), + "request_profile": "missing-auth-or-safe-invalid-input", + "side_effect_policy": "no successful write request is issued", + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(report, ensure_ascii=False, indent=2, default=str) + "\n", + encoding="utf-8", + ) + summary = report["summary"] + print(json.dumps(summary, ensure_ascii=False)) + for result in runner.results: + if not result.passed: + print(f"FAIL {result.name}: {result.difference}") + sys.exit(1 if summary["failed"] else 0) + + +if __name__ == "__main__": + main() diff --git a/main/manager-api-fastapi/tests/compatibility/seed_contract_data.py b/main/manager-api-fastapi/tests/compatibility/seed_contract_data.py new file mode 100644 index 00000000..4174a7ed --- /dev/null +++ b/main/manager-api-fastapi/tests/compatibility/seed_contract_data.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +import argparse +import asyncio +from datetime import datetime +from typing import Any + +import asyncmy # type: ignore[import-untyped] +from asyncmy.cursors import DictCursor # type: ignore[import-untyped] + +ADMIN_ID = 9_007_199_254_740_993 +NORMAL_ID = 9_007_199_254_740_994 +EXPIRED_ID = 9_007_199_254_740_995 +ADMIN_TOKEN = "contract-admin-token" # noqa: S105 - isolated fixture credential +NORMAL_TOKEN = "contract-normal-token" # noqa: S105 - isolated fixture credential +EXPIRED_TOKEN = "contract-expired-token" # noqa: S105 - isolated fixture credential +SERVER_SECRET = "contract-server-secret" # noqa: S105 - isolated fixture credential +MQTT_SIGNATURE_KEY = "contract-mqtt-signature-key" +MIGRATION_TIMESTAMP_TABLES = ( + "ai_model_provider", + "sys_dict_data", + "sys_dict_type", +) + + +def _database_config(port: int, database: str) -> dict[str, Any]: + return { + "host": "127.0.0.1", + "port": port, + "user": "xiaozhi_test", + "password": "isolated-test-only", + "db": database, + "charset": "utf8mb4", + "autocommit": False, + } + + +async def _query_params(port: int, database: str, codes: tuple[str, ...]) -> dict[str, str | None]: + connection = await asyncmy.connect(**_database_config(port, database)) + try: + async with connection.cursor(DictCursor) as cursor: + placeholders = ",".join(["%s"] * len(codes)) + await cursor.execute( + f"SELECT param_code,param_value FROM sys_params WHERE param_code IN ({placeholders})", # noqa: S608 + codes, + ) + rows = await cursor.fetchall() + return {str(row["param_code"]): row["param_value"] for row in rows} + finally: + connection.close() + + +async def _seed_database( + port: int, + database: str, + crypto_params: dict[str, str | None], + mock_port: int, +) -> None: + connection = await asyncmy.connect(**_database_config(port, database)) + now = datetime(2026, 7, 20, 12, 34, 56) + try: + async with connection.cursor(DictCursor) as cursor: + await cursor.execute( + "SELECT id FROM ai_agent_correct_word_file WHERE file_name LIKE 'contract-run-%'" + ) + generated_file_ids = [str(row["id"]) for row in await cursor.fetchall()] + if generated_file_ids: + placeholders = ",".join(["%s"] * len(generated_file_ids)) + await cursor.execute( + f"DELETE FROM ai_agent_correct_word_mapping WHERE file_id IN ({placeholders})", # noqa: S608 + generated_file_ids, + ) + await cursor.execute( + f"DELETE FROM ai_agent_correct_word_item WHERE file_id IN ({placeholders})", # noqa: S608 + generated_file_ids, + ) + await cursor.execute( + f"DELETE FROM ai_agent_correct_word_file WHERE id IN ({placeholders})", # noqa: S608 + generated_file_ids, + ) + await cursor.execute("DELETE FROM ai_ota WHERE id='contract-ota-run'") + + for identifier, username, super_admin in ( + (ADMIN_ID, "contract-admin", 1), + (NORMAL_ID, "contract-user", 0), + (EXPIRED_ID, "contract-expired", 0), + ): + await cursor.execute( + "INSERT INTO sys_user(id,username,password,super_admin,status,create_date,updater,creator," + "update_date) " + "VALUES(%s,%s,%s,%s,1,%s,%s,%s,%s) AS fixture ON DUPLICATE KEY UPDATE " + "username=fixture.username,password=fixture.password,super_admin=fixture.super_admin,status=1," + "create_date=fixture.create_date,updater=fixture.updater,creator=fixture.creator," + "update_date=fixture.update_date", + ( + identifier, + username, + "$2a$10$contract.fixture.not.used", + super_admin, + now, + ADMIN_ID, + ADMIN_ID, + now, + ), + ) + for identifier, user_id, token, expires in ( + (ADMIN_ID, ADMIN_ID, ADMIN_TOKEN, datetime(2099, 1, 1)), + (NORMAL_ID, NORMAL_ID, NORMAL_TOKEN, datetime(2099, 1, 1)), + (EXPIRED_ID, EXPIRED_ID, EXPIRED_TOKEN, datetime(2020, 1, 1)), + ): + await cursor.execute( + "INSERT INTO sys_user_token(id,user_id,token,expire_date,update_date,create_date) " + "VALUES(%s,%s,%s,%s,%s,%s) AS fixture ON DUPLICATE KEY UPDATE token=fixture.token," + "expire_date=fixture.expire_date,update_date=fixture.update_date,create_date=fixture.create_date", + (identifier, user_id, token, expires, now, now), + ) + + parameter_values: dict[str, str | None] = { + **crypto_params, + "server.secret": SERVER_SECRET, + "server.auth.enabled": "true", + "server.websocket": "ws://127.0.0.1:18080/xiaozhi/v1/", + "server.mqtt_gateway": "mqtt://127.0.0.1:1883", + "server.mqtt_manager_api": f"127.0.0.1:{mock_port}", + "server.mqtt_signature_key": MQTT_SIGNATURE_KEY, + "server.ota": "http://127.0.0.1:18082/xiaozhi/ota/", + "server.frontend.url": "http://127.0.0.1:8001", + } + for code, value in parameter_values.items(): + await cursor.execute( + "UPDATE sys_params SET param_value=%s,update_date=%s WHERE param_code=%s", + (value, now, code), + ) + + await cursor.execute( + "INSERT INTO ai_agent(id,user_id,agent_code,agent_name,system_prompt,summary_memory,chat_history_conf," + "lang_code,language,sort,creator,created_at,updater,updated_at) " + "VALUES(%s,%s,%s,%s,NULL,%s,1,%s,%s,7,%s,%s,%s,%s) AS fixture ON DUPLICATE KEY UPDATE " + "user_id=fixture.user_id,agent_code=fixture.agent_code,agent_name=fixture.agent_name," + "system_prompt=NULL,summary_memory=fixture.summary_memory,chat_history_conf=1," + "lang_code=fixture.lang_code,language=fixture.language,sort=7,creator=fixture.creator," + "created_at=fixture.created_at,updater=fixture.updater,updated_at=fixture.updated_at", + ( + "contract-agent-1", + NORMAL_ID, + "contract-agent-code", + "Contract Agent", + "fixture memory", + "zh-CN", + "zh", + NORMAL_ID, + now, + NORMAL_ID, + now, + ), + ) + await cursor.execute( + "INSERT INTO ai_device(id,user_id,mac_address,last_connected_at,auto_update,board,alias,agent_id," + "app_version,sort,creator,create_date,updater,update_date) " + "VALUES(%s,%s,%s,%s,1,%s,NULL,%s,%s,3,%s,%s,%s,%s) " + "AS fixture ON DUPLICATE KEY UPDATE user_id=fixture.user_id,mac_address=fixture.mac_address," + "last_connected_at=fixture.last_connected_at,auto_update=1,board=fixture.board,alias=NULL," + "agent_id=fixture.agent_id,app_version=fixture.app_version,sort=3,creator=fixture.creator," + "create_date=fixture.create_date,updater=fixture.updater,update_date=fixture.update_date", + ( + "contract-device-1", + NORMAL_ID, + "AA:BB:CC:DD:EE:01", + now, + "esp32s3", + "contract-agent-1", + "1.2.3", + NORMAL_ID, + now, + NORMAL_ID, + now, + ), + ) + await cursor.execute( + "INSERT INTO ai_model_provider(id,model_type,provider_code,name,fields,sort,creator,create_date," + "updater,update_date) " + "VALUES(%s,%s,%s,%s,CAST(%s AS JSON),9,%s,%s,%s,%s) AS fixture ON DUPLICATE KEY UPDATE " + "model_type=fixture.model_type,provider_code=fixture.provider_code,name=fixture.name," + "fields=fixture.fields,sort=9,creator=fixture.creator,create_date=fixture.create_date," + "updater=fixture.updater,update_date=fixture.update_date", + ( + "contract-provider", + "LLM", + "contract", + "Contract Provider", + '[{"key":"api_key","label":"API Key","type":"string"}]', + ADMIN_ID, + now, + ADMIN_ID, + now, + ), + ) + await cursor.execute( + "INSERT INTO ai_agent_correct_word_file(id,file_name,word_count,content,creator,created_at,updater," + "updated_at) " + "VALUES(%s,%s,2,%s,%s,%s,%s,%s) AS fixture ON DUPLICATE KEY UPDATE " + "file_name=fixture.file_name,word_count=2,content=fixture.content,creator=fixture.creator," + "created_at=fixture.created_at,updater=fixture.updater,updated_at=fixture.updated_at", + ( + "contract-words", + "合同词表.txt", + "小智,小志\nESP32,esp32", + NORMAL_ID, + now, + NORMAL_ID, + now, + ), + ) + await cursor.execute("DELETE FROM ai_agent_correct_word_item WHERE file_id=%s", ("contract-words",)) + for word_id, source, target in ( + ("contract-word-1", "小智", "小志"), + ("contract-word-2", "ESP32", "esp32"), + ): + await cursor.execute( + "INSERT INTO ai_agent_correct_word_item(id,file_id,source_word,target_word) VALUES(%s,%s,%s,%s)", + (word_id, "contract-words", source, target), + ) + await cursor.execute( + "INSERT INTO ai_agent_correct_word_mapping(id,agent_id,file_id,creator,created_at,updater,updated_at) " + "VALUES(%s,%s,%s,%s,%s,%s,%s) AS fixture ON DUPLICATE KEY UPDATE creator=fixture.creator," + "created_at=fixture.created_at,updater=fixture.updater,updated_at=fixture.updated_at", + ("contract-word-map", "contract-agent-1", "contract-words", NORMAL_ID, now, NORMAL_ID, now), + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + finally: + connection.close() + + +async def _sync_migration_timestamps(port: int) -> None: + """Copy baseline-generated timestamps for rows shared by both schemas. + + Several Liquibase seed changesets use the database current timestamp. The + Java and FastAPI schemas are migrated a few seconds apart, which would make + otherwise identical read responses differ. The isolated contract fixture + treats Java as the behavior baseline and aligns only the audit timestamps of + matching migration rows; it does not copy business data or change IDs. + """ + + connection = await asyncmy.connect(**_database_config(port, "manager_fastapi_test")) + try: + async with connection.cursor(DictCursor) as cursor: + for table in MIGRATION_TIMESTAMP_TABLES: + await cursor.execute( + f"UPDATE manager_fastapi_test.`{table}` AS target " # noqa: S608 + f"JOIN manager_java_test.`{table}` AS baseline ON baseline.id=target.id " + "SET target.create_date=baseline.create_date,target.update_date=baseline.update_date" + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + finally: + connection.close() + + +async def seed(port: int, mock_port: int) -> None: + crypto = await _query_params( + port, + "manager_java_test", + ("server.public_key", "server.private_key"), + ) + if not crypto.get("server.public_key") or not crypto.get("server.private_key"): + raise RuntimeError("Java baseline must be started once before seeding so its SM2 keypair exists") + for database in ("manager_java_test", "manager_fastapi_test"): + await _seed_database(port, database, crypto, mock_port) + await _sync_migration_timestamps(port) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--mysql-port", type=int, default=13316) + parser.add_argument("--mock-port", type=int, default=18084) + args = parser.parse_args() + asyncio.run(seed(args.mysql_port, args.mock_port)) + + +if __name__ == "__main__": + main() diff --git a/main/manager-api-fastapi/tests/compatibility/tcp_proxy.py b/main/manager-api-fastapi/tests/compatibility/tcp_proxy.py new file mode 100644 index 00000000..2653b3db --- /dev/null +++ b/main/manager-api-fastapi/tests/compatibility/tcp_proxy.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import argparse +import asyncio +import contextlib + + +async def _copy(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + while data := await reader.read(64 * 1024): + writer.write(data) + await writer.drain() + finally: + with contextlib.suppress(Exception): + writer.write_eof() + + +async def _handle( + downstream_reader: asyncio.StreamReader, + downstream_writer: asyncio.StreamWriter, + target_host: str, + target_port: int, +) -> None: + try: + upstream_reader, upstream_writer = await asyncio.open_connection(target_host, target_port) + except OSError: + downstream_writer.close() + await downstream_writer.wait_closed() + return + + try: + tasks = { + asyncio.create_task(_copy(downstream_reader, upstream_writer)), + asyncio.create_task(_copy(upstream_reader, downstream_writer)), + } + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + for task in pending: + task.cancel() + await asyncio.gather(*done, *pending, return_exceptions=True) + finally: + upstream_writer.close() + downstream_writer.close() + await asyncio.gather( + upstream_writer.wait_closed(), + downstream_writer.wait_closed(), + return_exceptions=True, + ) + + +async def _serve(listen_port: int, target_host: str, target_port: int) -> None: + server = await asyncio.start_server( + lambda reader, writer: _handle(reader, writer, target_host, target_port), + host="0.0.0.0", # noqa: S104 - isolated container-to-host test bridge + port=listen_port, + ) + async with server: + await server.serve_forever() + + +def main() -> None: + parser = argparse.ArgumentParser(description="Ephemeral TCP bridge for isolated container tests") + parser.add_argument("--listen-port", type=int, required=True) + parser.add_argument("--target-host", default="127.0.0.1") + parser.add_argument("--target-port", type=int, required=True) + args = parser.parse_args() + asyncio.run(_serve(args.listen_port, args.target_host, args.target_port)) + + +if __name__ == "__main__": + main() diff --git a/main/manager-api-fastapi/tests/compatibility/test_authenticated_route_runner.py b/main/manager-api-fastapi/tests/compatibility/test_authenticated_route_runner.py new file mode 100644 index 00000000..0ba4b84f --- /dev/null +++ b/main/manager-api-fastapi/tests/compatibility/test_authenticated_route_runner.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import json + +from tests.compatibility.authenticated_route_runner import ( + DYNAMIC_RESPONSES, + JAVA_MANIFEST, + MULTIPART_ROUTES, + NO_BODY_ROUTES, + OTA_DOWNLOAD_UUID_ROUTE, + RANDOM_PASSWORD_ROUTE, + SINGLE_CONSTRAINT_PAYLOADS, + _normalize_ota_download_uuid, + _normalize_random_password, + _valid_random_password, + _valid_uuid4, + _validate_dynamic_response, + build_cases, +) +from tests.compatibility.differential_runner import ContractRunner +from tests.compatibility.seed_contract_data import ( + ADMIN_TOKEN, + MIGRATION_TIMESTAMP_TABLES, + SERVER_SECRET, +) + + +def test_authenticated_route_cases_are_fresh_and_close_all_java_routes() -> None: + manifest = json.loads(JAVA_MANIFEST.read_text(encoding="utf-8")) + expected = {(str(route["method"]), str(route["path"])) for route in manifest["routes"]} + cases = build_cases() + + assert manifest["count"] == len(expected) == len(cases) == 154 + assert {case.key for case in cases} == expected + assert len({(case.method, case.path) for case in cases}) == 154 + + +def test_authenticated_route_cases_use_safe_auth_and_body_profiles() -> None: + for case in build_cases(): + headers = case.request_kwargs.get("headers", {}) + if case.auth == "database-token": + assert headers == {"Authorization": f"Bearer {ADMIN_TOKEN}"} + elif case.auth == "server-secret": + assert headers == {"Authorization": f"Bearer {SERVER_SECRET}"} + else: + assert headers == {} + + if case.method in {"GET", "DELETE"}: + assert "json" not in case.request_kwargs + elif case.key in MULTIPART_ROUTES | NO_BODY_ROUTES: + assert "json" not in case.request_kwargs + elif case.key in SINGLE_CONSTRAINT_PAYLOADS: + assert case.request_kwargs.get("json") == SINGLE_CONSTRAINT_PAYLOADS[case.key] + else: + assert case.request_kwargs.get("json") == {} + + +def test_array_body_validation_routes_are_not_hidden_by_safe_case_overrides() -> None: + cases = {case.key: case for case in build_cases()} + object_sent_to_array_routes = { + ("POST", "/admin/dict/data/delete"), + ("POST", "/admin/dict/type/delete"), + ("POST", "/admin/params/delete"), + ("PUT", "/admin/users/changeStatus/{status}"), + ("POST", "/agent/template/batch-remove"), + ("POST", "/correct-word/file/batch-delete"), + ("POST", "/models/provider/delete"), + ("POST", "/ttsVoice/delete"), + } + for key in object_sent_to_array_routes: + assert cases[key].request_kwargs.get("json") == {} + assert "json" not in cases[("DELETE", "/datasets/batch")].request_kwargs + + +def test_dynamic_response_normalizers_require_the_full_java_formats() -> None: + valid_password = "aA1!bcDEF234" # noqa: S105 - format-only fixture value + assert _valid_random_password(valid_password) + assert not _valid_random_password("aA1!short") + assert not _valid_random_password("aA1_bcDEF234") + assert not _valid_random_password("abcdefghijkl") + + valid_uuid = "123e4567-e89b-42d3-a456-426614174000" + assert _valid_uuid4(valid_uuid) + assert not _valid_uuid4("123e4567-e89b-12d3-a456-426614174000") + assert not _valid_uuid4(valid_uuid.upper()) + + password_body = {"code": 0, "msg": "success", "data": valid_password} + uuid_body = {"code": 0, "msg": "success", "data": valid_uuid} + assert _normalize_random_password(password_body) == { + "code": 0, + "msg": "success", + "data": "", + } + assert _normalize_ota_download_uuid(uuid_body) == { + "code": 0, + "msg": "success", + "data": "", + } + invalid = {"code": 0, "msg": "success", "data": "not-dynamic"} + assert _normalize_random_password(invalid) is invalid + assert _normalize_ota_download_uuid(invalid) is invalid + assert set(DYNAMIC_RESPONSES) == {RANDOM_PASSWORD_ROUTE, OTA_DOWNLOAD_UUID_ROUTE} + + +def test_identical_invalid_dynamic_values_fail_both_independent_format_checks() -> None: + runner = ContractRunner("http://java.invalid", "http://fastapi.invalid", "http://mock.invalid", 0) + try: + runner.add_custom("dynamic", "test", {"body": True}) + invalid = {"code": 0, "msg": "success", "data": "same-invalid-value"} + _validate_dynamic_response( + runner, + java_body=invalid, + fastapi_body=invalid, + validator=_valid_uuid4, + label="uuid_v4", + ) + result = runner.results[-1] + assert not result.passed + assert result.checks["body"] + assert not result.checks["java:uuid_v4"] + assert not result.checks["fastapi:uuid_v4"] + assert result.difference == ( + "invalid dynamic response format: java:uuid_v4, fastapi:uuid_v4" + ) + finally: + runner.close() + + +def test_migration_timestamp_fixture_is_limited_to_generated_seed_rows() -> None: + assert MIGRATION_TIMESTAMP_TABLES == ( + "ai_model_provider", + "sys_dict_data", + "sys_dict_type", + ) diff --git a/main/manager-api-fastapi/tests/compatibility/test_differential_reporting.py b/main/manager-api-fastapi/tests/compatibility/test_differential_reporting.py new file mode 100644 index 00000000..70c4c4e0 --- /dev/null +++ b/main/manager-api-fastapi/tests/compatibility/test_differential_reporting.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from tests.compatibility.differential_runner import ( + CapturedResponse, + _redact_report_value, + _response_summary, +) + + +def test_recursive_report_redaction_preserves_non_sensitive_shape() -> None: + source = { + "data": { + "private_key": "generated-private-key", + "server.secret": "fixture-secret", + "mqtt_signature_key": "fixture-signature", + "token": "fixture-token", + "token_count": 17, + "public_key": "public-material", + "params": [ + {"paramCode": "server.secret", "paramValue": "fixture-param-secret"}, + {"paramCode": "server.websocket", "paramValue": "ws://safe.example/ws"}, + ], + }, + "headers": {"authorization": "Bearer fixture"}, + } + + assert _redact_report_value(source) == { + "data": { + "private_key": "", + "server.secret": "", + "mqtt_signature_key": "", + "token": "", + "token_count": 17, + "public_key": "public-material", + "params": [ + {"paramCode": "server.secret", "paramValue": ""}, + {"paramCode": "server.websocket", "paramValue": "ws://safe.example/ws"}, + ], + }, + "headers": {"authorization": ""}, + } + + +def test_response_summary_never_embeds_sensitive_values_in_body_or_excerpt() -> None: + response = CapturedResponse( + status=200, + headers={"content-type": "application/json", "content-disposition": None, "content-length": None}, + body={"token": "fixture-token", "password": "fixture-password"}, + body_sha256="raw-response-digest", + ) + + summary = _response_summary(response, response.body) + + assert summary["body"] == {"token": "", "password": ""} + assert "fixture-token" not in summary["body_excerpt"] + assert "fixture-password" not in summary["body_excerpt"] diff --git a/main/manager-api-fastapi/tests/domain_support.py b/main/manager-api-fastapi/tests/domain_support.py new file mode 100644 index 00000000..b135b4af --- /dev/null +++ b/main/manager-api-fastapi/tests/domain_support.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import time +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + + +class FakeRedis: + def __init__(self) -> None: + self.values: dict[str, Any] = {} + self.hashes: dict[str, dict[str, Any]] = {} + self.expirations: dict[str, float] = {} + + def _purge(self, key: str) -> None: + expiry = self.expirations.get(key) + if expiry is not None and expiry <= time.monotonic(): + self.values.pop(key, None) + self.hashes.pop(key, None) + self.expirations.pop(key, None) + + async def get(self, key: str) -> Any: + self._purge(key) + return self.values.get(key) + + async def set( + self, + key: str, + value: Any, + *, + ex: int | None = None, + nx: bool = False, + ) -> bool | None: + self._purge(key) + if nx and key in self.values: + return None + self.values[key] = value + if ex is not None: + self.expirations[key] = time.monotonic() + ex + return True + + async def delete(self, *keys: str) -> int: + deleted = 0 + for key in keys: + deleted += int(key in self.values or key in self.hashes) + self.values.pop(key, None) + self.hashes.pop(key, None) + self.expirations.pop(key, None) + return deleted + + async def incr(self, key: str) -> int: + self._purge(key) + raw = self.values.get(key, b"0") + if isinstance(raw, bytes): + raw = raw.decode() + value = int(raw) + 1 + self.values[key] = str(value).encode() + return value + + async def expire(self, key: str, seconds: int) -> bool: + if key not in self.values and key not in self.hashes: + return False + self.expirations[key] = time.monotonic() + seconds + return True + + async def ttl(self, key: str) -> int: + self._purge(key) + if key not in self.values and key not in self.hashes: + return -2 + expiry = self.expirations.get(key) + return -1 if expiry is None else max(0, int(expiry - time.monotonic())) + + async def hget(self, key: str, field: str) -> Any: + self._purge(key) + return self.hashes.get(key, {}).get(field) + + async def hset(self, key: str, field: str, value: Any) -> int: + created = field not in self.hashes.setdefault(key, {}) + self.hashes[key][field] = value + return int(created) + + async def hdel(self, key: str, *fields: str) -> int: + values = self.hashes.get(key, {}) + deleted = 0 + for field in fields: + deleted += int(field in values) + values.pop(field, None) + return deleted + + +@asynccontextmanager +async def sqlite_session(statements: list[str]) -> AsyncIterator[AsyncSession]: + engine = create_async_engine( + "sqlite+aiosqlite:///:memory:", + poolclass=StaticPool, + connect_args={"check_same_thread": False}, + ) + async with engine.begin() as connection: + for statement in statements: + await connection.execute(text(statement)) + factory = async_sessionmaker(engine, expire_on_commit=False) + try: + async with factory() as session: + yield session + finally: + await engine.dispose() diff --git a/main/manager-api-fastapi/tests/fixtures/agent-mcp-aes-golden.json b/main/manager-api-fastapi/tests/fixtures/agent-mcp-aes-golden.json new file mode 100644 index 00000000..f562280f --- /dev/null +++ b/main/manager-api-fastapi/tests/fixtures/agent-mcp-aes-golden.json @@ -0,0 +1,17 @@ +[ + { + "agentId": "0123456789abcdef0123456789abcdef", + "key": "0123456789abcdef", + "token": "gGoKMxPZmy7exM0UfEVukLNhJhqNrpQqZp3dFvGxSP9A/OF5dw4QOxYI+5vaDP9k" + }, + { + "agentId": "agent-测试-0001", + "key": "short-key", + "token": "J2iqQ9MTPgcWyMBBnukLWoCoepQ3iL3ifRep9U9wHL404qPjkQbaki4gx+GF3Zju" + }, + { + "agentId": "ffffffffffffffffffffffffffffffff", + "key": "0123456789abcdef0123456789abcdef-extra", + "token": "aX8khOtYV5n7m6cJdkhIpC0crHYfz1FWAsD4HmCNBNfTlIy7qU1vVLOZBdNonA6r" + } +] diff --git a/main/manager-api-fastapi/tests/fixtures/sm2-c1c3c2-golden.json b/main/manager-api-fastapi/tests/fixtures/sm2-c1c3c2-golden.json new file mode 100644 index 00000000..cb3db194 --- /dev/null +++ b/main/manager-api-fastapi/tests/fixtures/sm2-c1c3c2-golden.json @@ -0,0 +1,8 @@ +{ + "algorithm": "SM2 C1C3C2 / sm2p256v1", + "publicKey": "04725b2e5df390575296c8753d883a88680a879152df47106e30156aba4ebfd354d77e1503edfaf7566ae26a34dfc40b4758f72aa850d82df0d8bfd29319f7419a", + "privateKey": "4a5a013f676be5ee700588593e50960470c9469cc9ebadade37149bc5a440228", + "plaintext": "A1b2C小智-2026", + "pythonCiphertext": "042f22fabd89b2012aff7141284850db1155c68f384237ea317ece255247f48014e12a4e48216ba9765a511a2548940125554b201d0828e0c676b74158e44488ebc0920e858c513661a2184eb64ac19e729d2e3684e78b3a267da0bd3a7886b060238a210683ad296cb5bb0cef29463ce8", + "javaCiphertext": "04c09af2b7c3bc9dad2610f7e22c166dad403e84d0a337033e0287bdc7fae5c6535055f34ecf908035df567c2fa7ed4cdb56046e277bb5f0c8782d2efbe789a00462c564788025b2de83bc07483dd46e83693d2944d3a28c189476ff2f64ebbcc8d4f8640f258a780e301e749a4f438964" +} diff --git a/main/manager-api-fastapi/tests/integration/__init__.py b/main/manager-api-fastapi/tests/integration/__init__.py new file mode 100644 index 00000000..bd58c0be --- /dev/null +++ b/main/manager-api-fastapi/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Tests requiring the repository's isolated MySQL and Redis runtime.""" diff --git a/main/manager-api-fastapi/tests/integration/test_isolated_runtime.py b/main/manager-api-fastapi/tests/integration/test_isolated_runtime.py new file mode 100644 index 00000000..d16d834b --- /dev/null +++ b/main/manager-api-fastapi/tests/integration/test_isolated_runtime.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import asyncio +import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any, cast + +import pytest +from redis.asyncio import Redis +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine + +from app.core.redis import close_redis, distributed_lock + +pytestmark = pytest.mark.integration + + +def _required_environment(name: str) -> str: + value = os.getenv(name) + if not value: + pytest.skip(f"{name} is required; use scripts/isolated-env.sh env") + return value + + +@pytest.fixture +async def engine() -> AsyncIterator[AsyncEngine]: + value = _required_environment("TEST_FASTAPI_DATABASE_URL") + selected = create_async_engine(value, pool_pre_ping=True) + try: + yield selected + finally: + await selected.dispose() + + +@pytest.fixture +async def isolated_redis() -> AsyncIterator[Redis]: + value = _required_environment("TEST_FASTAPI_REDIS_URL") + selected = Redis.from_url(value, decode_responses=True) + try: + if not await selected.ping(): + pytest.fail("isolated Redis did not answer PING") + yield selected + finally: + await selected.delete( + "contract:test:ttl", + "contract:test:persistent-sentinel", + "jobs:knowledge-document-status", + "jobs:contract-renewal", + ) + await selected.aclose() + + +@pytest.mark.asyncio +async def test_mysql_transaction_rollback(engine: AsyncEngine) -> None: + parameter_id = 9_007_199_254_740_980 + code = "contract.test.rollback" + async with engine.begin() as connection: + await connection.execute(text("DELETE FROM sys_params WHERE param_code=:code"), {"code": code}) + + with pytest.raises(RuntimeError, match="force rollback"): + async with engine.begin() as connection: + await connection.execute( + text( + "INSERT INTO sys_params(id,param_code,param_value,value_type,param_type) " + "VALUES(:id,:code,'before-rollback','string',1)" + ), + {"id": parameter_id, "code": code}, + ) + raise RuntimeError("force rollback") + + async with engine.connect() as connection: + count = await connection.scalar( + text("SELECT COUNT(*) FROM sys_params WHERE param_code=:code"), + {"code": code}, + ) + assert count == 0 + + +@pytest.mark.asyncio +async def test_mysql_select_for_update_serializes_concurrent_writers(engine: AsyncEngine) -> None: + parameter_id = 9_007_199_254_740_981 + code = "contract.test.for-update" + async with engine.begin() as connection: + await connection.execute(text("DELETE FROM sys_params WHERE param_code=:code"), {"code": code}) + await connection.execute( + text( + "INSERT INTO sys_params(id,param_code,param_value,value_type,param_type) " + "VALUES(:id,:code,'0','number',1)" + ), + {"id": parameter_id, "code": code}, + ) + + first_has_lock = asyncio.Event() + + async def increment(*, hold_lock: bool) -> None: + async with engine.begin() as connection: + result = await connection.execute( + text("SELECT param_value FROM sys_params WHERE param_code=:code FOR UPDATE"), + {"code": code}, + ) + current = int(result.scalar_one()) + if hold_lock: + first_has_lock.set() + await asyncio.sleep(0.2) + await connection.execute( + text("UPDATE sys_params SET param_value=:value WHERE param_code=:code"), + {"value": str(current + 1), "code": code}, + ) + + first = asyncio.create_task(increment(hold_lock=True)) + await first_has_lock.wait() + second = asyncio.create_task(increment(hold_lock=False)) + await asyncio.gather(first, second) + + async with engine.connect() as connection: + value = await connection.scalar( + text("SELECT param_value FROM sys_params WHERE param_code=:code"), + {"code": code}, + ) + assert value == "2" + + async with engine.begin() as connection: + await connection.execute(text("DELETE FROM sys_params WHERE param_code=:code"), {"code": code}) + + +@pytest.mark.asyncio +async def test_redis_ttl_expires_without_clearing_unrelated_keys(isolated_redis: Redis) -> None: + await isolated_redis.set("contract:test:persistent-sentinel", "preserved") + await isolated_redis.set("contract:test:ttl", "temporary", ex=1) + ttl = await isolated_redis.ttl("contract:test:ttl") + assert 0 < ttl <= 1 + await asyncio.sleep(1.1) + assert await isolated_redis.get("contract:test:ttl") is None + assert await isolated_redis.get("contract:test:persistent-sentinel") == "preserved" + + +@pytest.mark.asyncio +async def test_job_distributed_lock_allows_one_concurrent_execution( + isolated_redis: Redis, +) -> None: + await isolated_redis.delete("jobs:knowledge-document-status") + executions = 0 + entered = asyncio.Event() + + async def contender() -> bool: + nonlocal executions + async with distributed_lock("jobs:knowledge-document-status", 10) as acquired: + if not acquired: + return False + executions += 1 + entered.set() + await asyncio.sleep(0.2) + return True + + try: + first = asyncio.create_task(contender()) + await asyncio.wait_for(entered.wait(), timeout=2) + second = asyncio.create_task(contender()) + results = await asyncio.gather(first, second) + assert list(results) == [True, False] + assert executions == 1 + assert await isolated_redis.exists("jobs:knowledge-document-status") == 0 + finally: + await close_redis() + + +@pytest.mark.asyncio +async def test_job_distributed_lock_watchdog_renews_long_execution( + isolated_redis: Redis, +) -> None: + """A job longer than its initial lease must remain single-instance.""" + + key = "jobs:contract-renewal" + await isolated_redis.delete(key) + first_entered = asyncio.Event() + release_first = asyncio.Event() + + async def first_owner() -> bool: + async with distributed_lock(key, 1) as acquired: + assert acquired + first_entered.set() + await release_first.wait() + return acquired + + first = asyncio.create_task(first_owner()) + try: + await asyncio.wait_for(first_entered.wait(), timeout=2) + # Cross the original one-second lease. The watchdog should have extended + # it, so another worker still cannot enter. + await asyncio.sleep(1.25) + async with distributed_lock(key, 1) as second_acquired: + assert not second_acquired + release_first.set() + assert await first + assert await isolated_redis.exists(key) == 0 + finally: + release_first.set() + if not first.done(): + await first + await close_redis() + + +class _FakeFactory: + @asynccontextmanager + async def __call__(self) -> AsyncIterator[object]: + yield object() + + +@pytest.mark.asyncio +async def test_knowledge_job_function_is_single_instance( + isolated_redis: Redis, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from app.jobs import tasks + from app.services.knowledge import KnowledgeDocumentService + + await isolated_redis.delete("jobs:knowledge-document-status") + executions = 0 + entered = asyncio.Event() + + async def fake_sync(_: KnowledgeDocumentService) -> int: + nonlocal executions + executions += 1 + entered.set() + await asyncio.sleep(0.2) + return 1 + + monkeypatch.setattr(tasks, "get_session_factory", lambda: _FakeFactory()) + monkeypatch.setattr(KnowledgeDocumentService, "sync_running", fake_sync) + try: + first = asyncio.create_task(tasks.sync_running_knowledge_documents()) + await asyncio.wait_for(entered.wait(), timeout=2) + second = asyncio.create_task(tasks.sync_running_knowledge_documents()) + results = await asyncio.gather(first, second) + assert list(results) == [1, 0] + assert executions == 1 + finally: + await close_redis() + + +@pytest.mark.asyncio +async def test_redis_java_hash_default_ttl(isolated_redis: Redis) -> None: + from app.core.redis import java_hset + + try: + await java_hset("contract:test:java-hash", "field", {"id": 9_007_199_254_740_993}) + ttl = await isolated_redis.ttl("contract:test:java-hash") + assert 86_390 <= ttl <= 86_400 + raw = await cast(Any, isolated_redis.hget)("contract:test:java-hash", "field") + assert raw is not None + await isolated_redis.delete("contract:test:java-hash") + finally: + await close_redis() diff --git a/main/manager-api-fastapi/tests/java/AgentMcpCryptoInteropCli.java b/main/manager-api-fastapi/tests/java/AgentMcpCryptoInteropCli.java new file mode 100644 index 00000000..c54a6e32 --- /dev/null +++ b/main/manager-api-fastapi/tests/java/AgentMcpCryptoInteropCli.java @@ -0,0 +1,19 @@ +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; + +import xiaozhi.common.utils.AESUtils; + +public final class AgentMcpCryptoInteropCli { + private AgentMcpCryptoInteropCli() { + } + + public static void main(String[] args) throws Exception { + if (args.length != 2) { + throw new IllegalArgumentException("usage: AgentMcpCryptoInteropCli "); + } + byte[] digest = MessageDigest.getInstance("MD5").digest(args[0].getBytes(StandardCharsets.UTF_8)); + String json = "{\"agentId\": \"%s\"}".formatted(HexFormat.of().formatHex(digest)); + System.out.print(AESUtils.encrypt(args[1], json)); + } +} diff --git a/main/manager-api-fastapi/tests/java/RedisCodecVectors.java b/main/manager-api-fastapi/tests/java/RedisCodecVectors.java new file mode 100644 index 00000000..c1e209ee --- /dev/null +++ b/main/manager-api-fastapi/tests/java/RedisCodecVectors.java @@ -0,0 +1,63 @@ +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.data.redis.serializer.RedisSerializer; + +import cn.hutool.json.JSONObject; +import xiaozhi.modules.model.entity.ModelConfigEntity; +import xiaozhi.modules.sys.vo.SysDictDataItem; +import xiaozhi.modules.timbre.vo.TimbreDetailsVO; + +public final class RedisCodecVectors { + private RedisCodecVectors() {} + + private static void vector(String name, Object value) { + RedisSerializer serializer = RedisSerializer.json(); + byte[] bytes = serializer.serialize(value); + Object decoded = serializer.deserialize(bytes); + System.out.println(name + "\t" + new String(bytes, StandardCharsets.UTF_8) + + "\t" + (decoded == null ? "null" : decoded.getClass().getName())); + } + + public static void main(String[] args) { + vector("string", "hello"); + vector("integer", Integer.valueOf(7)); + vector("long", Long.valueOf(2147483648L)); + vector("boolean", Boolean.TRUE); + + Map map = new HashMap<>(); + map.put("text", "hello"); + map.put("number", Long.valueOf(2147483648L)); + map.put("list", new ArrayList<>(List.of("a", "b"))); + Map nested = new HashMap<>(); + nested.put("enabled", Boolean.TRUE); + map.put("nested", nested); + vector("map", map); + vector("list", new ArrayList<>(List.of("a", Long.valueOf(2147483648L), nested))); + vector("date", new Date(0)); + + TimbreDetailsVO timbre = new TimbreDetailsVO(); + timbre.setId("voice-1"); + timbre.setName("sample"); + timbre.setSort(2L); + vector("pojo", timbre); + + ModelConfigEntity model = new ModelConfigEntity(); + model.setId("model-1"); + model.setModelType("LLM"); + JSONObject config = new JSONObject(); + config.set("type", "openai"); + config.set("api_key", "secret"); + model.setConfigJson(config); + vector("model-pojo", model); + + SysDictDataItem item = new SysDictDataItem(); + item.setName("China"); + item.setKey("+86"); + vector("dict-list", new ArrayList<>(List.of(item))); + } +} diff --git a/main/manager-api-fastapi/tests/java/Sm2InteropCli.java b/main/manager-api-fastapi/tests/java/Sm2InteropCli.java new file mode 100644 index 00000000..1ff516b2 --- /dev/null +++ b/main/manager-api-fastapi/tests/java/Sm2InteropCli.java @@ -0,0 +1,18 @@ +import xiaozhi.common.utils.SM2Utils; + +/** Tiny subprocess boundary used by the Python/Java SM2 interoperability tests. */ +public final class Sm2InteropCli { + private Sm2InteropCli() { + } + + public static void main(String[] args) { + if (args.length != 3) { + throw new IllegalArgumentException("usage: encrypt | decrypt <private-key> <ciphertext>"); + } + switch (args[0]) { + case "encrypt" -> System.out.print(SM2Utils.encrypt(args[1], args[2])); + case "decrypt" -> System.out.print(SM2Utils.decrypt(args[1], args[2])); + default -> throw new IllegalArgumentException("unknown operation: " + args[0]); + } + } +} diff --git a/main/manager-api-fastapi/tests/test_agent_compatibility.py b/main/manager-api-fastapi/tests/test_agent_compatibility.py new file mode 100644 index 00000000..56b8f385 --- /dev/null +++ b/main/manager-api-fastapi/tests/test_agent_compatibility.py @@ -0,0 +1,678 @@ +from __future__ import annotations + +import copy +import json +import subprocess +from collections.abc import Awaitable +from datetime import datetime +from pathlib import Path +from typing import Any, cast +from urllib.parse import quote_plus + +import httpx +import pytest +import respx +from fastapi import Request +from fastapi.routing import APIRoute +from pydantic import ValidationError +from sqlalchemy import text +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.core.errors import AppError +from app.core.redis import JavaRedisCodec +from app.core.responses import error_response +from app.core.security import AuthUser +from app.integrations.llm import openai_completion +from app.integrations.mcp import build_agent_mcp_address, encrypt_agent_token +from app.integrations.voiceprint import VoicePrintClient, VoicePrintEndpoint, VoicePrintIntegrationError +from app.repositories.agent import AgentRepository +from app.routers.agent import router +from app.routers.agent import update_template as update_template_route +from app.schemas.agent import ( + AgentChatHistoryReport, + AgentCreate, + AgentSnapshotPage, + AgentSnapshotRestore, + AgentTemplate, + AgentUpdate, + FunctionInfo, +) +from app.services.agent import ( + SECRET_PLACEHOLDER, + AgentService, + _cached_datetime, + _changed_snapshot_fields, + _parse_snapshot_data, + _preserve_snapshot_sensitive, + _redact_snapshot_data, + _snapshot_state_token, +) +from tests.domain_support import sqlite_session + +TARGET_ROOT = Path(__file__).resolve().parents[1] +REPOSITORY_ROOT = TARGET_ROOT.parents[1] +JAVA_BIN = REPOSITORY_ROOT / ".runtime" / "jdk" / "bin" +MCP_VECTOR_PATH = TARGET_ROOT / "tests" / "fixtures" / "agent-mcp-aes-golden.json" + +EXPECTED_AGENT_ROUTES = { + ("POST", "/agent/chat-history/report"), + ("POST", "/agent/chat-history/getDownloadUrl/{agentId}/{sessionId}"), + ("GET", "/agent/chat-history/download/{uuid}/current"), + ("GET", "/agent/chat-history/download/{uuid}/previous"), + ("GET", "/agent/template/page"), + ("POST", "/agent/template/batch-remove"), + ("GET", "/agent/template/{id}"), + ("POST", "/agent/template"), + ("PUT", "/agent/template"), + ("DELETE", "/agent/template/{id}"), + ("POST", "/agent/voice-print"), + ("PUT", "/agent/voice-print"), + ("DELETE", "/agent/voice-print/{id}"), + ("GET", "/agent/voice-print/list/{id}"), + ("GET", "/agent/mcp/address/{agentId}"), + ("GET", "/agent/mcp/tools/{agentId}"), + ("GET", "/agent/tag/list"), + ("POST", "/agent/tag"), + ("DELETE", "/agent/tag/{id}"), + ("POST", "/agent/audio/{audioId}"), + ("GET", "/agent/play/{uuid}"), + ("PUT", "/agent/saveMemory/{macAddress}"), + ("POST", "/agent/chat-summary/{sessionId}/save"), + ("POST", "/agent/chat-title/{sessionId}/generate"), + ("GET", "/agent/all"), + ("GET", "/agent/list"), + ("GET", "/agent/template"), + ("GET", "/agent/{agentId}/snapshots"), + ("GET", "/agent/{agentId}/snapshots/{snapshotId}"), + ("POST", "/agent/{agentId}/snapshots/{snapshotId}/restore"), + ("DELETE", "/agent/{agentId}/snapshots/{snapshotId}"), + ("GET", "/agent/{id}/sessions"), + ("GET", "/agent/{id}/chat-history/{sessionId}"), + ("GET", "/agent/{id}/chat-history/user"), + ("GET", "/agent/{id}/chat-history/audio"), + ("GET", "/agent/{id}/tags"), + ("PUT", "/agent/{id}/tags"), + ("POST", "/agent"), + ("PUT", "/agent/{id}"), + ("DELETE", "/agent/{id}"), + ("GET", "/agent/{id}"), +} + +AGENT_SCHEMA = [ + """ + CREATE TABLE ai_agent ( + id TEXT PRIMARY KEY, user_id INTEGER, agent_code TEXT, agent_name TEXT, asr_model_id TEXT, + vad_model_id TEXT, llm_model_id TEXT, slm_model_id TEXT, vllm_model_id TEXT, tts_model_id TEXT, + tts_voice_id TEXT, tts_language TEXT, tts_volume INTEGER, tts_rate INTEGER, tts_pitch INTEGER, + mem_model_id TEXT, intent_model_id TEXT, chat_history_conf INTEGER, system_prompt TEXT, + summary_memory TEXT, lang_code TEXT, language TEXT, sort INTEGER, creator INTEGER, created_at DATETIME, + updater INTEGER, updated_at DATETIME + ) + """, + """ + CREATE TABLE ai_agent_template ( + id TEXT PRIMARY KEY, agent_code TEXT, agent_name TEXT, asr_model_id TEXT, vad_model_id TEXT, + llm_model_id TEXT, vllm_model_id TEXT, tts_model_id TEXT, tts_voice_id TEXT, tts_language TEXT, + tts_volume INTEGER, tts_rate INTEGER, tts_pitch INTEGER, mem_model_id TEXT, intent_model_id TEXT, + chat_history_conf INTEGER, system_prompt TEXT, summary_memory TEXT, lang_code TEXT, language TEXT, + sort INTEGER, creator INTEGER, created_at DATETIME, updater INTEGER, updated_at DATETIME + ) + """, + """ + CREATE TABLE ai_model_config ( + id TEXT PRIMARY KEY, model_type TEXT, model_code TEXT, model_name TEXT, is_default INTEGER, + is_enabled INTEGER, config_json TEXT, sort INTEGER + ) + """, + "CREATE TABLE ai_model_provider (id TEXT PRIMARY KEY, provider_code TEXT, fields TEXT)", + "CREATE TABLE ai_tts_voice (id TEXT PRIMARY KEY, tts_model_id TEXT, tts_voice TEXT, name TEXT, languages TEXT)", + "CREATE TABLE ai_voice_clone (id TEXT PRIMARY KEY, name TEXT, languages TEXT)", + """ + CREATE TABLE ai_agent_plugin_mapping ( + id INTEGER PRIMARY KEY, agent_id TEXT, plugin_id TEXT, param_info TEXT, UNIQUE(agent_id, plugin_id) + ) + """, + """ + CREATE TABLE ai_agent_context_provider ( + id TEXT PRIMARY KEY, agent_id TEXT, context_providers TEXT, creator INTEGER, created_at DATETIME, + updater INTEGER, updated_at DATETIME + ) + """, + """ + CREATE TABLE ai_agent_correct_word_mapping ( + id TEXT PRIMARY KEY, agent_id TEXT, file_id TEXT, creator INTEGER, created_at DATETIME, + updater INTEGER, updated_at DATETIME, UNIQUE(agent_id, file_id) + ) + """, + """ + CREATE TABLE ai_agent_tag ( + id TEXT PRIMARY KEY, tag_name TEXT, sort INTEGER, deleted INTEGER, creator INTEGER, + created_at DATETIME, updater INTEGER, updated_at DATETIME + ) + """, + """ + CREATE TABLE ai_agent_tag_relation ( + id TEXT PRIMARY KEY, agent_id TEXT, tag_id TEXT, sort INTEGER, creator INTEGER, + created_at DATETIME, updater INTEGER, updated_at DATETIME + ) + """, + """ + CREATE TABLE ai_agent_snapshot ( + id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, user_id INTEGER, version_no INTEGER NOT NULL, + snapshot_data TEXT NOT NULL, changed_fields TEXT, source TEXT, restore_from_snapshot_id TEXT, + restore_from_version_no INTEGER, creator INTEGER, created_at DATETIME, redaction_version INTEGER, + UNIQUE(agent_id, version_no) + ) + """, + """ + CREATE TABLE ai_device ( + id TEXT PRIMARY KEY, user_id INTEGER, mac_address TEXT, agent_id TEXT, last_connected_at DATETIME + ) + """, + "CREATE TABLE ai_device_address_book (mac_address TEXT, target_mac TEXT)", + """ + CREATE TABLE ai_agent_chat_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, mac_address TEXT, agent_id TEXT, session_id TEXT, + chat_type INTEGER, content TEXT, audio_id TEXT, created_at DATETIME + ) + """, + """ + CREATE TABLE ai_agent_chat_title ( + id TEXT PRIMARY KEY, session_id TEXT, title TEXT, created_at DATETIME, updated_at DATETIME + ) + """, + "CREATE TABLE ai_agent_chat_audio (id TEXT PRIMARY KEY, audio BLOB)", +] + + +def normal_user(user_id: int = 7) -> AuthUser: + return AuthUser( + id=user_id, + username="agent-user", + super_admin=0, + status=1, + token="test-token", # noqa: S106 - isolated authentication fixture + row={"id": user_id}, + ) + + +async def java_error_payload(awaitable: Awaitable[Any]) -> dict[str, Any]: + with pytest.raises(AppError) as captured: + await awaitable + request = Request({"type": "http", "method": "GET", "path": "/agent/test", "headers": []}) + payload = json.loads(bytes(error_response(request, captured.value.code, captured.value.message).body)) + assert isinstance(payload, dict) + return cast(dict[str, Any], payload) + + +@pytest.fixture(scope="module") +def mcp_vectors() -> list[dict[str, str]]: + value = json.loads(MCP_VECTOR_PATH.read_text(encoding="utf-8")) + assert isinstance(value, list) + return value + + +@pytest.fixture(scope="module") +def java_mcp_classpath(tmp_path_factory: pytest.TempPathFactory) -> Path: + classes = tmp_path_factory.mktemp("agent-mcp-java") + subprocess.run( # noqa: S603 + [ + str(JAVA_BIN / "javac"), + "-d", + str(classes), + str( + REPOSITORY_ROOT + / "main" + / "manager-api" + / "src" + / "main" + / "java" + / "xiaozhi" + / "common" + / "utils" + / "AESUtils.java" + ), + str(TARGET_ROOT / "tests" / "java" / "AgentMcpCryptoInteropCli.java"), + ], + check=True, + capture_output=True, + text=True, + ) + return classes + + +def test_agent_router_matches_all_six_java_controllers() -> None: + routes = [cast(APIRoute, route) for route in router.routes] + actual = { + (method, route.path) + for route in routes + for method in sorted(getattr(route, "methods", set()) or set()) + } + assert actual == EXPECTED_AGENT_ROUTES + assert len(actual) == 41 + + paths = [route.path for route in routes] + assert paths.index("/agent/template/page") < paths.index("/agent/template/{id}") + assert paths.index("/agent/list") < paths.index("/agent/{id}") + assert paths.index("/agent/{agentId}/snapshots") < paths.index("/agent/{id}") + assert paths.index("/agent/{id}/chat-history/user") < paths.index("/agent/{id}/chat-history/{sessionId}") + assert paths.index("/agent/{id}/chat-history/audio") < paths.index("/agent/{id}/chat-history/{sessionId}") + + +@pytest.mark.parametrize( + ("model", "payload", "message"), + [ + (AgentCreate, {"agentName": " "}, "智能体名称不能为空"), + ( + AgentChatHistoryReport, + {"macAddress": " ", "sessionId": "session", "chatType": 1, "content": "content"}, + "不能为空", + ), + ( + AgentChatHistoryReport, + {"macAddress": "mac", "sessionId": "session", "chatType": None, "content": "content"}, + "不能为空", + ), + (AgentSnapshotRestore, {"currentStateToken": ""}, "不能为空"), + ], +) +def test_agent_not_blank_constraints_keep_java_messages( + model: type[Any], payload: dict[str, Any], message: str +) -> None: + with pytest.raises(ValidationError) as captured: + model.model_validate(payload) + assert captured.value.errors()[0]["msg"] == message + + +@pytest.mark.asyncio +async def test_agent_missing_record_permission_and_validation_order_match_java() -> None: + missing = "contract-missing-agent" + snapshot_error = {"code": 500, "msg": "没有权限访问该智能体快照", "data": None} + permission_error = {"code": 10169, "msg": "您没有权限操作该记录", "data": None} + + async with sqlite_session(AGENT_SCHEMA) as session: + service = AgentService(session, normal_user()) + + assert await java_error_payload(service.snapshot_page(missing, AgentSnapshotPage())) == snapshot_error + assert await java_error_payload(service.snapshot_detail(missing, "missing-snapshot")) == snapshot_error + assert await java_error_payload( + service.restore_snapshot(missing, "missing-snapshot", "valid-state-token") + ) == snapshot_error + assert await java_error_payload(service.delete_snapshot(missing, "missing-snapshot")) == snapshot_error + + assert await java_error_payload(service.sessions(missing, None, None)) == permission_error + assert await java_error_payload(service.audio_content("missing-audio")) == permission_error + assert await java_error_payload(service.agent_tags(missing)) == permission_error + assert await java_error_payload(service.save_agent_tags(missing, None, None)) == permission_error + + await session.execute( + text("INSERT INTO ai_agent (id,user_id,agent_name) VALUES ('owned-agent',7,'Owned')") + ) + await session.commit() + assert await java_error_payload(service.sessions("owned-agent", None, None)) == { + "code": 500, + "msg": "服务器内部异常", + "data": None, + } + assert await java_error_payload(service.sessions("owned-agent", "bad", "10")) == { + "code": 500, + "msg": "服务器内部异常", + "data": None, + } + assert await service.sessions("owned-agent", "1", "10") == {"list": [], "total": 0} + + +@pytest.mark.asyncio +async def test_template_update_without_id_keeps_java_generic_error() -> None: + request = Request({"type": "http", "method": "PUT", "path": "/agent/template", "headers": []}) + async with sqlite_session(AGENT_SCHEMA) as session: + response = await update_template_route(AgentTemplate(), request, session, normal_user()) + assert json.loads(bytes(response.body)) == {"code": 500, "msg": "服务器内部异常", "data": None} + + +def test_mcp_tokens_match_static_and_live_java_golden_vectors( + mcp_vectors: list[dict[str, str]], java_mcp_classpath: Path +) -> None: + for vector in mcp_vectors: + python_token = encrypt_agent_token(vector["agentId"], vector["key"]) + java_token = subprocess.run( # noqa: S603 + [ + str(JAVA_BIN / "java"), + "-cp", + str(java_mcp_classpath), + "AgentMcpCryptoInteropCli", + vector["agentId"], + vector["key"], + ], + check=True, + capture_output=True, + text=True, + ).stdout + assert python_token == vector["token"] + assert java_token == vector["token"] + + +def test_mcp_address_preserves_java_path_scheme_and_form_encoding(mcp_vectors: list[dict[str, str]]) -> None: + vector = mcp_vectors[0] + endpoint = f"https://broker.example/xiaozhi/v1/?key={vector['key']}" + assert build_agent_mcp_address(endpoint, vector["agentId"]) == ( + f"wss://broker.example/xiaozhi/v1/mcp/?token={quote_plus(vector['token'], safe='')}" + ) + assert build_agent_mcp_address("null", vector["agentId"]) is None + + +def _snapshot_fixture() -> dict[str, Any]: + return { + "agentCode": "AGT_1", + "agentName": "agent", + "summaryMemory": None, + "functions": [ + { + "pluginId": "plugin-a", + "paramInfo": { + "apiKey": "function-secret", + "safe": "visible", + "nested": {"password": "nested-secret"}, + "webhookUrl": "https://hooks.slack.com/services/T/B/slack-secret?key=query-secret&safe=1", + }, + } + ], + "contextProviders": [ + { + "url": "https://user:pass@example.com/context?api_key=url-secret&safe=1#token=fragment-secret", + "headers": {"Authorization": "Bearer header-secret", "X-Safe": "visible"}, + } + ], + "correctWordFileIds": ["file-b", "file-a"], + "tagNames": ["tag-b", "tag-a"], + "tags": [{"id": "tag-id", "tagName": "tag-a", "sort": 0}], + } + + +def test_snapshot_redaction_is_secret_free_and_reversible() -> None: + current = _parse_snapshot_data(_snapshot_fixture()) + assert current is not None + redacted = _redact_snapshot_data(current) + assert redacted is not None + wire = json.dumps(redacted, ensure_ascii=False) + for secret in ( + "function-secret", + "nested-secret", + "slack-secret", + "query-secret", + "user:pass", + "url-secret", + "fragment-secret", + "header-secret", + ): + assert secret not in wire + assert wire.count(SECRET_PLACEHOLDER) >= 8 + assert "safe=1" in wire + assert "visible" in wire + + restored = _preserve_snapshot_sensitive(redacted, current) + assert restored == current + + +def test_snapshot_state_token_and_change_detection_match_java_normalization() -> None: + first = _parse_snapshot_data(_snapshot_fixture()) + assert first is not None + second = copy.deepcopy(first) + second["functions"][0]["paramInfo"]["apiKey"] = "rotated-function-secret" + second["contextProviders"][0]["headers"]["Authorization"] = "Bearer rotated-header-secret" + second["correctWordFileIds"].reverse() + second["tagNames"].reverse() + second["summaryMemory"] = " " + assert _snapshot_state_token(first) == _snapshot_state_token(second) + assert _changed_snapshot_fields(first, second) == [] + + second["agentName"] = "changed" + assert _changed_snapshot_fields(first, second) == ["agentName"] + + +def test_snapshot_url_redaction_keeps_public_parameters_and_rejects_cross_origin_restore() -> None: + current = _parse_snapshot_data(_snapshot_fixture()) + assert current is not None + redacted = _redact_snapshot_data(current) + assert redacted is not None + provider = redacted["contextProviders"][0] + assert provider["url"] == ( + f"https://{SECRET_PLACEHOLDER}@example.com/context?api_key={SECRET_PLACEHOLDER}&safe=1" + f"#token={SECRET_PLACEHOLDER}" + ) + + provider["url"] = provider["url"].replace("example.com", "attacker.example") + with pytest.raises(Exception, match="URL 标识"): + _preserve_snapshot_sensitive(redacted, current) + + +def test_function_info_accepts_java_string_or_map_param_info() -> None: + from_string = FunctionInfo.model_validate({"pluginId": "p", "paramInfo": '{"answer":42}'}) + from_map = FunctionInfo.model_validate({"pluginId": "p", "paramInfo": {"answer": 42}}) + assert from_string.param_info == from_map.param_info == {"answer": 42} + + +def test_java_date_redis_wire_round_trip() -> None: + value = datetime(2026, 7, 20, 12, 34, 56, 789000) + encoded = JavaRedisCodec.encode(value) + assert json.loads(encoded) == ["java.util.Date", 1784522096789] + assert _cached_datetime(JavaRedisCodec.decode(encoded)) == value + + +@pytest.mark.asyncio +async def test_snapshot_repository_allocates_monotonic_versions_and_prunes() -> None: + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + factory = async_sessionmaker(engine, expire_on_commit=False) + async with engine.begin() as connection: + await connection.execute( + text( + "CREATE TABLE ai_agent_snapshot (" + "id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, user_id INTEGER, version_no INTEGER NOT NULL," + "snapshot_data TEXT NOT NULL, changed_fields TEXT, source TEXT, restore_from_snapshot_id TEXT," + "restore_from_version_no INTEGER, creator INTEGER, created_at DATETIME, redaction_version INTEGER," + "UNIQUE(agent_id, version_no))" + ) + ) + async with factory() as session: + repository = AgentRepository(session) + for index in range(1, 5): + assert ( + await repository.insert_snapshot_next_version( + { + "id": f"snapshot-{index}", + "agent_id": "agent", + "user_id": 1, + "snapshot_data": "{}", + "changed_fields": "[]", + "source": "config", + "restore_from_snapshot_id": None, + "restore_from_version_no": None, + "creator": 1, + "created_at": datetime(2026, 7, 20, 12, index), + "redaction_version": 2, + } + ) + == 1 + ) + assert await repository.snapshot_max_version("agent") == 4 + assert await repository.prune_snapshots("agent", 2) == 2 + rows = await repository.fetch_all( + "SELECT id,version_no FROM ai_agent_snapshot WHERE agent_id=:id ORDER BY version_no", {"id": "agent"} + ) + assert rows == [{"id": "snapshot-3", "version_no": 3}, {"id": "snapshot-4", "version_no": 4}] + await engine.dispose() + + +@pytest.mark.asyncio +async def test_agent_create_update_restore_rollback_and_delete_are_transactional() -> None: + async with sqlite_session(AGENT_SCHEMA) as session: + await session.execute( + text( + "INSERT INTO ai_agent_template " + "(id,agent_name,llm_model_id,mem_model_id,system_prompt,summary_memory,lang_code,language,sort) " + "VALUES ('template','默认模板','llm-default','Memory_local_short','默认提示词','初始记忆'," + "'zh_CN','中文',1)" + ) + ) + await session.execute( + text( + "INSERT INTO ai_model_config " + "(id,model_type,model_name,is_default,is_enabled,config_json,sort) " + "VALUES ('llm-default','LLM','默认模型',1,1,:config,1)" + ), + { + "config": json.dumps( + { + "type": "openai", + "base_url": "https://llm.invalid/v1", + "api_key": "mock-only", + "model_name": "mock", + } + ) + }, + ) + for provider_id in ("SYSTEM_PLUGIN_MUSIC", "SYSTEM_PLUGIN_WEATHER", "SYSTEM_PLUGIN_NEWS_NEWSNOW"): + await session.execute( + text("INSERT INTO ai_model_provider (id,provider_code,fields) VALUES (:id,:id,:fields)"), + { + "id": provider_id, + "fields": json.dumps([{"key": "region", "default": "cn"}]), + }, + ) + await session.commit() + + service = AgentService(session, normal_user()) + agent_id = await service.create_agent(AgentCreate(agent_name="测试智能体")) + detail = await service.agent_detail(agent_id) + assert detail["agent_name"] == "测试智能体" + assert detail["chat_history_conf"] == 2 + assert detail["current_version_no"] == 1 + assert {item["plugin_id"] for item in detail["functions"]} == { + "SYSTEM_PLUGIN_MUSIC", + "SYSTEM_PLUGIN_WEATHER", + "SYSTEM_PLUGIN_NEWS_NEWSNOW", + } + + await service.update_agent( + agent_id, + AgentUpdate.model_validate( + { + "agentName": "更新后的智能体", + "functions": [{"pluginId": "SYSTEM_PLUGIN_WEATHER", "paramInfo": {"region": "shanghai"}}], + "contextProviders": [{"url": "https://context.example/v1", "headers": {"X-Safe": "yes"}}], + "correctWordFileIds": ["words-1"], + "tagNames": ["家庭"], + } + ), + ) + updated = await service.agent_detail(agent_id) + assert updated["agent_name"] == "更新后的智能体" + assert [item["plugin_id"] for item in updated["functions"]] == ["SYSTEM_PLUGIN_WEATHER"] + assert updated["context_providers"] == [ + {"url": "https://context.example/v1", "headers": {"X-Safe": "yes"}} + ] + assert updated["correct_word_file_ids"] == ["words-1"] + assert updated["current_version_no"] == 2 + assigned_tags = await service.agent_tags(agent_id) + assert len(assigned_tags) == 1 + assert assigned_tags[0]["tagName"] == "家庭" + + snapshot_rows = await service.repo.fetch_all( + "SELECT id,version_no,source FROM ai_agent_snapshot WHERE agent_id=:id ORDER BY version_no", + {"id": agent_id}, + ) + assert [(row["version_no"], row["source"]) for row in snapshot_rows] == [(1, "initial"), (2, "config")] + + with pytest.raises(AppError): + await service.update_agent( + agent_id, + AgentUpdate.model_validate( + {"agentName": "不应提交", "llmModelId": "missing-model", "functions": []} + ), + ) + after_rollback = await service.agent_detail(agent_id) + assert after_rollback["agent_name"] == "更新后的智能体" + assert [item["plugin_id"] for item in after_rollback["functions"]] == ["SYSTEM_PLUGIN_WEATHER"] + assert after_rollback["current_version_no"] == 2 + + first_snapshot = snapshot_rows[0] + preview = await service.snapshot_detail(agent_id, str(first_snapshot["id"])) + await service.restore_snapshot(agent_id, str(first_snapshot["id"]), str(preview["currentStateToken"])) + restored = await service.agent_detail(agent_id) + assert restored["agent_name"] == "测试智能体" + assert restored["current_version_no"] == 3 + assert {item["plugin_id"] for item in restored["functions"]} == { + "SYSTEM_PLUGIN_MUSIC", + "SYSTEM_PLUGIN_WEATHER", + "SYSTEM_PLUGIN_NEWS_NEWSNOW", + } + assert await service.agent_tags(agent_id) == [] + assert await service.repo.scalar("SELECT COUNT(*) FROM ai_agent_tag") == 1 + + await service.delete_agent(agent_id) + assert await service.repo.get_agent(agent_id) is None + assert await service.repo.scalar( + "SELECT COUNT(*) FROM ai_agent_snapshot WHERE agent_id=:id", {"id": agent_id} + ) == 0 + assert await service.repo.scalar("SELECT COUNT(*) FROM ai_agent_tag") == 1 + + +@pytest.mark.asyncio +async def test_voiceprint_mock_validates_java_multipart_and_error_mapping() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + body = await request.aread() + if request.url.path == "/voiceprint/identify": + assert b'name="speaker_ids"' in body + assert b"speaker-a,speaker-b" in body + assert b'filename="VoicePrint.WAV"' in body + return httpx.Response(200, json={"speaker_id": "speaker-a", "score": 0.75}) + if request.url.path == "/voiceprint/register": + assert b'name="speaker_id"' in body + return httpx.Response(200, text="true") + return httpx.Response(503, text="unavailable") + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + voiceprint = VoicePrintClient("http://voiceprint.example/service?key=test-secret", client=client) + assert await voiceprint.identify(["speaker-a", "speaker-b"], b"audio") == ("speaker-a", 0.75) + await voiceprint.register("speaker-a", b"audio") + with pytest.raises(VoicePrintIntegrationError) as error: + await voiceprint.cancel("speaker-a") + assert error.value.code == 10089 + + assert all(request.headers["Authorization"] == "Bearer test-secret" for request in requests) + assert VoicePrintEndpoint.parse("https://voiceprint.example:9443/path?key=secret") == VoicePrintEndpoint( + "https://voiceprint.example:9443", "Bearer secret" + ) + + +@pytest.mark.asyncio +@respx.mock +async def test_llm_mock_validates_openai_request_and_thinking_policy() -> None: + route = respx.post("https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions").mock( + return_value=httpx.Response(200, json={"choices": [{"message": {"content": "summary"}}]}) + ) + result = await openai_completion( + { + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_key": "mock-key", + "model_name": "mock-model", + }, + "prompt", + temperature=0.2, + max_tokens=2000, + timeout=1, + ) + assert result == "summary" + request = route.calls.last.request + payload = json.loads(request.content) + assert payload == { + "model": "mock-model", + "messages": [{"role": "user", "content": "prompt"}], + "temperature": 0.2, + "max_tokens": 2000, + "enable_thinking": False, + } + assert request.headers["Authorization"] == "Bearer mock-key" diff --git a/main/manager-api-fastapi/tests/test_compatibility_document.py b/main/manager-api-fastapi/tests/test_compatibility_document.py new file mode 100644 index 00000000..1537702e --- /dev/null +++ b/main/manager-api-fastapi/tests/test_compatibility_document.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path + +TARGET_ROOT = Path(__file__).resolve().parents[1] +REPOSITORY_ROOT = TARGET_ROOT.parents[1] +DOCUMENT = REPOSITORY_ROOT / "docs" / "manager-api-fastapi-compatibility.md" +RENDERER = TARGET_ROOT / "scripts" / "render_compatibility_doc.py" +JAVA_MANIFEST = TARGET_ROOT / "compatibility" / "java-routes.json" +ROUTE_SURFACE_RESULTS = TARGET_ROOT / "compatibility" / "route-surface-results.json" +AUTHENTICATED_ROUTE_RESULTS = TARGET_ROOT / "compatibility" / "authenticated-route-results.json" + + +def test_compatibility_document_is_current() -> None: + rendered = subprocess.run( # noqa: S603 + [sys.executable, str(RENDERER)], + check=True, + capture_output=True, + text=True, + ).stdout + assert DOCUMENT.read_text(encoding="utf-8") == rendered + + +def test_compatibility_document_has_every_java_route_exactly_once() -> None: + document = DOCUMENT.read_text(encoding="utf-8") + routes = json.loads(JAVA_MANIFEST.read_text(encoding="utf-8"))["routes"] + rows = re.findall(r"^\| J\d{3} \|.*$", document, flags=re.MULTILINE) + assert len(rows) == 154 == len(routes) + for route, row in zip(routes, rows, strict=True): + assert f"`{route['method']} {route['path']}`" in row + assert f"`{route['controller']}.{route['handler']}`" in row + assert "结构✓" in row + assert "请求面差分✓1" in row + assert "认证业务面差分✓1" in row + assert "领域✓" in row + assert "差分" in row + + +def test_document_separates_structure_domain_and_actual_differential_evidence() -> None: + document = DOCUMENT.read_text(encoding="utf-8") + assert "154/154(100%)" in document + assert "49/49 checks 通过、0 失败、0 跳过" in document + assert "154/154 通过、0 失败、0 跳过" in document + assert "authenticated-route-results.json" in document + assert "21/154" in document + assert "188" in document and "140" in document + assert "24 个、154 条映射" in document + assert "MyBatis XML:20 个" in document + assert "101 个 `changeSet`" in document + for orphan in ( + "GET /api/ping", + "PUT /user/configDevice/{device_id}", + "GET /device/address-book/lookup", + ): + assert orphan in document + + +def test_document_evidence_requires_two_complete_all_route_differentials() -> None: + expected = {"total": 154, "passed": 154, "failed": 0, "skipped": 0} + expected_names = [ + f"{route['method']} {route['path']}" + for route in json.loads(JAVA_MANIFEST.read_text(encoding="utf-8"))["routes"] + ] + route_surface = json.loads(ROUTE_SURFACE_RESULTS.read_text(encoding="utf-8")) + authenticated_route = json.loads(AUTHENTICATED_ROUTE_RESULTS.read_text(encoding="utf-8")) + assert route_surface["summary"] == expected + assert authenticated_route["summary"] == expected + assert route_surface["coverage"]["request_profile"] == "missing-auth-or-safe-invalid-input" + assert authenticated_route["coverage"]["request_profile"] == ( + "authenticated-safe-business-or-validation" + ) + for evidence in (route_surface, authenticated_route): + assert [result["name"] for result in evidence["results"]] == expected_names + assert all( + result["passed"] is True and result["difference"] is None + for result in evidence["results"] + ) diff --git a/main/manager-api-fastapi/tests/test_config_domain.py b/main/manager-api-fastapi/tests/test_config_domain.py new file mode 100644 index 00000000..5d883ba6 --- /dev/null +++ b/main/manager-api-fastapi/tests/test_config_domain.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import json + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.errors import AppError +from app.core.redis import JavaRedisCodec +from app.repositories.config import ConfigRepository +from app.routers.config import config_router +from app.services.config import ConfigService +from tests.domain_support import FakeRedis, sqlite_session + +CONFIG_SCHEMA = [ + "CREATE TABLE sys_params (" + "id INTEGER PRIMARY KEY, param_code TEXT UNIQUE, param_value TEXT, value_type TEXT, param_type INTEGER)", + "CREATE TABLE ai_agent_template (" + "id TEXT PRIMARY KEY, agent_code TEXT, agent_name TEXT, asr_model_id TEXT, vad_model_id TEXT, " + "llm_model_id TEXT, slm_model_id TEXT, vllm_model_id TEXT, tts_model_id TEXT, tts_voice_id TEXT, " + "tts_language TEXT, tts_volume INTEGER, tts_rate INTEGER, tts_pitch INTEGER, mem_model_id TEXT, " + "intent_model_id TEXT, chat_history_conf INTEGER, system_prompt TEXT, summary_memory TEXT, " + "lang_code TEXT, language TEXT, sort INTEGER)", + "CREATE TABLE ai_device (" + "id TEXT PRIMARY KEY, user_id INTEGER, mac_address TEXT UNIQUE, board TEXT, agent_id TEXT, " + "app_version TEXT, auto_update INTEGER)", + "CREATE TABLE ai_agent (" + "id TEXT PRIMARY KEY, user_id INTEGER, agent_code TEXT, agent_name TEXT, asr_model_id TEXT, " + "vad_model_id TEXT, llm_model_id TEXT, slm_model_id TEXT, vllm_model_id TEXT, tts_model_id TEXT, " + "tts_voice_id TEXT, tts_language TEXT, tts_volume INTEGER, tts_rate INTEGER, tts_pitch INTEGER, " + "mem_model_id TEXT, intent_model_id TEXT, chat_history_conf INTEGER, system_prompt TEXT, " + "summary_memory TEXT, lang_code TEXT, language TEXT)", + "CREATE TABLE ai_model_config (" + "id TEXT PRIMARY KEY, model_type TEXT, model_code TEXT, model_name TEXT, is_default INTEGER, " + "is_enabled INTEGER, config_json TEXT, doc_link TEXT, remark TEXT, sort INTEGER, creator INTEGER, " + "create_date DATETIME, updater INTEGER, update_date DATETIME)", + "CREATE TABLE ai_tts_voice (" + "id TEXT PRIMARY KEY, languages TEXT, name TEXT, remark TEXT, reference_audio TEXT, " + "reference_text TEXT, sort INTEGER, tts_model_id TEXT, tts_voice TEXT, voice_demo TEXT)", + "CREATE TABLE ai_voice_clone (" + "id TEXT PRIMARY KEY, name TEXT, model_id TEXT, voice_id TEXT, languages TEXT, user_id INTEGER, " + "train_status INTEGER, train_error TEXT)", + "CREATE TABLE ai_agent_plugin_mapping (" + "id TEXT PRIMARY KEY, agent_id TEXT, plugin_id TEXT, param_info TEXT)", + "CREATE TABLE ai_model_provider (id TEXT PRIMARY KEY, provider_code TEXT)", + "CREATE TABLE ai_rag_dataset (" + "id TEXT PRIMARY KEY, dataset_id TEXT, rag_model_id TEXT, name TEXT, description TEXT, status INTEGER)", + "CREATE TABLE ai_agent_context_provider (agent_id TEXT PRIMARY KEY, context_providers TEXT)", + "CREATE TABLE ai_agent_voice_print (" + "id TEXT PRIMARY KEY, agent_id TEXT, source_name TEXT, introduce TEXT, create_date DATETIME)", + "CREATE TABLE ai_agent_correct_word_mapping (agent_id TEXT, file_id TEXT)", + "CREATE TABLE ai_agent_correct_word_item (file_id TEXT, source_word TEXT, target_word TEXT)", +] + + +async def _seed_config(session: AsyncSession) -> None: + execute = session.execute + await execute( + text( + "INSERT INTO sys_params VALUES " + "(1, 'server.port', '8000', 'number', 1)," + "(2, 'features.enabled', 'true', 'boolean', 1)," + "(3, 'allowed.languages', 'zh; en ;', 'array', 1)," + "(4, 'device_max_output_size', '2048', 'string', 1)," + "(5, 'server.mcp_endpoint', 'https://mcp.example.test/mcp/?key=0123456789abcdef', 'string', 1)," + "(6, 'server.voice_print', 'https://voice.example.test/identify', 'string', 1)," + "(7, 'server.voiceprint_similarity_threshold', '0.73', 'number', 1)" + ) + ) + await execute( + text( + "INSERT INTO ai_agent_template " + "(id, agent_code, agent_name, asr_model_id, vad_model_id, sort) " + "VALUES ('template-1', 'default', 'Default', 'asr-1', 'vad-1', 1)" + ) + ) + models = [ + ("vad-1", "VAD", "vad", {"type": "silero"}), + ("asr-1", "ASR", "asr", {"type": "funasr"}), + ("llm-1", "LLM", "llm", {"type": "openai", "model": "mock"}), + ("tts-1", "TTS", "tts", {"type": "huoshan_double_stream"}), + ("mem-1", "Memory", "mem", {"type": "mem_local_short", "llm": "llm-1"}), + ("intent-1", "Intent", "intent", {"type": "intent_llm", "llm": "llm-1", "functions": "a;b"}), + ("rag-1", "RAG", "ragflow", {"base_url": "https://rag.test", "api_key": "mock-key"}), + ] + for index, (model_id, model_type, model_code, configuration) in enumerate(models, start=1): + await execute( + text( + "INSERT INTO ai_model_config " + "(id, model_type, model_code, model_name, is_default, is_enabled, config_json, sort) " + "VALUES (:id, :type, :code, :name, 0, 1, :config, :sort)" + ), + { + "id": model_id, + "type": model_type, + "code": model_code, + "name": model_code, + "config": json.dumps(configuration), + "sort": index, + }, + ) + + +@pytest.mark.asyncio +async def test_server_base_types_modules_and_redis_cache() -> None: + redis = FakeRedis() + async with sqlite_session(CONFIG_SCHEMA) as session: + await _seed_config(session) + await session.commit() + service = ConfigService(ConfigRepository(session), redis=redis) # type: ignore[arg-type] + result = await service.get_config(use_cache=False) + + assert result["server"]["port"] == 8000 + assert result["features"]["enabled"] is True + assert result["allowed"]["languages"] == ["zh", "en"] + assert ConfigService._typed_param("2147483647", "number") == 2147483647 + assert ConfigService._typed_param("2147483648", "number") == 2147483648.0 + assert isinstance(ConfigService._typed_param("2147483648", "number"), float) + assert result["VAD"]["vad-1"] == {"type": "silero"} + assert result["ASR"]["asr-1"] == {"type": "funasr"} + assert result["selected_module"] == {"VAD": "vad-1", "ASR": "asr-1"} + assert JavaRedisCodec.decode(await redis.get("server:config")) == result + server_wire = json.loads((await redis.get("server:config")).decode()) + assert server_wire["@class"] == "java.util.HashMap" + assert server_wire["server"]["@class"] == "java.util.HashMap" + assert 0 < await redis.ttl("server:config") <= 86400 + + await session.execute(text("UPDATE sys_params SET param_value = '9000' WHERE param_code = 'server.port'")) + await session.commit() + assert (await service.get_config(use_cache=True))["server"]["port"] == 8000 + + +@pytest.mark.asyncio +async def test_agent_config_aggregation_selected_models_mcp_voiceprint_and_correct_words() -> None: + redis = FakeRedis() + async with sqlite_session(CONFIG_SCHEMA) as session: + await _seed_config(session) + await session.execute( + text( + "INSERT INTO ai_device VALUES " + "('device-1', 10, 'AA:BB:CC:DD:EE:FF', 'esp32s3', 'agent-1', '1.2.3', 1)" + ) + ) + await session.execute( + text( + "INSERT INTO ai_agent " + "(id, user_id, agent_code, agent_name, asr_model_id, vad_model_id, llm_model_id, " + "tts_model_id, tts_voice_id, tts_volume, tts_rate, tts_pitch, mem_model_id, intent_model_id, " + "system_prompt, summary_memory) VALUES " + "('agent-1', 10, 'a1', '小明', 'asr-1', 'vad-1', 'llm-1', 'tts-1', 'voice-1', " + "80, 2, -1, 'mem-1', 'intent-1', '你是{{assistant_name}}', 'memory')" + ) + ) + await session.execute( + text( + "INSERT INTO ai_tts_voice " + "(id, languages, name, reference_audio, reference_text, sort, tts_model_id, tts_voice) " + "VALUES ('voice-1', '普通话、English', 'Voice', 'ref.wav', '你好', 2147483648, " + "'tts-1', 'S_demo')" + ) + ) + await session.execute( + text("UPDATE ai_model_config SET creator = 2147483648, updater = 2147483649 WHERE id = 'tts-1'") + ) + await session.execute(text("INSERT INTO ai_model_provider VALUES ('weather-plugin', 'weather')")) + await session.execute( + text( + "INSERT INTO ai_agent_plugin_mapping VALUES " + "('map-1', 'agent-1', 'weather-plugin', '{\"city\":\"shanghai\"}')," + "('map-2', 'agent-1', 'dataset-row', NULL)" + ) + ) + await session.execute( + text( + "INSERT INTO ai_rag_dataset VALUES " + "('dataset-row', 'dataset-external', 'rag-1', 'FAQ', 'product questions', 1)" + ) + ) + await session.execute( + text("INSERT INTO ai_agent_context_provider VALUES ('agent-1', '[{\"type\":\"weather\"}]')") + ) + await session.execute( + text( + "INSERT INTO ai_agent_voice_print VALUES " + "('vp-1', 'agent-1', 'Alice', 'owner', '2026-01-02 03:04:05')" + ) + ) + await session.execute(text("INSERT INTO ai_agent_correct_word_mapping VALUES ('agent-1', 'file-1')")) + await session.execute( + text( + "INSERT INTO ai_agent_correct_word_item VALUES " + "('file-1', '晓智', '小智'), ('file-1', NULL, '空值')" + ) + ) + await session.commit() + + service = ConfigService(ConfigRepository(session), redis=redis) # type: ignore[arg-type] + result = await service.get_agent_models("AA:BB:CC:DD:EE:FF", {"VAD": "vad-1"}) + + assert result["device_max_output_size"] == "2048" + assert result["chat_history_conf"] == 2 + assert "VAD" not in result and result["selected_module"]["ASR"] == "asr-1" + assert result["prompt"] == "你是小明" + assert result["TTS"]["tts-1"] == { + "type": "huoshan_double_stream", + "private_voice": "S_demo", + "ref_audio": "ref.wav", + "ref_text": "你好", + "language": "普通话", + "ttsVolume": 80, + "ttsRate": 2, + "ttsPitch": -1, + "resource_id": "seed-icl-1.0", + } + assert result["Intent"]["intent-1"]["functions"] == ["a", "b"] + assert result["plugins"]["weather"] == '{"city":"shanghai"}' + rag_plugin = json.loads(result["plugins"]["search_from_ragflow"]) + assert rag_plugin["dataset_ids"] == ["dataset-external"] + # This preserves URI.getSchemeSpecificPart() in Java, including its nested /mcp segment. + assert result["mcp_endpoint"].startswith("wss://mcp.example.test/call/mcp/?token=") + assert result["context_providers"] == [{"type": "weather"}] + assert result["voiceprint"] == { + "url": "https://voice.example.test/identify", + "speakers": ["vp-1,Alice,owner"], + "similarity_threshold": 0.73, + } + assert set(await service.get_correct_words("AA:BB:CC:DD:EE:FF")) == {"晓智|小智", "null|空值"} + model_wire = json.loads((await redis.get("model:data:tts-1")).decode()) + assert model_wire["@class"] == "xiaozhi.modules.model.entity.ModelConfigEntity" + assert model_wire["configJson"]["@class"] == "cn.hutool.json.JSONObject" + assert model_wire["creator"] == 2147483648 + assert model_wire["updater"] == 2147483649 + timbre_wire = json.loads((await redis.get("timbre:details:voice-1")).decode()) + assert timbre_wire["@class"] == "xiaozhi.modules.timbre.vo.TimbreDetailsVO" + assert timbre_wire["sort"] == 2147483648 + + +@pytest.mark.asyncio +async def test_temporary_admin_config_is_one_shot_and_activation_code_error_is_preserved() -> None: + redis = FakeRedis() + async with sqlite_session(CONFIG_SCHEMA) as session: + await _seed_config(session) + await session.commit() + service = ConfigService(ConfigRepository(session), redis=redis) # type: ignore[arg-type] + + await redis.set("tmp_register_mac:temporary", JavaRedisCodec.encode("true"), ex=300) + temporary = await service.get_agent_models("temporary", {}) + assert temporary["selected_module"] == {"VAD": "vad-1", "ASR": "asr-1"} + assert await redis.get("tmp_register_mac:temporary") is None + + await redis.set( + "ota:activation:data:aa_bb_cc_dd_ee_11", + JavaRedisCodec.encode({"activation_code": "654321"}), + ex=300, + ) + with pytest.raises(AppError) as captured: + await service.get_agent_models("AA:BB:CC:DD:EE:11", {}) + assert captured.value.code == 10042 + assert captured.value.params == ("654321",) + + +@pytest.mark.asyncio +async def test_invalid_model_config_fails_like_java_jackson_type_handler() -> None: + redis = FakeRedis() + async with sqlite_session(CONFIG_SCHEMA) as session: + await _seed_config(session) + await session.execute(text("UPDATE ai_model_config SET config_json = '{' WHERE id = 'vad-1'")) + await session.commit() + service = ConfigService(ConfigRepository(session), redis=redis) # type: ignore[arg-type] + with pytest.raises(json.JSONDecodeError): + await service.get_config(use_cache=False) + + +def test_config_router_exposes_all_config_controller_routes() -> None: + routes = {(method, route.path) for route in config_router.routes for method in route.methods} + assert routes == { + ("POST", "/config/server-base"), + ("POST", "/config/agent-models"), + ("POST", "/config/correct-words"), + } diff --git a/main/manager-api-fastapi/tests/test_consumer_route_manifest.py b/main/manager-api-fastapi/tests/test_consumer_route_manifest.py new file mode 100644 index 00000000..e430a8ce --- /dev/null +++ b/main/manager-api-fastapi/tests/test_consumer_route_manifest.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path + +from app.main import app + +TARGET_ROOT = Path(__file__).resolve().parents[1] +MANIFEST = TARGET_ROOT / "compatibility" / "consumer-routes.json" +EXTRACTOR = TARGET_ROOT / "scripts" / "extract_consumer_routes.py" +PATH_PARAMETER = re.compile(r"\{[^/{}]+\}") + + +def live_manifest() -> dict[str, object]: + result = subprocess.run( # noqa: S603 + [sys.executable, str(EXTRACTOR)], + check=True, + capture_output=True, + text=True, + ) + return json.loads(result.stdout) + + +def _route_matches(declared: str, consumed: str) -> bool: + declared_parts = declared.strip("/").split("/") + consumed_parts = consumed.strip("/").split("/") + if len(declared_parts) != len(consumed_parts): + return False + return all( + PATH_PARAMETER.fullmatch(declared_part) is not None or declared_part == consumed_part + for declared_part, consumed_part in zip(declared_parts, consumed_parts, strict=True) + ) + + +def test_checked_in_consumer_route_manifest_is_current() -> None: + checked_in = json.loads(MANIFEST.read_text(encoding="utf-8")) + assert checked_in == live_manifest() + + +def test_consumer_inventory_counts_are_closed() -> None: + manifest = live_manifest() + assert manifest["count"] == 188 + assert manifest["uniqueRoutes"] == 140 + assert manifest["consumers"] == { + "manager-web": { + "callSites": 134, + "uniqueRoutes": 130, + "methods": {"DELETE": 12, "GET": 59, "POST": 40, "PUT": 23}, + }, + "manager-mobile": { + "callSites": 46, + "uniqueRoutes": 40, + "methods": {"DELETE": 3, "GET": 26, "POST": 12, "PUT": 5}, + }, + "xiaozhi-server": { + "callSites": 8, + "uniqueRoutes": 8, + "methods": {"GET": 2, "POST": 6}, + }, + } + + +def test_every_consumer_call_resolves_to_a_fastapi_route() -> None: + calls = live_manifest()["calls"] + assert isinstance(calls, list) + actual = [ + (method, route.path.removeprefix("/xiaozhi")) + for route in app.routes + for method in getattr(route, "methods", set()) + if method in {"GET", "POST", "PUT", "DELETE", "PATCH"} + and route.path.startswith("/xiaozhi") + ] + missing = [ + call + for call in calls + if not any( + method == str(call["method"]) and _route_matches(route_path, str(call["path"])) + for method, route_path in actual + ) + ] + assert missing == [] diff --git a/main/manager-api-fastapi/tests/test_core_compatibility.py b/main/manager-api-fastapi/tests/test_core_compatibility.py new file mode 100644 index 00000000..4cb48b34 --- /dev/null +++ b/main/manager-api-fastapi/tests/test_core_compatibility.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import datetime + +import pytest +from fastapi import Request +from fastapi.exceptions import RequestValidationError + +from app.core.crypto import bcrypt_hash, bcrypt_matches, generate_database_token +from app.core.errors import ErrorCode +from app.core.i18n import message_for, resolve_language +from app.core.ids import SnowflakeIdGenerator +from app.core.redis import JavaRedisCodec +from app.core.responses import JavaJSONResponse, envelope, error_response +from app.core.serialization import java_compatible, preserve_java_map_keys +from app.main import validation_error_handler + + +def test_java_response_envelope_always_contains_null_data() -> None: + response = JavaJSONResponse(envelope(code=401, msg="Unauthorized")) + assert json.loads(response.body) == {"code": 401, "msg": "Unauthorized", "data": None} + + +def test_auth_filter_can_preserve_java_content_type_without_starlette_charset_rewrite() -> None: + request = Request({"type": "http", "method": "GET", "path": "/xiaozhi/user/info", "headers": []}) + response = error_response(request, 401, media_type="application/json;charset=utf-8") + assert response.headers["content-type"] == "application/json;charset=utf-8" + + +@pytest.mark.asyncio +async def test_absent_request_body_uses_java_generic_error_but_field_missing_uses_10034() -> None: + request = Request({"type": "http", "method": "POST", "path": "/xiaozhi/user/login", "headers": []}) + absent = RequestValidationError( + [{"type": "missing", "loc": ("body",), "msg": "Field required", "input": None}] + ) + absent_response = await validation_error_handler(request, absent) + assert json.loads(absent_response.body) == { + "code": ErrorCode.INTERNAL_SERVER_ERROR, + "msg": "服务器内部异常", + "data": None, + } + + field_missing = RequestValidationError( + [{"type": "missing", "loc": ("body", "username"), "msg": "Field required", "input": {}}] + ) + field_response = await validation_error_handler(request, field_missing) + assert json.loads(field_response.body) == { + "code": ErrorCode.PARAM_VALUE_NULL, + "msg": "Field required", + "data": None, + } + + +@pytest.mark.asyncio +async def test_java_binding_failures_use_generic_error_without_changing_json_field_validation() -> None: + root_type_request = Request( + {"type": "http", "method": "POST", "path": "/xiaozhi/admin/dict/data/delete", "headers": []} + ) + root_type = RequestValidationError( + [{"type": "list_type", "loc": ("body",), "msg": "Input should be a valid list", "input": {}}] + ) + root_response = await validation_error_handler(root_type_request, root_type) + assert json.loads(root_response.body) == { + "code": ErrorCode.INTERNAL_SERVER_ERROR, + "msg": "服务器内部异常", + "data": None, + } + + query_request = Request( + {"type": "http", "method": "GET", "path": "/xiaozhi/models/list", "headers": []} + ) + missing_query = RequestValidationError( + [{"type": "missing", "loc": ("query", "modelType"), "msg": "Field required", "input": None}] + ) + query_response = await validation_error_handler(query_request, missing_query) + assert json.loads(query_response.body)["code"] == ErrorCode.INTERNAL_SERVER_ERROR + + multipart_request = Request( + {"type": "http", "method": "POST", "path": "/xiaozhi/voiceClone/upload", "headers": []} + ) + missing_file = RequestValidationError( + [{"type": "missing", "loc": ("body", "file"), "msg": "Field required", "input": None}] + ) + multipart_response = await validation_error_handler(multipart_request, missing_file) + assert json.loads(multipart_response.body)["code"] == ErrorCode.INTERNAL_SERVER_ERROR + + json_request = Request( + {"type": "http", "method": "POST", "path": "/xiaozhi/unmapped-json", "headers": []} + ) + missing_field = RequestValidationError( + [{"type": "missing", "loc": ("body", "name"), "msg": "Field required", "input": {}}] + ) + json_response = await validation_error_handler(json_request, missing_field) + assert json.loads(json_response.body) == { + "code": ErrorCode.PARAM_VALUE_NULL, + "msg": "Field required", + "data": None, + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("path", "field", "expected"), + [ + ("/xiaozhi/admin/server/emit-action", "action", "操作不能为空"), + ("/xiaozhi/admin/server/emit-action", "targetWs", "目标ws地址不能为空"), + ("/xiaozhi/agent", "agentName", "智能体名称不能为空"), + ("/xiaozhi/agent/chat-history/report", "macAddress", "不能为空"), + ( + "/xiaozhi/agent/missing/snapshots/missing/restore", + "currentStateToken", + "不能为空", + ), + ("/xiaozhi/config/agent-models", "macAddress", "设备MAC地址不能为空"), + ("/xiaozhi/config/agent-models", "clientId", "客户端ID不能为空"), + ( + "/xiaozhi/config/agent-models", + "selectedModule", + "客户端已实例化的模型不能为空", + ), + ("/xiaozhi/config/correct-words", "macAddress", "设备MAC地址不能为空"), + ("/xiaozhi/device/address-book/alias", "targetMac", "目标MAC地址不能为空"), + ("/xiaozhi/device/address-book/alias", "macAddress", "MAC地址不能为空"), + ], +) +async def test_required_json_fields_keep_java_constraint_messages( + path: str, field: str, expected: str +) -> None: + request = Request({"type": "http", "method": "POST", "path": path, "headers": []}) + missing = RequestValidationError( + [{"type": "missing", "loc": ("body", field), "msg": "Field required", "input": {}}] + ) + response = await validation_error_handler(request, missing) + assert json.loads(response.body) == { + "code": ErrorCode.PARAM_VALUE_NULL, + "msg": expected, + "data": None, + } + + +def test_dynamic_java_map_keys_are_not_changed_by_property_naming() -> None: + response = JavaJSONResponse( + envelope( + preserve_java_map_keys( + {"selected_module": {"wake_up_words": ["你好"]}, "timestamp": 2**32} + ) + ) + ) + assert json.loads(response.body)["data"] == { + "selected_module": {"wake_up_words": ["你好"]}, + "timestamp": str(2**32), + } + + +def test_long_dates_aliases_and_nulls_follow_jackson_configuration() -> None: + payload = java_compatible( + { + "id": 7, + "agent_id": 1890000000000000000, + "word_count": 3, + "created_at": datetime(2026, 7, 20, 9, 8, 7), + "optional_value": None, + } + ) + assert payload == { + "id": "7", + "agentId": "1890000000000000000", + "wordCount": 3, + "createdAt": "2026-07-20 09:08:07", + "optionalValue": None, + } + # PageData.total is declared as an Integer in the Java baseline, not a Long. + assert java_compatible({"total": 1}) == {"total": 1} + + +def test_accept_language_uses_first_value_and_supported_fallbacks() -> None: + assert resolve_language("de-DE;q=0.1,en-US;q=1") == "de-DE" + assert message_for(401, "zh-CN") == "未授权" + assert message_for(401, "zh-TW") == "未授權" + assert message_for(401, "en") == "Unauthorized" + assert message_for(401, "de") == "Nicht autorisiert" + assert message_for(401, "vi") == "Không được ủy quyền" + assert message_for(401, "pt-BR") == "Não autorizado" + assert message_for(401, "unknown") == "未授权" + + +def test_java_redis_json_wire_format_for_protocol_scalars() -> None: + assert JavaRedisCodec.encode("abc") == b'"abc"' + assert JavaRedisCodec.encode(3) == b"3" + assert JavaRedisCodec.encode(["a", "b"]) == b'["java.util.ArrayList",["a","b"]]' + assert JavaRedisCodec.encode({"nested": {"enabled": True}}) == ( + b'{"@class":"java.util.HashMap","nested":{"@class":"java.util.HashMap","enabled":true}}' + ) + assert JavaRedisCodec.encode(2147483648) == b"2147483648" + assert JavaRedisCodec.encode({"number": 2147483648}) == ( + b'{"@class":"java.util.HashMap","number":["java.lang.Long",2147483648]}' + ) + assert JavaRedisCodec.decode(b'["java.util.ArrayList",["a","b"]]') == ["a", "b"] + assert JavaRedisCodec.decode(b'"abc"') == "abc" + assert JavaRedisCodec.decode(b"legacy-unquoted") == "legacy-unquoted" + + +def test_database_token_is_java_md5_uuid_format() -> None: + expected = hashlib.md5(b"fixed-input", usedforsecurity=False).hexdigest() + assert generate_database_token("fixed-input") == expected + generated = generate_database_token() + assert len(generated) == 32 + assert generated == generated.lower() + + +def test_bcrypt_output_is_accepted_by_bundled_java_encoder() -> None: + encoded = bcrypt_hash("correct horse battery staple") + assert encoded.startswith("$2a$10$") + assert bcrypt_matches("correct horse battery staple", encoded) + assert not bcrypt_matches("wrong", encoded) + assert not bcrypt_matches("correct horse battery staple", encoded.replace("$2a$", "$2b$", 1)) + + +def test_snowflake_ids_are_unique_monotonic_and_fit_signed_bigint() -> None: + generator = SnowflakeIdGenerator(worker_id=1, datacenter_id=2) + values = [generator.next_id() for _ in range(1000)] + assert values == sorted(values) + assert len(set(values)) == len(values) + assert all(0 < value < 2**63 for value in values) diff --git a/main/manager-api-fastapi/tests/test_deployment_artifacts.py b/main/manager-api-fastapi/tests/test_deployment_artifacts.py new file mode 100644 index 00000000..2a8946f6 --- /dev/null +++ b/main/manager-api-fastapi/tests/test_deployment_artifacts.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] + + +def test_runtime_dependency_versions_are_explicitly_locked() -> None: + project = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + lock = (ROOT / "uv.lock").read_text(encoding="utf-8") + assert '"fastapi==0.116.1"' in project + assert '"pydantic==2.11.7"' in project + assert '"uvicorn[standard]==0.35.0"' in project + assert 'name = "pydantic"\nversion = "2.11.7"' in lock + + +def test_container_artifacts_are_pinned_and_preserve_the_java_upload_path() -> None: + dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") + migration_dockerfile = (ROOT / "Dockerfile.migrations").read_text(encoding="utf-8") + migration_pom = (ROOT / "migration-pom.xml").read_text(encoding="utf-8") + assert dockerfile.startswith("FROM python:3.10.20-bookworm AS build\n") + assert dockerfile.count("FROM python:3.10.20-slim-bookworm") == 1 + assert "COPY --from=ghcr.io/astral-sh/uv:0.11.28 /uv /uvx /bin/" in dockerfile + assert "UV_HTTP_TIMEOUT=120" in dockerfile + assert "UV_HTTP_RETRIES=10" in dockerfile + assert "apt-get" not in dockerfile + assert "COPY --from=build --chown=10001:10001 /app/.venv ./.venv" in dockerfile + assert "USER 10001:10001" in dockerfile + assert "ln -s /data/uploads /app/uploadfile" in dockerfile + assert 'STOPSIGNAL SIGTERM' in dockerfile + assert "FROM maven:3.9.9-eclipse-temurin-21 AS build" in migration_dockerfile + assert "FROM eclipse-temurin:21-jre" in migration_dockerfile + assert "--mount=type=cache,target=/root/.m2/repository" in migration_dockerfile + assert "-Daether.connector.basic.threads=1" in migration_dockerfile + assert "-Daether.connector.connectTimeout=15000" in migration_dockerfile + assert "-Daether.connector.requestTimeout=60000" in migration_dockerfile + assert "-Daether.connector.http.retryHandler.count=5" in migration_dockerfile + assert "USER 10001:10001" in migration_dockerfile + assert "STOPSIGNAL SIGTERM" in migration_dockerfile + assert "manager-api-liquibase-runner-1.0.0-all.jar" in migration_dockerfile + assert "<shadedArtifactAttached>true</shadedArtifactAttached>" in migration_pom + assert "<shadedClassifierName>all</shadedClassifierName>" in migration_pom + assert ":latest" not in dockerfile + migration_dockerfile + + +def test_compose_separates_migrations_api_jobs_and_nginx() -> None: + compose = yaml.safe_load((ROOT / "docker-compose.yml").read_text(encoding="utf-8")) + services = compose["services"] + assert set(services) == { + "manager-api-migrate", + "manager-api-fastapi", + "manager-api-jobs", + "manager-api-nginx", + } + api = services["manager-api-fastapi"] + jobs = services["manager-api-jobs"] + assert api["depends_on"]["manager-api-migrate"]["condition"] == "service_completed_successfully" + assert jobs["command"] == ["python", "-m", "app.jobs.worker"] + assert api["read_only"] is True and jobs["read_only"] is True + assert api["stop_grace_period"] == "40s" + expected_upload = ["${MANAGER_API_UPLOAD_SOURCE:-manager-api-uploads}:/data/uploads"] + assert api["volumes"] == jobs["volumes"] == expected_upload + assert api["environment"]["APP_GRACEFUL_SHUTDOWN_SECONDS"] == "${APP_GRACEFUL_SHUTDOWN_SECONDS:-30}" + assert jobs["environment"]["APP_GRACEFUL_SHUTDOWN_SECONDS"] == "${APP_GRACEFUL_SHUTDOWN_SECONDS:-30}" + + +def test_nginx_health_and_startup_scripts_are_wired() -> None: + nginx = (ROOT / "deploy" / "nginx.conf").read_text(encoding="utf-8") + nginx_dockerfile = (ROOT / "Dockerfile.nginx").read_text(encoding="utf-8") + nginx_entrypoint = ROOT / "deploy" / "nginx-entrypoint.sh" + compose_text = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") + compose = yaml.safe_load(compose_text) + nginx_service = compose["services"]["manager-api-nginx"] + assert "location /xiaozhi/" in nginx + assert "location = /xiaozhi" in nginx + assert "server ${MANAGER_API_UPSTREAM};" in nginx + assert "proxy_request_buffering off;" in nginx + assert "proxy_buffering off;" in nginx + assert "client_max_body_size 100m;" in nginx + assert nginx_dockerfile.startswith("FROM nginx:1.28.0-alpine\n") + assert ":latest" not in nginx_dockerfile + assert "envsubst '${MANAGER_API_UPSTREAM}'" in nginx_dockerfile + assert nginx_service["build"]["dockerfile"] == "main/manager-api-fastapi/Dockerfile.nginx" + assert nginx_service["environment"] == { + "MANAGER_API_UPSTREAM": "${MANAGER_API_UPSTREAM:-manager-api-fastapi:8002}" + } + assert nginx_service["read_only"] is True + assert set(nginx_service["tmpfs"]) == { + "/var/cache/nginx:size=32m", + "/var/run:size=1m", + "/tmp:size=16m", # noqa: S108 - literal deployment mount, not a test scratch path + } + assert nginx_service["depends_on"]["manager-api-fastapi"]["condition"] == "service_healthy" + assert "/xiaozhi/health/ready" in compose_text + assert os.access(nginx_entrypoint, os.X_OK) + syntax = subprocess.run( # noqa: S603 + ["/bin/sh", "-n", str(nginx_entrypoint)], + check=False, + capture_output=True, + text=True, + ) + assert syntax.returncode == 0, syntax.stderr + for script_name in ( + "container-entrypoint.sh", + "run-migrations.sh", + "isolated-env.sh", + "start-api.sh", + "start-jobs.sh", + ): + script = ROOT / "scripts" / script_name + assert script.read_text(encoding="utf-8").startswith("#!/bin/sh\nset -eu\n") + assert os.access(script, os.X_OK), f"{script_name} must be executable" + + +def test_docker_context_excludes_local_runtimes_secrets_and_build_products() -> None: + dockerignore = (ROOT.parents[1] / ".dockerignore").read_text(encoding="utf-8") + for ignored in ( + ".env", + ".runtime/", + ".venv-*/", + "**/.venv/", + "**/.test-runtime/", + "**/node_modules/", + "**/dist/", + "**/target/", + "**/uploadfile/", + "main/xiaozhi-server/models/", + "main/xiaozhi-server/data/", + ): + assert ignored in dockerignore + + +def test_start_scripts_are_executable_without_shell_reinterpretation() -> None: + environment = {**os.environ, "PYTHON_BIN": "/bin/echo"} + api = subprocess.run( # noqa: S603 + [str(ROOT / "scripts" / "start-api.sh")], + check=True, + capture_output=True, + text=True, + env=environment, + ) + assert "-m uvicorn app.main:app" in api.stdout + assert "--timeout-graceful-shutdown 30" in api.stdout + jobs = subprocess.run( # noqa: S603 + [str(ROOT / "scripts" / "start-jobs.sh")], + check=True, + capture_output=True, + text=True, + env=environment, + ) + assert jobs.stdout.strip() == "-m app.jobs.worker" + override = subprocess.run( # noqa: S603 + [str(ROOT / "scripts" / "container-entrypoint.sh"), "/bin/echo", "override-ok"], + check=True, + capture_output=True, + text=True, + ) + assert override.stdout.strip() == "override-ok" + + +def test_repository_local_restart_entrypoint_keeps_fastapi_default_and_java_rollback() -> None: + restart = ROOT.parents[1] / "scripts" / "restart-local-services.sh" + assert os.access(restart, os.X_OK) + syntax = subprocess.run( # noqa: S603 + ["/bin/sh", "-n", str(restart)], + check=False, + capture_output=True, + text=True, + ) + assert syntax.returncode == 0, syntax.stderr + help_result = subprocess.run( # noqa: S603 + [str(restart), "--help"], + check=True, + capture_output=True, + text=True, + ) + assert "--manager-api fastapi|java" in help_result.stdout + assert "The FastAPI manager is the default" in help_result.stdout + + +def test_local_migration_script_prefers_bundled_jdk_maven_and_repository() -> None: + script = (ROOT / "scripts" / "run-migrations.sh").read_text(encoding="utf-8") + assert 'RUNTIME_ROOT="${PROJECT_DIR}/../../.runtime"' in script + assert 'MAVEN="${RUNTIME_ROOT}/maven/bin/mvn"' in script + assert 'MAVEN_REPOSITORY_ARGS="-Dmaven.repo.local=${RUNTIME_ROOT}/m2"' in script + assert 'JAVA="${RUNTIME_ROOT}/jdk/bin/java"' in script + assert " clean package" not in script + assert "manager-api-liquibase-runner-1.0.0-all.jar" in script diff --git a/main/manager-api-fastapi/tests/test_deployment_runtime.py b/main/manager-api-fastapi/tests/test_deployment_runtime.py new file mode 100644 index 00000000..5a14f76d --- /dev/null +++ b/main/manager-api-fastapi/tests/test_deployment_runtime.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace +from typing import cast + +import pytest +from redis.asyncio import Redis +from sqlalchemy.ext.asyncio import AsyncSession + +from app.jobs.worker import _finish_tasks, _fixed_delay_loop +from app.services.device import DeviceService, redis_set +from tests.domain_support import FakeRedis + +TARGET_ROOT = Path(__file__).resolve().parents[1] +REPOSITORY_ROOT = TARGET_ROOT.parents[1] + + +def test_application_lifespan_starts_and_shuts_down_without_binding_a_port(tmp_path: Path) -> None: + upload_dir = tmp_path / "uploads" + code = ( + "from fastapi.testclient import TestClient; " + "from app.main import app; " + "client=TestClient(app); " + "client.__enter__(); " + "response=client.get('/xiaozhi/health/live'); " + "assert response.status_code == 200, response.text; " + "client.__exit__(None, None, None)" + ) + environment = { + **os.environ, + "APP_ENVIRONMENT": "test", + "APP_ALLOW_START_WITHOUT_DEPENDENCIES": "true", + "APP_DATABASE_URL": "sqlite+aiosqlite:///:memory:", + "APP_REDIS_URL": "redis://127.0.0.1:1/15", + "APP_UPLOAD_DIR": str(upload_dir), + "APP_JAVA_RESOURCES_DIR": str(REPOSITORY_ROOT / "main" / "manager-api" / "src" / "main" / "resources"), + } + result = subprocess.run( # noqa: S603 + [sys.executable, "-c", code], + cwd=TARGET_ROOT, + env=environment, + check=False, + capture_output=True, + text=True, + timeout=20, + ) + assert result.returncode == 0, result.stderr + assert upload_dir.is_dir() + + +@pytest.mark.asyncio +async def test_readiness_reports_dependency_state_with_real_http_status(monkeypatch: pytest.MonkeyPatch) -> None: + import app.main as main_module + + async def available() -> bool: + return True + + async def unavailable() -> bool: + return False + + monkeypatch.setattr(main_module, "database_ping", available) + monkeypatch.setattr(main_module, "redis_ping", unavailable) + monkeypatch.setattr(main_module, "upload_storage_ready", lambda: True) + failed = await main_module.readiness() + assert failed.status_code == 503 + assert json.loads(failed.body) == { + "code": 503, + "msg": "dependencies unavailable", + "data": {"database": True, "redis": False, "uploads": True}, + } + + monkeypatch.setattr(main_module, "redis_ping", available) + ready = await main_module.readiness() + assert ready.status_code == 200 + assert json.loads(ready.body)["data"] == { + "database": True, + "redis": True, + "uploads": True, + } + + monkeypatch.setattr(main_module, "upload_storage_ready", lambda: False) + unwritable = await main_module.readiness() + assert unwritable.status_code == 503 + assert json.loads(unwritable.body)["data"] == { + "database": True, + "redis": True, + "uploads": False, + } + assert (await main_module.liveness()).status_code == 200 + + +@pytest.mark.asyncio +async def test_job_shutdown_drains_active_work_before_cancelling() -> None: + stop = asyncio.Event() + started = asyncio.Event() + release = asyncio.Event() + + async def operation() -> int: + started.set() + await release.wait() + return 1 + + task = asyncio.create_task( + _fixed_delay_loop(stop, operation, name="test", initial_delay=0, delay=60) + ) + await started.wait() + stop.set() + drain = asyncio.create_task(_finish_tasks([task], timeout=1)) + await asyncio.sleep(0) + assert not drain.done() + release.set() + assert await drain is True + assert task.done() and not task.cancelled() + + +@pytest.mark.asyncio +async def test_fixed_delay_job_runs_again_after_python_310_wait_timeout() -> None: + stop = asyncio.Event() + calls = 0 + + async def operation() -> int: + nonlocal calls + calls += 1 + if calls == 2: + stop.set() + return calls + + await asyncio.wait_for( + _fixed_delay_loop(stop, operation, name="timeout-regression", initial_delay=0, delay=0.01), + timeout=1, + ) + assert calls == 2 + + +@pytest.mark.asyncio +async def test_job_shutdown_cancels_only_after_timeout() -> None: + task = asyncio.create_task(asyncio.sleep(60)) + assert await _finish_tasks([task], timeout=0.01) is False + assert task.cancelled() + + +@pytest.mark.asyncio +async def test_configured_upload_volume_keeps_java_relative_database_path( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + mounted = tmp_path / "mounted-uploads" + monkeypatch.setattr( + "app.services.device.get_settings", + lambda: SimpleNamespace(upload_dir=mounted), + ) + redis = cast(Redis, FakeRedis()) + service = DeviceService(cast(AsyncSession, object()), redis_client=redis) + content = b"deployment-upload" + digest = hashlib.md5(content, usedforsecurity=False).hexdigest() + java_path = f"uploadfile/{digest}.bin" + + assert await service.save_firmware_file(filename="firmware.bin", content=content) == java_path + assert (mounted / f"{digest}.bin").read_bytes() == content + assert not (tmp_path / java_path).exists() + + await redis_set("ota:id:volume-test", f"file:{java_path}", client=redis) + resolved = await service.resolve_ota_download("volume-test") + assert resolved is not None + assert resolved[0] == mounted / f"{digest}.bin" + assert resolved[0].read_bytes() == content diff --git a/main/manager-api-fastapi/tests/test_device.py b/main/manager-api-fastapi/tests/test_device.py new file mode 100644 index 00000000..86fbdb48 --- /dev/null +++ b/main/manager-api-fastapi/tests/test_device.py @@ -0,0 +1,348 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, cast + +import httpx +import pytest +from fastapi import FastAPI, Request +from redis.asyncio import Redis +from sqlalchemy import text + +from app.core.database import get_db +from app.core.security import AuthUser +from app.integrations.mqtt_gateway import daily_authorization_tokens, post_json +from app.routers.device import _validate_device_update, device_router, unbind_device, update_address_alias +from app.schemas.device import ( + ApplicationInfo, + BoardInfo, + DeviceAddressBookAliasRequest, + DeviceManualAddRequest, + DeviceReportRequest, + DeviceUnbindRequest, + DeviceUpdateRequest, +) +from app.services.device import DeviceService, redis_get, redis_set +from app.services.system_params import SystemParamService +from tests.domain_support import FakeRedis, sqlite_session + +DEVICE_SCHEMA = [ + """ + CREATE TABLE ai_device ( + id VARCHAR(32) PRIMARY KEY, user_id BIGINT, mac_address VARCHAR(50), last_connected_at DATETIME, + auto_update INTEGER, board VARCHAR(50), alias VARCHAR(64), agent_id VARCHAR(32), app_version VARCHAR(20), + sort INTEGER, updater BIGINT, update_date DATETIME, creator BIGINT, create_date DATETIME + ) + """, + """ + CREATE TABLE ai_ota ( + id VARCHAR(32) PRIMARY KEY, firmware_name VARCHAR(100), type VARCHAR(50), version VARCHAR(50), size BIGINT, + remark VARCHAR(500), firmware_path VARCHAR(255), sort INTEGER, updater BIGINT, update_date DATETIME, + creator BIGINT, create_date DATETIME + ) + """, + """ + CREATE TABLE ai_device_address_book ( + mac_address VARCHAR(64), target_mac VARCHAR(64), alias VARCHAR(64), has_permission INTEGER, + creator BIGINT, create_date DATETIME, updater BIGINT, update_date DATETIME, + PRIMARY KEY (mac_address, target_mac) + ) + """, + "CREATE TABLE sys_params (param_code VARCHAR(100) PRIMARY KEY, param_value TEXT, update_date DATETIME)", +] + + +def normal_user(user_id: int = 7, *, super_admin: int = 0) -> AuthUser: + return AuthUser( + id=user_id, + username="tester", + super_admin=super_admin, + status=1, + token="test-token", # noqa: S106 - isolated authentication fixture + row={"id": user_id}, + ) + + +def test_device_router_closes_all_assigned_paths_and_static_routes_win() -> None: + routes = [route for route in device_router.routes if hasattr(route, "methods")] + pairs = {(next(iter(route.methods)), route.path) for route in routes} + assert pairs == { + ("POST", "/device/bind/{agent_id}/{device_code}"), + ("POST", "/device/register"), + ("GET", "/device/bind/{agent_id}"), + ("POST", "/device/bind/{agent_id}"), + ("POST", "/device/unbind"), + ("PUT", "/device/update/{device_id}"), + ("PUT", "/user/configDevice/{device_id}"), + ("POST", "/device/manual-add"), + ("POST", "/device/tools/list/{device_id}"), + ("POST", "/device/tools/call/{device_id}"), + ("GET", "/device/address-book/call"), + ("GET", "/device/address-book/lookup"), + ("PUT", "/device/address-book/alias"), + ("PUT", "/device/address-book/permission"), + ("GET", "/device/address-book/{mac_address}"), + ("POST", "/ota/"), + ("POST", "/ota/activate"), + ("GET", "/ota/"), + ("GET", "/otaMag/getDownloadUrl/{ota_id}"), + ("GET", "/otaMag/download/{download_id}"), + ("POST", "/otaMag/upload"), + ("POST", "/otaMag/uploadAssetsBin"), + ("GET", "/otaMag"), + ("GET", "/otaMag/{ota_id}"), + ("POST", "/otaMag"), + ("DELETE", "/otaMag/{ota_id}"), + ("PUT", "/otaMag/{ota_id}"), + } + assert len(routes) == len(pairs) + paths = [route.path for route in routes] + assert paths.index("/device/address-book/call") < paths.index("/device/address-book/{mac_address}") + assert paths.index("/otaMag/getDownloadUrl/{ota_id}") < paths.index("/otaMag/{ota_id}") + + +def test_device_update_validation_matches_java_utf16_and_localized_constraints() -> None: + assert _validate_device_update(DeviceUpdateRequest(alias="😀" * 32), "en-US") is None + assert ( + _validate_device_update(DeviceUpdateRequest(alias="😀" * 33), "en-US") + == "size must be between 0 and 64" + ) + assert _validate_device_update(DeviceUpdateRequest(auto_update=2), "de-DE") == "muss kleiner-gleich 1 sein" + + +@pytest.mark.asyncio +async def test_unbind_empty_object_is_java_successful_noop_and_alias_validates_target_first() -> None: + async with sqlite_session(DEVICE_SCHEMA) as session: + request = Request({"type": "http", "method": "POST", "path": "/device/unbind", "headers": []}) + request.state.user = normal_user() + response = await unbind_device(DeviceUnbindRequest(), request, session) + assert json.loads(response.body) == {"code": 0, "msg": "success", "data": None} + + alias_request = Request( + {"type": "http", "method": "PUT", "path": "/device/address-book/alias", "headers": []} + ) + alias_request.state.user = normal_user() + alias_response = await update_address_alias( + DeviceAddressBookAliasRequest(), alias_request, session + ) + assert json.loads(alias_response.body) == { + "code": 10034, + "msg": "目标MAC地址不能为空", + "data": None, + } + + +def test_device_list_keeps_java_utc_create_date_but_shanghai_epoch() -> None: + view = DeviceService._user_device_view( # noqa: SLF001 - compatibility regression fixture + { + "id": "device", + "mac_address": "AA:BB:CC:DD:EE:FF", + "create_date": datetime(2026, 7, 20, 12, 34, 56), + } + ) + assert view["create_date"] == datetime(2026, 7, 20, 4, 34, 56) + assert view["create_date_timestamp"] == 1_784_522_096_000 + + +@pytest.mark.asyncio +async def test_mqtt_gateway_uses_java_daily_tokens_and_only_retries_401() -> None: + fixed = datetime(2026, 7, 20, 1, 2, 3, tzinfo=timezone.utc) + tokens = daily_authorization_tokens("shared-key", fixed) + seen: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.headers["Authorization"]) + assert json.loads(request.content) == {"clientIds": ["one"]} + return httpx.Response(401 if len(seen) < 3 else 200, text='{"success":true}') + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + result = await post_json( + "http://gateway/api/devices/status", + {"clientIds": ["one"]}, + "shared-key", + timeout_seconds=1, + now=fixed, + client=client, + ) + assert result == '{"success":true}' + assert seen == [f"Bearer {token}" for token in tokens] + + +@pytest.mark.asyncio +async def test_device_activation_commits_row_and_consumes_java_redis_keys() -> None: + fake = cast(Redis, FakeRedis()) + device_id = "AA:BB:CC:DD:EE:FF" + code = "123456" + await redis_set(f"ota:activation:code:{code}", device_id, client=fake) + await redis_set( + "ota:activation:data:aa_bb_cc_dd_ee_ff", + { + "activation_code": code, + "mac_address": device_id, + "board": "esp32-s3", + "app_version": "1.2.3", + }, + client=fake, + ) + async with sqlite_session(DEVICE_SCHEMA) as session: + service = DeviceService(session, redis_client=fake) + await service.activate_bound_device(agent_id="agent-1", activation_code=code, user=normal_user()) + row = ( + await session.execute(text("SELECT * FROM ai_device WHERE id = :id"), {"id": device_id}) + ).mappings().one() + assert row["user_id"] == 7 + assert row["agent_id"] == "agent-1" + assert row["auto_update"] == 1 + assert await redis_get(f"ota:activation:code:{code}", fake) is None + assert await redis_get("ota:activation:data:aa_bb_cc_dd_ee_ff", fake) is None + + +@pytest.mark.asyncio +async def test_manual_add_preserves_java_uuid_fill_for_missing_mac() -> None: + fake = cast(Redis, FakeRedis()) + await redis_set("agent:device:count:null", 4, client=fake) + async with sqlite_session(DEVICE_SCHEMA) as session: + await DeviceService(session, redis_client=fake).manual_add( + request=DeviceManualAddRequest(), + user=normal_user(), + ) + row = (await session.execute(text("SELECT id,mac_address FROM ai_device"))).mappings().one() + assert len(row["id"]) == 32 + assert row["mac_address"] is None + assert await redis_get("agent:device:count:null", fake) is None + + +@pytest.mark.asyncio +async def test_domain_redis_maps_use_spring_json_type_metadata() -> None: + fake = cast(Redis, FakeRedis()) + await redis_set("map", {"outer": {"value": "ok"}}, client=fake) + raw = await cast(Any, fake).get("map") + assert json.loads(raw) == { + "@class": "java.util.HashMap", + "outer": {"@class": "java.util.HashMap", "value": "ok"}, + } + assert await redis_get("map", fake) == {"outer": {"value": "ok"}} + + +@pytest.mark.asyncio +async def test_ota_report_keeps_numeric_timestamp_signatures_and_activation_ttl( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake = cast(Redis, FakeRedis()) + params = { + "server.websocket": "ws://one/xiaozhi/v1/", + "server.auth.enabled": "true", + "server.secret": "ws-secret", + "server.mqtt_gateway": "mqtt.example:1883", + "server.mqtt_signature_key": "mqtt-secret", + "server.fronted_url": "https://console.example", + } + + async def get_value(_: SystemParamService, code: str, *, from_cache: bool = True) -> str | None: + del from_cache + return params.get(code) + + monkeypatch.setattr(SystemParamService, "get_value", get_value) + report = DeviceReportRequest( + application=ApplicationInfo(version="1.0.0"), + board=BoardInfo(type="esp32-s3"), + ) + async with sqlite_session(DEVICE_SCHEMA) as session: + payload = await DeviceService(session, redis_client=fake).check_ota( + device_id="AA:BB:CC:DD:EE:FF", + client_id="client-one", + report=report, + request_url="http://manager/xiaozhi/ota/", + client_ip="127.0.0.1", + ) + assert isinstance(payload["server_time"]["timestamp"], int) + assert payload["firmware"]["url"].endswith("NOT_ACTIVATED_FIRMWARE_THIS_IS_A_INVALID_URL") + assert payload["activation"]["challenge"] == "AA:BB:CC:DD:EE:FF" + websocket_signature, websocket_timestamp = payload["websocket"]["token"].split(".") + assert websocket_signature + assert websocket_timestamp.isdigit() + mqtt = payload["mqtt"] + decoded_user = json.loads(__import__("base64").b64decode(mqtt["username"])) + assert decoded_user == {"ip": "127.0.0.1"} + assert mqtt["client_id"] == "GID_default@@@AA_BB_CC_DD_EE_FF@@@AA_BB_CC_DD_EE_FF" + activation_key = "ota:activation:data:aa_bb_cc_dd_ee_ff" + assert 86_390 <= await cast(Any, fake).ttl(activation_key) <= 86_400 + + +@pytest.mark.asyncio +async def test_address_book_lookup_matches_historical_server_consumer_contract() -> None: + fake = cast(Redis, FakeRedis()) + now = "2026-07-20 10:00:00" + async with sqlite_session(DEVICE_SCHEMA) as session: + await session.execute( + text( + "INSERT INTO ai_device_address_book " + "(mac_address,target_mac,alias,has_permission,create_date,update_date) " + "VALUES (:a,:b,'小明',1,:now,:now),(:b,:a,'客厅',1,:now,:now)" + ), + {"a": "AA:AA:AA:AA:AA:AA", "b": "BB:BB:BB:BB:BB:BB", "now": now}, + ) + await session.commit() + service = DeviceService(session, redis_client=fake) + await service.refresh_address_book_cache() + result = await service.lookup_address_book(caller_mac="AA:AA:AA:AA:AA:AA", nickname="小明") + assert result == { + "targetMac": "bb:bb:bb:bb:bb:bb", + "callerNickname": "客厅", + "hasPermission": "true", + } + + +@pytest.mark.asyncio +async def test_firmware_storage_md5_and_three_download_limit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + fake = cast(Redis, FakeRedis()) + content = b"firmware-bytes" + expected = f"uploadfile/{hashlib.md5(content, usedforsecurity=False).hexdigest()}.bin" + async with sqlite_session(DEVICE_SCHEMA) as session: + service = DeviceService(session, redis_client=fake) + stored = await service.save_firmware_file(filename="release.bin", content=content) + assert stored == expected + assert Path(stored).read_bytes() == content + await redis_set("ota:id:download", f"file:{stored}", client=fake) + for _ in range(3): + resolved = await service.resolve_ota_download("download") + assert resolved is not None + assert resolved[0].read_bytes() == content + assert resolved[1] == "assets_1.0.0.bin" + assert await service.resolve_ota_download("download") is None + assert await redis_get("ota:id:download", fake) is None + + +@pytest.mark.asyncio +async def test_firmware_download_route_preserves_binary_headers( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + fake = cast(Redis, FakeRedis()) + firmware = tmp_path / "release.bin" + firmware.write_bytes(b"binary-firmware") + await redis_set("ota:id:route-link", f"file:{firmware}", client=fake) + monkeypatch.setattr("app.services.device.get_redis", lambda: fake) + async with sqlite_session(DEVICE_SCHEMA) as session: + app = FastAPI() + app.include_router(device_router) + + async def override_db() -> Any: + yield session + + app.dependency_overrides[get_db] = override_db + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/otaMag/download/route-link") + assert response.status_code == 200 + assert response.content == b"binary-firmware" + assert response.headers["content-type"] == "application/octet-stream" + assert response.headers["content-length"] == str(len(response.content)) + assert response.headers["content-disposition"] == 'attachment; filename="assets_1.0.0.bin"' diff --git a/main/manager-api-fastapi/tests/test_java_route_manifest.py b/main/manager-api-fastapi/tests/test_java_route_manifest.py new file mode 100644 index 00000000..9f699e9f --- /dev/null +++ b/main/manager-api-fastapi/tests/test_java_route_manifest.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import json +import re +import subprocess +import sys +from collections import Counter +from pathlib import Path + +from app.main import app + +TARGET_ROOT = Path(__file__).resolve().parents[1] +MANIFEST = TARGET_ROOT / "compatibility" / "java-routes.json" +EXTRACTOR = TARGET_ROOT / "scripts" / "extract_java_routes.py" +PATH_PARAMETER = re.compile(r"\{[^/{}]+\}") + + +def live_manifest() -> dict[str, object]: + result = subprocess.run( # noqa: S603 + [sys.executable, str(EXTRACTOR)], + check=True, + capture_output=True, + text=True, + ) + return json.loads(result.stdout) + + +def test_checked_in_java_route_manifest_is_current() -> None: + checked_in = json.loads(MANIFEST.read_text(encoding="utf-8")) + assert checked_in == live_manifest() + + +def test_java_route_inventory_counts_are_closed() -> None: + payload = live_manifest() + routes = payload["routes"] + assert isinstance(routes, list) + assert payload["count"] == 154 + assert len({(route["method"], route["path"]) for route in routes}) == 154 + assert Counter(route["method"] for route in routes) == {"GET": 65, "POST": 52, "PUT": 23, "DELETE": 14} + assert Counter(route["auth"] for route in routes) == { + "database-token": 133, + "anonymous": 14, + "server-secret": 7, + } + + +def test_every_java_route_is_registered_by_fastapi() -> None: + routes = live_manifest()["routes"] + assert isinstance(routes, list) + expected = { + (str(route["method"]), PATH_PARAMETER.sub("{}", f"/xiaozhi{route['path']}")) + for route in routes + } + actual = { + (method, PATH_PARAMETER.sub("{}", route.path)) + for route in app.routes + for method in getattr(route, "methods", set()) + if method in {"GET", "POST", "PUT", "DELETE"} + and route.path.startswith("/xiaozhi") + and not route.path.startswith("/xiaozhi/health") + and route.path not in {"/xiaozhi/doc.html", "/xiaozhi/v3/api-docs"} + } + consumer_only = { + ("GET", "/xiaozhi/api/ping"), + ("GET", "/xiaozhi/device/address-book/lookup"), + ("PUT", "/xiaozhi/user/configDevice/{}"), + } + assert expected <= actual + assert actual - expected == consumer_only diff --git a/main/manager-api-fastapi/tests/test_knowledge_ragflow.py b/main/manager-api-fastapi/tests/test_knowledge_ragflow.py new file mode 100644 index 00000000..6a9bda11 --- /dev/null +++ b/main/manager-api-fastapi/tests/test_knowledge_ragflow.py @@ -0,0 +1,447 @@ +from __future__ import annotations + +import io +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import httpx +import pytest +import respx +from sqlalchemy import text +from starlette.datastructures import Headers, UploadFile +from starlette.requests import Request + +from app.core.errors import AppError +from app.core.responses import ok +from app.core.security import AuthUser +from app.integrations.ragflow import RAGFlowClient +from app.repositories.knowledge import KnowledgeRepository +from app.routers.knowledge import parse_documents +from app.schemas.knowledge import DocumentBatchBody, KnowledgeBaseBody, RetrievalBody +from app.services.knowledge import ( + KnowledgeBaseService, + KnowledgeDocumentService, + _document_delete_error, + dataset_dto, +) +from tests.domain_support import FakeRedis, sqlite_session + +USER = AuthUser(id=7, username="alice", super_admin=0, status=1, token=str(7), row={}) + +KNOWLEDGE_SCHEMA = [ + "CREATE TABLE ai_rag_dataset (id TEXT PRIMARY KEY,dataset_id TEXT,rag_model_id TEXT,tenant_id TEXT,name TEXT," + "avatar TEXT,description TEXT,embedding_model TEXT,permission TEXT,chunk_method TEXT,parser_config TEXT," + "chunk_count INTEGER,document_count INTEGER,token_num INTEGER,status INTEGER,creator INTEGER,created_at DATETIME," + "updater INTEGER,updated_at DATETIME)", +] + + +def test_ragflow_configuration_timeout_and_adapter_validation() -> None: + assert RAGFlowClient({"base_url": "https://rag.test", "api_key": "key"}).timeout == 30.0 + assert RAGFlowClient( + {"base_url": "https://rag.test", "api_key": "key", "timeout": "invalid"} + ).timeout == 30.0 + assert RAGFlowClient( + {"base_url": "https://rag.test", "api_key": "key", "timeout": "11"} + ).timeout == 11.0 + for config, code in ( + ({}, 10164), + ({"api_key": "key"}, 10171), + ({"base_url": "https://rag.test"}, 10172), + ({"base_url": "rag.test", "api_key": "key"}, 10174), + ({"type": "other", "base_url": "https://rag.test", "api_key": "key"}, 10184), + ): + with pytest.raises(AppError) as caught: + RAGFlowClient(config) + assert caught.value.code == code + + +@pytest.mark.asyncio +async def test_ragflow_json_wire_headers_query_and_error_mapping() -> None: + client = RAGFlowClient({"base_url": "https://rag.test", "api_key": "secret"}) + with respx.mock(assert_all_called=True) as mock: + route = mock.get("https://rag.test/query").mock( + return_value=httpx.Response(200, json={"code": 0, "data": {}}) + ) + await client.request("GET", "/query", params={"run": ["DONE", "FAIL"], "enabled": True}) + request = route.calls.last.request + assert request.headers["Authorization"] == "Bearer secret" + assert request.headers["Accept-Charset"] == "utf-8" + assert request.url.params["run"] == "[DONE, FAIL]" + assert request.url.params["enabled"] == "true" + + with respx.mock: + respx.get("https://rag.test/fail").mock( + return_value=httpx.Response(200, json={"code": 7, "message": "remote failed"}) + ) + with pytest.raises(AppError) as caught: + await client.request("GET", "/fail") + assert caught.value.code == 10167 and caught.value.params == ("remote failed",) + + with respx.mock: + respx.get("https://rag.test/bad-code").mock( + return_value=httpx.Response(200, json={"code": "0", "data": {}}) + ) + with pytest.raises(AppError) as caught: + await client.request("GET", "/bad-code") + assert caught.value.code == 10167 + + +@pytest.mark.asyncio +async def test_ragflow_upload_filters_invalid_chunk_method_and_parser_fields() -> None: + client = RAGFlowClient({"base_url": "https://rag.test", "api_key": "secret"}) + upload = UploadFile( + io.BytesIO(b"hello"), + filename="manual.txt", + headers=Headers({"content-type": "text/plain"}), + ) + with respx.mock(assert_all_called=True) as mock: + route = mock.post("https://rag.test/api/v1/datasets/dataset/documents").mock( + return_value=httpx.Response( + 200, + json={ + "code": 0, + "data": [{"id": "document", "name": "manual.txt", "run": "UNSTART"}], + }, + ) + ) + result = await client.upload_document( + "dataset", + upload, + b"hello", + name="display.txt", + meta_fields={"tag": "测试"}, + chunk_method="NOT-A-METHOD", + parser_config={"chunk_token_num": 64, "extra": "drop"}, + ) + request = route.calls.last.request + multipart = request.content + + assert result["id"] == "document" + assert request.headers["Authorization"] == "Bearer secret" + assert "Accept-Charset" not in request.headers + assert b'name="chunk_method"' not in multipart + assert b'"chunk_token_num":64' in multipart + assert b'"delimiter":null' in multipart + assert b'"extra"' not in multipart + assert "测试".encode() in multipart + + +@pytest.mark.asyncio +async def test_ragflow_dataset_response_uses_strong_info_dto_shape() -> None: + client = RAGFlowClient({"base_url": "https://rag.test", "api_key": "secret"}) + with respx.mock(assert_all_called=True) as mock: + mock.post("https://rag.test/api/v1/datasets").mock( + return_value=httpx.Response( + 200, + json={ + "code": 0, + "data": { + "id": "dataset", + "name": "alice_FAQ", + "chunk_count": "2", + "parser_config": {"chunk_token_num": 64, "unknown": "drop"}, + "unknown": "drop", + }, + }, + ) + ) + result = await client.create_dataset({"name": "alice_FAQ"}) + + assert result["chunk_count"] == 2 + assert "unknown" not in result + assert "unknown" not in result["parser_config"] + assert result["parser_config"]["delimiter"] is None + + +@pytest.mark.asyncio +async def test_ragflow_strong_chunk_and_retrieval_response_shapes() -> None: + client = RAGFlowClient({"base_url": "https://rag.test", "api_key": "secret"}) + with respx.mock(assert_all_called=True) as mock: + mock.route(method="GET").mock( + return_value=httpx.Response( + 200, + json={ + "code": 0, + "data": { + "chunks": [{"id": "chunk", "content": "text", "extra": "drop"}], + "doc": { + "id": "doc", + "chunk_count": 2147483648, + "parser_config": {"chunk_token_num": 128, "extra": "drop"}, + "run": "DONE", + "extra": "drop", + }, + "total": 1, + "extra": "drop", + }, + }, + ) + ) + chunks = await client.chunks("ds", "doc", {"page": 1, "page_size": 10}) + + mock.post("https://rag.test/api/v1/retrieval").mock( + return_value=httpx.Response( + 200, + json={ + "code": 0, + "data": { + "chunks": [{"id": "hit", "content": "answer", "extra": "drop"}], + "doc_aggs": [{"doc_name": "doc", "doc_id": "id", "count": 2, "extra": 1}], + "total": 1, + "meta_summary": {"total_tokens": 2147483648}, + }, + }, + ) + ) + retrieval = await client.retrieval({"dataset_ids": ["ds"], "question": "q"}) + + assert chunks["doc"]["chunk_count"] == "2147483648" + assert chunks["total"] == "1" + assert "extra" not in chunks["doc"] and "extra" not in chunks["chunks"][0] + assert chunks["chunks"][0]["document_id"] is None + assert chunks["doc"]["parser_config"]["delimiter"] is None + assert set(retrieval) == {"chunks", "doc_aggs", "total"} + assert retrieval["total"] == "1" + assert "extra" not in retrieval["chunks"][0] and "extra" not in retrieval["doc_aggs"][0] + assert retrieval["chunks"][0]["document_id"] is None + + +@pytest.mark.asyncio +async def test_retrieval_service_sends_snake_case_non_null_and_java_bounds() -> None: + repository = SimpleNamespace() + service = KnowledgeDocumentService(repository) # type: ignore[arg-type] + service.datasets.get_owned = AsyncMock(return_value={}) # type: ignore[method-assign] + client = SimpleNamespace(retrieval=AsyncMock(return_value={"chunks": [], "doc_aggs": [], "total": 0})) + service._client_for_dataset = AsyncMock(return_value=client) # type: ignore[method-assign] + + await service.retrieval( + "dataset", + RetrievalBody( + question="hello", + page=0, + page_size=0, + top_k=0, + similarity_threshold=-1, + highlight=False, + metadata_condition={"op": "and"}, + ), + USER, + ) + payload = client.retrieval.await_args.args[0] + assert payload == { + "dataset_ids": ["dataset"], + "question": "hello", + "page": 1, + "page_size": 100, + "similarity_threshold": 0.2, + "top_k": 1024, + "highlight": False, + "metadata_condition": {"op": "and"}, + } + + +def test_knowledge_long_null_and_batch_alias_contract() -> None: + dto = dataset_dto( + { + "id": "local", + "chunk_count": 2147483648, + "token_num": 2147483649, + "document_count": 5, + } + ) + assert dto["chunkCount"] == "2147483648" + assert dto["tokenNum"] == "2147483649" + assert dto["documentCount"] is None + assert DocumentBatchBody.model_validate({"document_ids": ["a"]}).ids == ["a"] + assert DocumentBatchBody.model_validate({"ids": ["b"]}).ids == ["b"] + assert DocumentBatchBody.model_validate({"documentIds": ["c"]}).ids is None + body = KnowledgeBaseBody.model_validate( + { + "creator": "2147483648", + "createdAt": "2026-07-20 10:00:00", + "updater": "2147483649", + "updatedAt": "2026-07-20 11:00:00", + "documentCount": 2, + "errorMessage": "error", + } + ) + assert body.creator == 2147483648 and body.updater == 2147483649 + assert body.created_at is not None and body.updated_at is not None + assert body.document_count == 2 and body.error_message == "error" + + +@pytest.mark.asyncio +async def test_rag_model_config_keeps_java_jsonobject_snake_case_keys() -> None: + repository = SimpleNamespace( + rag_models=AsyncMock( + return_value=[ + { + "id": "RAG_RAGFlow", + "model_name": "RAGFlow", + "config_json": '{"type":"ragflow","base_url":"http://localhost","api_key":"secret"}', + } + ] + ) + ) + result = await KnowledgeBaseService(repository).rag_models() # type: ignore[arg-type] + payload = json.loads(ok(result).body) + assert payload["data"][0]["configJson"] == { + "type": "ragflow", + "base_url": "http://localhost", + "api_key": "secret", + } + + +@pytest.mark.asyncio +async def test_parse_documents_checks_missing_dataset_before_empty_document_ids() -> None: + async with sqlite_session(KNOWLEDGE_SCHEMA) as session: + request = Request( + { + "type": "http", + "method": "POST", + "path": "/datasets/missing/chunks", + "headers": [], + } + ) + request.state.user = USER + with pytest.raises(AppError) as caught: + await parse_documents("missing", {}, request, session) + assert caught.value.code == 10163 + + +@pytest.mark.asyncio +async def test_dataset_update_preserves_db_null_columns_but_returns_java_in_memory_nulls( + monkeypatch: pytest.MonkeyPatch, +) -> None: + redis = FakeRedis() + monkeypatch.setattr("app.services.knowledge.get_redis", lambda: redis) + async with sqlite_session(KNOWLEDGE_SCHEMA) as session: + await session.execute( + text( + "INSERT INTO ai_rag_dataset VALUES " + "('local','remote','rag','tenant','FAQ','avatar','desc','embed','me','naive','{}'," + "3,2,10,1,7,'2026-01-01 00:00:00',7,'2026-01-01 00:00:00')" + ) + ) + await session.commit() + + result = await KnowledgeBaseService(KnowledgeRepository(session)).update( + "remote", KnowledgeBaseBody(), USER + ) + stored = ( + await session.execute( + text( + "SELECT name,avatar,description,embedding_model,permission,chunk_method,creator,created_at " + "FROM ai_rag_dataset WHERE id='local'" + ) + ) + ).one() + + assert tuple(stored[:7]) == ("FAQ", "avatar", "desc", "embed", "me", "naive", 7) + assert stored.created_at is not None + assert result["datasetId"] == "remote" + assert result["name"] is None and result["permission"] is None + assert result["creator"] is None and result["createdAt"] is None + assert result["updater"] == USER.id and result["updatedAt"] is not None + + +@pytest.mark.asyncio +async def test_dataset_update_rejects_dataset_id_conflict_before_remote_call() -> None: + repository = SimpleNamespace( + get_dataset=AsyncMock(return_value={"id": "local", "creator": USER.id, "dataset_id": "old"}), + duplicate_dataset_name=AsyncMock(return_value=False), + dataset_id_conflict=AsyncMock(return_value=True), + ) + service = KnowledgeBaseService(repository) # type: ignore[arg-type] + with pytest.raises(AppError) as caught: + await service.update("conflicting", KnowledgeBaseBody(), USER) + assert caught.value.code == 10002 + + +@pytest.mark.asyncio +async def test_dataset_create_generic_db_failure_rolls_back_remote_and_maps_10167() -> None: + session = SimpleNamespace(commit=AsyncMock(), rollback=AsyncMock()) + repository = SimpleNamespace( + session=session, + duplicate_dataset_name=AsyncMock(return_value=False), + insert_dataset=AsyncMock(side_effect=RuntimeError("db failed")), + ) + client = SimpleNamespace( + create_dataset=AsyncMock(return_value={"id": "remote-id"}), + delete_datasets=AsyncMock(return_value=None), + ) + service = KnowledgeBaseService(repository) # type: ignore[arg-type] + service._client = AsyncMock(return_value=client) # type: ignore[method-assign] + with pytest.raises(AppError) as caught: + await service.create(KnowledgeBaseBody(name="FAQ", rag_model_id="rag"), USER) + + assert caught.value.code == 10167 + assert caught.value.params == ("创建知识库失败: db failed",) + session.rollback.assert_awaited_once() + client.delete_datasets.assert_awaited_once_with(["remote-id"]) + + +@pytest.mark.asyncio +async def test_running_document_missing_remotely_is_cancelled_idempotently() -> None: + session = SimpleNamespace(commit=AsyncMock(), rollback=AsyncMock()) + repository = SimpleNamespace( + session=session, + running_documents=AsyncMock( + return_value=[{"dataset_id": "ds", "document_id": "doc", "token_count": 3}] + ), + mark_document_remote_deleted=AsyncMock(return_value=1), + ) + client = SimpleNamespace(documents=AsyncMock(return_value=([], 0))) + service = KnowledgeDocumentService(repository) # type: ignore[arg-type] + service._client_for_dataset = AsyncMock(return_value=client) # type: ignore[method-assign] + + assert await service.sync_running() == 1 + repository.mark_document_remote_deleted.assert_awaited_once() + session.commit.assert_awaited_once() + session.rollback.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_running_sync_updates_only_java_status_columns_and_token_delta() -> None: + session = SimpleNamespace(commit=AsyncMock(), rollback=AsyncMock()) + repository = SimpleNamespace( + session=session, + running_documents=AsyncMock( + return_value=[ + { + "dataset_id": "ds", + "document_id": "doc", + "status": "1", + "run": "RUNNING", + "token_count": 3, + } + ] + ), + sync_running_document=AsyncMock(return_value=1), + update_stats=AsyncMock(return_value=None), + ) + remote = { + "id": "doc", + "status": "1", + "run": "DONE", + "token_count": 7, + "name": "must-not-be-written-by-status-sync", + } + client = SimpleNamespace(documents=AsyncMock(return_value=([remote], 1))) + service = KnowledgeDocumentService(repository) # type: ignore[arg-type] + service._client_for_dataset = AsyncMock(return_value=client) # type: ignore[method-assign] + + assert await service.sync_running() == 1 + synced_remote = repository.sync_running_document.await_args.args[2] + assert synced_remote is remote + repository.update_stats.assert_awaited_once_with("ds", 0, 0, 4) + session.commit.assert_awaited_once() + + +def test_document_delete_remote_error_is_wrapped_as_java_code_500() -> None: + translated = _document_delete_error(AppError(10167, params=("remote failed",)), "en-US") + plain = _document_delete_error(RuntimeError("network down"), None) + assert translated.code == 500 and translated.message and "remote failed" in translated.message + assert (plain.code, plain.message) == (500, "network down") diff --git a/main/manager-api-fastapi/tests/test_model_timbre_correctword.py b/main/manager-api-fastapi/tests/test_model_timbre_correctword.py new file mode 100644 index 00000000..cee6cef5 --- /dev/null +++ b/main/manager-api-fastapi/tests/test_model_timbre_correctword.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +import json +from datetime import datetime + +import pytest +from sqlalchemy import text +from starlette.requests import Request +from starlette.routing import Match + +from app.core.errors import AppError +from app.core.security import AuthUser +from app.main import app +from app.repositories.correctword import CorrectWordRepository +from app.repositories.model import ModelRepository +from app.repositories.timbre import TimbreRepository +from app.routers.correctword import _java_urlencode, download_file +from app.schemas.correctword import CorrectWordFileBody +from app.schemas.model import ModelConfigBody, ModelProviderBody +from app.schemas.timbre import TimbreBody +from app.services.correctword import CorrectWordService, file_vo +from app.services.model import ModelProviderService, ModelService, _mask_middle +from app.services.timbre import TimbreService, _details, _validation_message +from tests.domain_support import FakeRedis, sqlite_session + +USER = AuthUser(id=2147483648, username="tester", super_admin=1, status=1, token=str(2147483648), row={}) + +MODEL_SCHEMA = [ + "CREATE TABLE ai_model_config (id TEXT PRIMARY KEY, model_type TEXT, model_code TEXT, model_name TEXT, " + "is_default INTEGER DEFAULT 0, is_enabled INTEGER DEFAULT 0, config_json TEXT, doc_link TEXT, remark TEXT, " + "sort INTEGER DEFAULT 0)", + "CREATE TABLE ai_model_provider (id TEXT PRIMARY KEY, model_type TEXT, provider_code TEXT, name TEXT, " + "fields TEXT, sort INTEGER, creator INTEGER, create_date DATETIME, updater INTEGER, update_date DATETIME)", +] + +TIMBRE_SCHEMA = [ + "CREATE TABLE ai_tts_voice (id TEXT PRIMARY KEY, languages TEXT, name TEXT, remark TEXT, " + "reference_audio TEXT, reference_text TEXT, sort INTEGER, tts_model_id TEXT, tts_voice TEXT, " + "voice_demo TEXT, creator INTEGER, create_date DATETIME, updater INTEGER, update_date DATETIME)", +] + +CORRECTWORD_SCHEMA = [ + "CREATE TABLE ai_agent_correct_word_file (id TEXT PRIMARY KEY, file_name TEXT, word_count INTEGER, " + "content TEXT, creator INTEGER, created_at DATETIME, updater INTEGER, updated_at DATETIME)", + "CREATE TABLE ai_agent_correct_word_item (id TEXT PRIMARY KEY, file_id TEXT, source_word TEXT, target_word TEXT)", + "CREATE TABLE ai_agent_correct_word_mapping (id TEXT, agent_id TEXT, file_id TEXT)", +] + + +def test_model_enable_specific_route_precedes_generic_model_edit() -> None: + scope = {"type": "http", "method": "PUT", "path": "/xiaozhi/models/enable/model/0"} + endpoint_name = None + for route in app.routes: + match, _ = route.matches(scope) + if match is Match.FULL: + endpoint_name = route.endpoint.__name__ + break + assert endpoint_name == "model_enable" + + +@pytest.mark.asyncio +async def test_model_edit_preserves_not_null_columns_but_returns_request_nulls( + monkeypatch: pytest.MonkeyPatch, +) -> None: + redis = FakeRedis() + monkeypatch.setattr("app.services.model.get_redis", lambda: redis) + async with sqlite_session(MODEL_SCHEMA) as session: + await session.execute( + text( + "INSERT INTO ai_model_provider VALUES " + "('provider', 'LLM', 'openai', 'OpenAI', '{}', 1, 1, NULL, 1, NULL)" + ) + ) + await session.execute( + text( + "INSERT INTO ai_model_config VALUES " + "('model', 'LLM', 'openai', 'Original', 0, 1, " + "'{\"api_key\":\"secret-value\",\"model\":\"gpt\"}', 'doc', 'remark', 9)" + ) + ) + await session.commit() + + result = await ModelService(ModelRepository(session)).edit( + "LLM", + "openai", + "model", + ModelConfigBody(config_json={"api_key": "secr****alue", "model": "new"}), + ) + stored = ( + await session.execute( + text("SELECT model_name,is_enabled,remark,sort,config_json FROM ai_model_config WHERE id='model'") + ) + ).mappings().one() + + assert result["modelName"] is None and result["isEnabled"] is None and result["sort"] is None + assert stored["model_name"] == "Original" and stored["is_enabled"] == 1 + assert stored["remark"] == "remark" and stored["sort"] == 9 + assert json.loads(stored["config_json"]) == {"api_key": "secret-value", "model": "new"} + assert _mask_middle(" ") == " " + assert "model:data:model" not in redis.values + + +@pytest.mark.asyncio +async def test_model_insert_defaults_and_provider_generated_id_response_match_java() -> None: + async with sqlite_session(MODEL_SCHEMA) as session: + await session.execute( + text( + "INSERT INTO ai_model_provider VALUES " + "('provider', 'ASR', 'mock', 'Mock', '{}', 1, 1, NULL, 1, NULL)" + ) + ) + await session.commit() + model = await ModelService(ModelRepository(session)).add( + "ASR", "mock", ModelConfigBody(model_code="mock", model_name="ASR") + ) + stored_defaults = ( + await session.execute(text("SELECT is_enabled,sort FROM ai_model_config WHERE id=:id"), {"id": model["id"]}) + ).one() + # The assertion query starts its own read transaction; close it before + # exercising the next independently scoped service call. + await session.commit() + provider = await ModelProviderService(ModelRepository(session)).add( + ModelProviderBody( + model_type="TTS", provider_code="new-provider", name="New", fields="not-json", sort=2 + ), + USER, + ) + + assert model["isEnabled"] is None and model["sort"] is None + assert tuple(stored_defaults) == (0, 0) + assert provider["id"] is None + provider_count = await session.scalar( + text("SELECT COUNT(*) FROM ai_model_provider WHERE provider_code='new-provider'") + ) + assert provider_count == 1 + + +def test_timbre_validation_is_localized_code_500_and_long_sort_is_string() -> None: + with pytest.raises(AppError) as caught: + TimbreService._validate(TimbreBody(), "en-US") + assert caught.value.code == 500 + assert caught.value.message == _validation_message("timbre.languages.require", "en-US") + assert _details({"sort": 2147483648})["sort"] == "2147483648" + assert _details({"sort": None})["sort"] == "0" + + +@pytest.mark.asyncio +async def test_timbre_update_preserves_nullable_columns_and_clears_java_cache_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + redis = FakeRedis() + await redis.set("timbre:details:voice", b"cached") + monkeypatch.setattr("app.services.timbre.get_redis", lambda: redis) + async with sqlite_session(TIMBRE_SCHEMA) as session: + await session.execute( + text( + "INSERT INTO ai_tts_voice VALUES " + "('voice','zh','Old','remark','ref.wav','hello',7,'tts','old-code','demo.wav',1,NULL,NULL,NULL)" + ) + ) + await session.commit() + await TimbreService(TimbreRepository(session)).update( + "voice", + TimbreBody(languages="en", name="New", tts_model_id="tts", tts_voice="new-code", sort=None), + USER, + "en-US", + ) + row = ( + await session.execute( + text( + "SELECT languages,name,remark,reference_audio,reference_text,sort,tts_voice,voice_demo " + "FROM ai_tts_voice WHERE id='voice'" + ) + ) + ).one() + assert tuple(row) == ("en", "New", "remark", "ref.wav", "hello", 0, "new-code", "demo.wav") + assert await redis.get("timbre:details:voice") is None + + +def test_correctword_validation_size_and_java_line_semantics() -> None: + with pytest.raises(AppError) as missing: + CorrectWordService.validate(CorrectWordFileBody(), check_size=True) + assert (missing.value.code, missing.value.message) == (10034, "文件名不能为空") + + oversized = CorrectWordFileBody(file_name="words.txt", content=["a|b"], file_size=1024 * 1024 + 1) + with pytest.raises(AppError) as too_large: + CorrectWordService.validate(oversized, check_size=True) + assert too_large.value.code == 10204 + CorrectWordService.validate(oversized, check_size=False) + assert file_vo({"content": ""})["content"] == [""] + assert file_vo({"content": "a|b\n"})["content"] == ["a|b"] + + +@pytest.mark.asyncio +async def test_correctword_empty_download_and_java_content_disposition() -> None: + async with sqlite_session(CORRECTWORD_SCHEMA) as session: + await session.execute( + text( + "INSERT INTO ai_agent_correct_word_file VALUES " + "('file','中~* a.txt',0,'',2147483648,:now,NULL,NULL)" + ), + {"now": datetime(2026, 7, 20, 10, 0, 0)}, + ) + await session.commit() + request = Request({"type": "http", "method": "GET", "path": "/", "headers": []}) + request.state.user = USER + response = await download_file("file", request, session) + + assert response.status_code == 200 and response.body == b"" + assert response.media_type == "application/octet-stream" + assert response.headers["content-length"] == "0" + assert response.headers["content-disposition"] == ( + "attachment; filename=\"_~* a.txt\"; filename*=UTF-8''%E4%B8%AD%7E*%20a.txt" + ) + assert _java_urlencode("中~* a.txt") == "%E4%B8%AD%7E*%20a.txt" + + +@pytest.mark.asyncio +async def test_correctword_update_accepts_large_declared_size() -> None: + async with sqlite_session(CORRECTWORD_SCHEMA) as session: + await session.execute( + text( + "INSERT INTO ai_agent_correct_word_file VALUES " + "('file','old.txt',1,'a|b',2147483648,NULL,NULL,NULL)" + ) + ) + await session.execute(text("INSERT INTO ai_agent_correct_word_item VALUES ('item','file','a','b')")) + await session.commit() + await CorrectWordService(CorrectWordRepository(session)).update( + "file", + CorrectWordFileBody( + file_name="new.txt", content=["a|b", "invalid", " c | d "], file_size=2 * 1024 * 1024 + ), + USER, + ) + row = ( + await session.execute( + text("SELECT file_name,word_count,content FROM ai_agent_correct_word_file WHERE id='file'") + ) + ).one() + assert tuple(row) == ("new.txt", 2, "a|b\ninvalid\n c | d ") + assert await session.scalar(text("SELECT COUNT(*) FROM ai_agent_correct_word_item WHERE file_id='file'")) == 2 diff --git a/main/manager-api-fastapi/tests/test_redis_java_interop.py b/main/manager-api-fastapi/tests/test_redis_java_interop.py new file mode 100644 index 00000000..da821a65 --- /dev/null +++ b/main/manager-api-fastapi/tests/test_redis_java_interop.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import json +import subprocess +from datetime import datetime +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +import pytest + +from app.core.redis import JavaRedisCodec + +TARGET_ROOT = Path(__file__).resolve().parents[1] +REPOSITORY_ROOT = TARGET_ROOT.parents[1] +JAVA_PROJECT = REPOSITORY_ROOT / "main" / "manager-api" +MAVEN = REPOSITORY_ROOT / ".runtime" / "maven" / "bin" / "mvn" +MAVEN_REPOSITORY = REPOSITORY_ROOT / ".runtime" / "m2" +JAVA = REPOSITORY_ROOT / ".runtime" / "jdk" / "bin" + + +@pytest.fixture(scope="module") +def java_redis_vectors(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Any]: + work = tmp_path_factory.mktemp("redis-java") + classpath_file = work / "classpath.txt" + subprocess.run( # noqa: S603 + [ + str(MAVEN), + "-o", + "-q", + f"-Dmaven.repo.local={MAVEN_REPOSITORY}", + "-DskipTests", + "compile", + "dependency:build-classpath", + f"-Dmdep.outputFile={classpath_file}", + ], + cwd=JAVA_PROJECT, + check=True, + capture_output=True, + text=True, + ) + classpath = f"{JAVA_PROJECT / 'target' / 'classes'}:{classpath_file.read_text(encoding='utf-8').strip()}" + subprocess.run( # noqa: S603 + [ + str(JAVA / "javac"), + "-cp", + classpath, + "-d", + str(work), + str(TARGET_ROOT / "tests" / "java" / "RedisCodecVectors.java"), + ], + check=True, + capture_output=True, + text=True, + ) + result = subprocess.run( # noqa: S603 + [str(JAVA / "java"), "-cp", f"{work}:{classpath}", "RedisCodecVectors"], + check=True, + capture_output=True, + text=True, + ) + vectors: dict[str, Any] = {} + for line in result.stdout.splitlines(): + name, wire, _decoded_class = line.split("\t", 2) + vectors[name] = json.loads(wire) + return vectors + + +def _python_wire(value: Any, **kwargs: Any) -> Any: + return json.loads(JavaRedisCodec.encode(value, **kwargs)) + + +def test_spring_redis_serializer_core_wire_format_matches_python(java_redis_vectors: dict[str, Any]) -> None: + assert java_redis_vectors["string"] == _python_wire("hello") + assert java_redis_vectors["integer"] == _python_wire(7) + assert java_redis_vectors["long"] == _python_wire(2_147_483_648) + assert java_redis_vectors["boolean"] == _python_wire(True) + nested = {"enabled": True} + assert java_redis_vectors["map"] == _python_wire( + { + "text": "hello", + "number": 2_147_483_648, + "list": ["a", "b"], + "nested": nested, + } + ) + assert java_redis_vectors["list"] == _python_wire(["a", 2_147_483_648, nested]) + epoch_in_shanghai = datetime.fromtimestamp(0, ZoneInfo("Asia/Shanghai")).replace(tzinfo=None) + assert java_redis_vectors["date"] == _python_wire(epoch_in_shanghai) + + +def test_spring_redis_serializer_domain_pojo_wire_format_matches_python(java_redis_vectors: dict[str, Any]) -> None: + assert java_redis_vectors["pojo"] == _python_wire( + { + "id": "voice-1", + "languages": None, + "name": "sample", + "remark": None, + "reference_audio": None, + "reference_text": None, + "sort": 2, + "tts_model_id": None, + "tts_voice": None, + "voice_demo": None, + }, + java_type="xiaozhi.modules.timbre.vo.TimbreDetailsVO", + field_java_types={"sort": "java.lang.Long"}, + ) + assert java_redis_vectors["model-pojo"] == _python_wire( + { + "id": "model-1", + "model_type": "LLM", + "model_code": None, + "model_name": None, + "is_default": None, + "is_enabled": None, + "config_json": {"type": "openai", "api_key": "secret"}, + "doc_link": None, + "remark": None, + "sort": None, + "updater": None, + "update_date": None, + "creator": None, + "create_date": None, + }, + java_type="xiaozhi.modules.model.entity.ModelConfigEntity", + field_java_types={ + "configJson": "cn.hutool.json.JSONObject", + "creator": "java.lang.Long", + "updater": "java.lang.Long", + }, + ) + assert java_redis_vectors["dict-list"] == _python_wire( + [{"name": "China", "key": "+86"}], + item_java_type="xiaozhi.modules.sys.vo.SysDictDataItem", + ) diff --git a/main/manager-api-fastapi/tests/test_schema_alias_compatibility.py b/main/manager-api-fastapi/tests/test_schema_alias_compatibility.py new file mode 100644 index 00000000..9f5d5f86 --- /dev/null +++ b/main/manager-api-fastapi/tests/test_schema_alias_compatibility.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import warnings + +from fastapi._compat import get_model_fields + +from app.schemas.correctword import CorrectWordFileBody +from app.schemas.device import DeviceReportRequest, DeviceUpdateRequest +from app.schemas.model import ModelProviderBody + + +def test_fastapi_can_rebuild_all_alias_fields_without_ineffective_metadata_warnings() -> None: + # FastAPI reconstructs each body model field through TypeAdapter on first + # request. Keep this explicit regression because an unconstrained future + # Pydantic upgrade emitted warnings for every aliased field on that path. + with warnings.catch_warnings(): + warnings.simplefilter("error") + for model in (DeviceUpdateRequest, ModelProviderBody, DeviceReportRequest, CorrectWordFileBody): + assert get_model_fields(model) + + +def test_representative_java_aliases_bind_and_serialize_in_both_supported_input_forms() -> None: + update = DeviceUpdateRequest.model_validate({"autoUpdate": 1}) + assert update.auto_update == 1 + assert update.model_dump(by_alias=True)["autoUpdate"] == 1 + + provider = ModelProviderBody.model_validate({"modelType": "LLM", "providerCode": "mock"}) + assert (provider.model_type, provider.provider_code) == ("LLM", "mock") + + camel_report = DeviceReportRequest.model_validate({"flashSize": 8, "chipModelName": "esp32"}) + snake_report = DeviceReportRequest.model_validate({"flash_size": 8, "chip_model_name": "esp32"}) + assert camel_report.flash_size == snake_report.flash_size == 8 + assert camel_report.chip_model_name == snake_report.chip_model_name == "esp32" + + words = CorrectWordFileBody.model_validate({"fileName": "words.txt", "fileSize": 12}) + assert words.file_name == "words.txt" + assert words.file_size == 12 diff --git a/main/manager-api-fastapi/tests/test_security_domain.py b/main/manager-api-fastapi/tests/test_security_domain.py new file mode 100644 index 00000000..5e1c51be --- /dev/null +++ b/main/manager-api-fastapi/tests/test_security_domain.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import json +import urllib.parse +from datetime import datetime +from pathlib import Path +from typing import Any + +import httpx +import pytest +from fastapi import Request +from sqlalchemy import text + +from app.core.crypto import bcrypt_hash, sm2_encrypt_c1c3c2 +from app.core.errors import AppError +from app.core.redis import JavaRedisCodec +from app.core.security import AuthUser +from app.repositories.security import SecurityRepository +from app.routers.security import security_router +from app.schemas.security import ( + LoginRequest, + PasswordChangeRequest, + RetrievePasswordRequest, + SmsVerificationRequest, +) +from app.services.security import AliyunSmsSender, CaptchaService, SecurityService +from tests.domain_support import FakeRedis, sqlite_session + +SECURITY_SCHEMA = [ + "CREATE TABLE sys_params (param_code TEXT PRIMARY KEY, param_value TEXT)", + "CREATE TABLE sys_user (" + "id INTEGER PRIMARY KEY, username TEXT UNIQUE, password TEXT, super_admin INTEGER, status INTEGER, " + "creator INTEGER, create_date DATETIME, updater INTEGER, update_date DATETIME)", + "CREATE TABLE sys_user_token (" + "id INTEGER PRIMARY KEY, user_id INTEGER UNIQUE, token TEXT, expire_date DATETIME, " + "update_date DATETIME, create_date DATETIME)", + "CREATE TABLE sys_dict_type (id INTEGER PRIMARY KEY, dict_type TEXT)", + "CREATE TABLE sys_dict_data (" + "id INTEGER PRIMARY KEY, dict_type_id INTEGER, dict_label TEXT, dict_value TEXT, sort INTEGER)", +] +SM2_VECTOR = json.loads((Path(__file__).parent / "fixtures" / "sm2-c1c3c2-golden.json").read_text(encoding="utf-8")) + + +def _request() -> Request: + return Request( + { + "type": "http", + "method": "POST", + "path": "/xiaozhi/user/login", + "headers": [(b"user-agent", b"Manager-Web"), (b"x-forwarded-for", b"192.0.2.10")], + "client": ("127.0.0.1", 52341), + } + ) + + +@pytest.mark.asyncio +async def test_sm2_login_reuses_token_and_password_change_expires_it() -> None: + public_key, private_key = SM2_VECTOR["publicKey"], SM2_VECTOR["privateKey"] + redis = FakeRedis() + async with sqlite_session(SECURITY_SCHEMA) as session: + await session.execute( + text("INSERT INTO sys_params VALUES ('server.private_key', :private_key)"), + {"private_key": private_key}, + ) + await session.execute( + text( + "INSERT INTO sys_user " + "(id, username, password, super_admin, status) VALUES (1, 'alice', :password, 1, 1)" + ), + {"password": bcrypt_hash("StrongPass1", rounds=4)}, + ) + await session.commit() + + captcha = CaptchaService(redis) # type: ignore[arg-type] + service = SecurityService( + SecurityRepository(session), + redis=redis, # type: ignore[arg-type] + captcha=captcha, + ) + await redis.set("sys:captcha:first", JavaRedisCodec.encode("Ab12C"), ex=300) + first = await service.login( + LoginRequest( + username="alice", + password=sm2_encrypt_c1c3c2(public_key, "Ab12CStrongPass1"), + captcha_id="first", + ), + _request(), + ) + assert len(first["token"]) == 32 + assert len(first["clientHash"]) == 32 + assert await redis.get("sys:captcha:first") is None + + await redis.set("sys:captcha:second", JavaRedisCodec.encode("Z9x8Y"), ex=300) + second = await service.login( + LoginRequest( + username="alice", + password=sm2_encrypt_c1c3c2(public_key, "Z9x8YStrongPass1"), + captcha_id="second", + ), + _request(), + ) + assert second["token"] == first["token"] + + auth = AuthUser(1, "alice", 1, 1, first["token"], {}) + await service.change_password( + auth, + PasswordChangeRequest(password="StrongPass1", new_password="NewStrong2"), # noqa: S106 + ) + expiry = await session.scalar(text("SELECT expire_date FROM sys_user_token WHERE user_id = 1")) + assert expiry is not None + parsed_expiry = datetime.fromisoformat(expiry) if isinstance(expiry, str) else expiry + assert parsed_expiry < datetime.now() + + await redis.set("sys:captcha:bad", JavaRedisCodec.encode("right"), ex=300) + with pytest.raises(AppError) as captured: + await service.login( + LoginRequest( + username="alice", + password=sm2_encrypt_c1c3c2(public_key, "wrongNewStrong2"), + captcha_id="bad", + ), + _request(), + ) + assert captured.value.code == 10067 + assert await redis.get("sys:captcha:bad") is None + + with pytest.raises(AppError) as missing_password: + await service.login(LoginRequest(username="alice"), _request()) + assert missing_password.value.code == 10130 + + with pytest.raises(AppError) as manual_validation: + await service.change_password(auth, PasswordChangeRequest(), "en-US") + assert manual_validation.value.code == 500 + assert manual_validation.value.message == "The password cannot be empty" + + await session.execute( + text( + "INSERT INTO sys_params VALUES " + "('server.enable_mobile_register', 'true'), ('server.public_key', 'unused')" + ) + ) + await session.commit() + with pytest.raises(AppError) as retrieve_validation: + await service.retrieve_password(RetrievePasswordRequest(), "de-DE") + assert retrieve_validation.value.code == 500 + assert retrieve_validation.value.message == "Das Passwort darf nicht leer sein" + + +class _RecordingSmsSender: + def __init__(self) -> None: + self.calls: list[tuple[str, str]] = [] + + async def send_verification_code(self, phone: str | None, code: str) -> None: + assert phone is not None + self.calls.append((phone, code)) + + +@pytest.mark.asyncio +async def test_sms_rate_limit_cache_ttl_and_aliyun_rpc_shape() -> None: + redis = FakeRedis() + sender = _RecordingSmsSender() + async with sqlite_session(SECURITY_SCHEMA) as session: + await session.execute( + text( + "INSERT INTO sys_params VALUES " + "('server.enable_mobile_register', 'true')," + "('server.sms_max_send_count', '2')," + "('aliyun.sms.access_key_id', 'test-id')," + "('aliyun.sms.access_key_secret', 'test-secret')," + "('aliyun.sms.sign_name', 'test-sign')," + "('aliyun.sms.sms_code_template_code', 'SMS_123')" + ) + ) + await session.commit() + await redis.set("sys:captcha:sms-flow", JavaRedisCodec.encode("A1b2C"), ex=300) + service = SecurityService( + SecurityRepository(session), + redis=redis, # type: ignore[arg-type] + captcha=CaptchaService(redis), # type: ignore[arg-type] + sms_sender=sender, + ) + with pytest.raises(AppError) as missing_sms_fields: + await service.send_sms_verification(SmsVerificationRequest()) + assert missing_sms_fields.value.code == 10067 + + dto = SmsVerificationRequest(phone="+8613800138000", captcha="a1B2c", captcha_id="sms-flow") + await service.send_sms_verification(dto) + + assert len(sender.calls) == 1 + assert sender.calls[0][0] == "+8613800138000" + assert sender.calls[0][1].isdigit() and len(sender.calls[0][1]) == 6 + cached_code = JavaRedisCodec.decode(await redis.get("sys:captcha:sms:Validate:Code:+8613800138000")) + assert cached_code == sender.calls[0][1] + assert 0 < await redis.ttl("sms:Validate:Code:+8613800138000:today_count") <= 86400 + + with pytest.raises(AppError) as captured: + await service.send_sms_verification(dto) + assert captured.value.code == 10060 + + recorded: dict[str, Any] = {} + + async def handler(request: httpx.Request) -> httpx.Response: + recorded["params"] = dict(urllib.parse.parse_qsl((await request.aread()).decode())) + return httpx.Response(200, json={"Code": "OK"}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + aliyun = AliyunSmsSender( + SecurityRepository(session), + redis=redis, # type: ignore[arg-type] + client=client, + ) + await aliyun.send_verification_code("+8613800138000", "123456") + params = recorded["params"] + assert params["Action"] == "SendSms" + assert params["TemplateParam"] == '{"code":"123456"}' + assert params["Signature"] + + await session.execute( + text( + "UPDATE sys_params SET param_value = '' " + "WHERE param_code IN ('aliyun.sms.access_key_id', 'aliyun.sms.access_key_secret')" + ) + ) + await session.commit() + blank_credentials = AliyunSmsSender( + SecurityRepository(session), + redis=FakeRedis(), # type: ignore[arg-type] + ) + with pytest.raises(AppError) as connection_error: + await blank_credentials.send_verification_code("+8613800138000", "123456") + assert connection_error.value.code == 10056 + + +def test_security_router_exposes_all_login_controller_routes_and_ping() -> None: + routes = {(next(iter(route.methods)), route.path) for route in security_router.routes} + assert len(routes) == 9 + assert ("POST", "/user/login") in routes + assert ("GET", "/user/captcha") in routes + assert ("GET", "/api/ping") in routes diff --git a/main/manager-api-fastapi/tests/test_sm2_interop.py b/main/manager-api-fastapi/tests/test_sm2_interop.py new file mode 100644 index 00000000..ae4f81db --- /dev/null +++ b/main/manager-api-fastapi/tests/test_sm2_interop.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from app.core.crypto import sm2_decrypt_c1c3c2, sm2_encrypt_c1c3c2 + +TARGET_ROOT = Path(__file__).resolve().parents[1] +REPOSITORY_ROOT = TARGET_ROOT.parents[1] +VECTOR_PATH = TARGET_ROOT / "tests" / "fixtures" / "sm2-c1c3c2-golden.json" +BCPROV = ( + REPOSITORY_ROOT + / ".runtime" + / "m2" + / "org" + / "bouncycastle" + / "bcprov-jdk18on" + / "1.78" + / "bcprov-jdk18on-1.78.jar" +) +JAVA_HOME = REPOSITORY_ROOT / ".runtime" / "jdk" / "bin" + + +@pytest.fixture(scope="module") +def vector() -> dict[str, str]: + return json.loads(VECTOR_PATH.read_text(encoding="utf-8")) + + +@pytest.fixture(scope="module") +def java_classpath(tmp_path_factory: pytest.TempPathFactory) -> str: + classes = tmp_path_factory.mktemp("sm2-java") + command = [ + str(JAVA_HOME / "javac"), + "-cp", + str(BCPROV), + "-d", + str(classes), + str( + REPOSITORY_ROOT + / "main" + / "manager-api" + / "src" + / "main" + / "java" + / "xiaozhi" + / "common" + / "utils" + / "SM2Utils.java" + ), + str(TARGET_ROOT / "tests" / "java" / "Sm2InteropCli.java"), + ] + subprocess.run(command, check=True, capture_output=True, text=True) # noqa: S603 + return f"{classes}:{BCPROV}" + + +def java_sm2(classpath: str, operation: str, key: str, value: str) -> str: + result = subprocess.run( # noqa: S603 + [str(JAVA_HOME / "java"), "-cp", classpath, "Sm2InteropCli", operation, key, value], + check=True, + capture_output=True, + text=True, + ) + return result.stdout + + +def test_static_java_and_python_vectors_decrypt_in_python(vector: dict[str, str]) -> None: + assert sm2_decrypt_c1c3c2(vector["privateKey"], vector["javaCiphertext"]) == vector["plaintext"] + assert sm2_decrypt_c1c3c2(vector["privateKey"], vector["pythonCiphertext"]) == vector["plaintext"] + + +def test_static_java_and_python_vectors_decrypt_in_java(vector: dict[str, str], java_classpath: str) -> None: + assert java_sm2(java_classpath, "decrypt", vector["privateKey"], vector["javaCiphertext"]) == vector["plaintext"] + assert java_sm2(java_classpath, "decrypt", vector["privateKey"], vector["pythonCiphertext"]) == vector["plaintext"] + + +def test_fresh_ciphertexts_are_bidirectionally_compatible(vector: dict[str, str], java_classpath: str) -> None: + java_ciphertext = java_sm2(java_classpath, "encrypt", vector["publicKey"], vector["plaintext"]) + assert java_ciphertext.startswith("04") + assert sm2_decrypt_c1c3c2(vector["privateKey"], java_ciphertext) == vector["plaintext"] + + python_ciphertext = sm2_encrypt_c1c3c2(vector["publicKey"], vector["plaintext"]) + assert python_ciphertext.startswith("04") + assert java_sm2(java_classpath, "decrypt", vector["privateKey"], python_ciphertext) == vector["plaintext"] diff --git a/main/manager-api-fastapi/tests/test_sys_domain.py b/main/manager-api-fastapi/tests/test_sys_domain.py new file mode 100644 index 00000000..ffe28040 --- /dev/null +++ b/main/manager-api-fastapi/tests/test_sys_domain.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +from typing import Any + +import httpx +import pytest +from sqlalchemy import text +from websockets.asyncio.server import serve + +from app.core.crypto import bcrypt_matches +from app.core.errors import AppError +from app.core.redis import JavaRedisCodec +from app.core.security import AuthUser +from app.repositories.sys import SysRepository +from app.routers.sys import sys_router +from app.schemas.sys import DictDataPayload, DictTypePayload, EmitServerActionRequest, SysParamPayload +from app.services.sys import AdminService, DictService, ParamExternalValidator, ServerActionService, SysParamService +from tests.domain_support import FakeRedis, sqlite_session + +SYS_SCHEMA = [ + "CREATE TABLE sys_user (" + "id INTEGER PRIMARY KEY, username TEXT, password TEXT, super_admin INTEGER, status INTEGER, " + "creator INTEGER, create_date DATETIME, updater INTEGER, update_date DATETIME)", + "CREATE TABLE ai_device (" + "id INTEGER PRIMARY KEY, user_id INTEGER, mac_address TEXT, last_connected_at DATETIME, auto_update INTEGER, " + "board TEXT, alias TEXT, agent_id TEXT, app_version TEXT, sort INTEGER, create_date DATETIME, " + "update_date DATETIME)", + "CREATE TABLE sys_params (" + "id INTEGER PRIMARY KEY, param_code TEXT UNIQUE, param_value TEXT, value_type TEXT, param_type INTEGER, " + "remark TEXT, creator INTEGER, create_date DATETIME, updater INTEGER, update_date DATETIME)", + "CREATE TABLE ai_agent_plugin_mapping (id INTEGER PRIMARY KEY, agent_id TEXT, plugin_id TEXT)", + "CREATE TABLE sys_dict_type (" + "id INTEGER PRIMARY KEY, dict_type TEXT UNIQUE, dict_name TEXT, remark TEXT, sort INTEGER, " + "creator INTEGER, create_date DATETIME, updater INTEGER, update_date DATETIME)", + "CREATE TABLE sys_dict_data (" + "id INTEGER PRIMARY KEY, dict_type_id INTEGER, dict_label TEXT, dict_value TEXT, remark TEXT, sort INTEGER, " + "creator INTEGER, create_date DATETIME, updater INTEGER, update_date DATETIME)", +] + + +ADMIN = AuthUser(7, "root", 1, 1, "token", {}) + + +@pytest.mark.asyncio +async def test_admin_param_update_external_mock_menu_side_effect_and_cache() -> None: + redis = FakeRedis() + calls: list[str] = [] + + async def endpoint(request: httpx.Request) -> httpx.Response: + calls.append(str(request.url)) + return httpx.Response(200, text="xiaozhi OTA service") + + async with sqlite_session(SYS_SCHEMA) as session, httpx.AsyncClient( + transport=httpx.MockTransport(endpoint) + ) as client: + await session.execute( + text( + "INSERT INTO sys_params " + "(id, param_code, param_value, value_type, param_type) VALUES " + "(1, 'server.ota', 'https://old.example/ota/', 'string', 1)," + "(2, 'system-web.menu', :menu, 'json', 1)" + ), + {"menu": json.dumps({"features": {"addressBook": {"enabled": True}}})}, + ) + await session.execute( + text( + "INSERT INTO ai_agent_plugin_mapping (id, agent_id, plugin_id) " + "VALUES (10, 'agent-1', 'SYSTEM_PLUGIN_CALL_DEVICE')" + ) + ) + await session.execute( + text( + "INSERT INTO sys_user (id, username, status, super_admin) VALUES " + "(7, 'root', 1, 1), (8, 'member', 1, 0)" + ) + ) + await session.execute( + text( + "INSERT INTO ai_device " + "(id, user_id, mac_address, board, alias, app_version, create_date, update_date) " + "VALUES (20, 8, 'AA:BB', 'esp32s3', 'Kitchen', '1.0.0', " + "'2026-01-02 03:04:05', CURRENT_TIMESTAMP)" + ) + ) + await session.commit() + + params = SysParamService( + SysRepository(session), + redis=redis, # type: ignore[arg-type] + validator=ParamExternalValidator(client), + ) + await params.update( + SysParamPayload( + id=1, + param_code="server.ota", + param_value="https://mock.example/ota/", + value_type="string", + ), + ADMIN, + ) + assert calls == ["https://mock.example/ota/"] + assert await params.get_value("server.ota") == "https://mock.example/ota/" + assert 0 < await redis.ttl("sys:params") <= 86400 + + await params.update( + SysParamPayload( + id=2, + param_code="system-web.menu", + param_value=json.dumps({"features": {"addressBook": {"enabled": False}}}), + value_type="json", + ), + ADMIN, + ) + remaining = await session.scalar( + text("SELECT COUNT(*) FROM ai_agent_plugin_mapping WHERE plugin_id = 'SYSTEM_PLUGIN_CALL_DEVICE'") + ) + assert remaining == 0 + + # Missing `features` does not mean disabled in Java: both feature maps + # must be present before it removes call-device mappings. + await session.execute( + text("UPDATE sys_params SET param_value = :menu WHERE id = 2"), + {"menu": json.dumps({"features": {"addressBook": {"enabled": True}}})}, + ) + await session.execute( + text( + "INSERT INTO ai_agent_plugin_mapping (id, agent_id, plugin_id) " + "VALUES (11, 'agent-1', 'SYSTEM_PLUGIN_CALL_DEVICE')" + ) + ) + await session.commit() + await params.update( + SysParamPayload(id=2, param_code="system-web.menu", param_value="{}", value_type="json"), + ADMIN, + ) + assert await session.scalar(text("SELECT COUNT(*) FROM ai_agent_plugin_mapping WHERE id = 11")) == 1 + + with pytest.raises(AppError) as add_group: + await params.save( + SysParamPayload( + id=99, + param_code="new.param", + param_value="value", + value_type="string", + ), + ADMIN, + "en-US", + ) + assert add_group.value.code == 500 + assert add_group.value.message == "ID has to be empty" + + with pytest.raises(AppError) as update_group: + await params.update( + SysParamPayload(param_code="new.param", param_value="value", value_type="string"), + ADMIN, + "de-DE", + ) + assert update_group.value.code == 500 + assert update_group.value.message == "ID darf nicht leer sein" + + with pytest.raises(AppError) as value_type_pattern: + await params.update( + SysParamPayload(id=1, param_code="server.ota", param_value="value", value_type="STRING"), + ADMIN, + "en-US", + ) + assert value_type_pattern.value.code == 500 + assert value_type_pattern.value.message == "Value type must be string, number, boolean or array" + + admin = AdminService(SysRepository(session)) + users = await admin.page_users(mobile="mem", page=1, limit=10) + assert users["total"] == 1 + assert users["list"][0]["deviceCount"] == "1" + zero_limit_users = await admin.page_users(mobile="mem", page=1, limit=0) + assert zero_limit_users == {"list": [], "total": 1} + generated = await admin.reset_password(8, ADMIN) + stored = await session.scalar(text("SELECT password FROM sys_user WHERE id = 8")) + assert len(generated) == 12 and bcrypt_matches(generated, stored) + assert await session.scalar(text("SELECT updater FROM sys_user WHERE id = 8")) == ADMIN.id + devices = await admin.page_devices(keywords="Kit", page=1, limit=10) + assert devices["list"][0]["createDate"] == "2026-01-01 19:04:05" + + with pytest.raises(ValueError): + await admin.change_status(0, ["8", "not-a-long"], ADMIN) + assert await session.scalar(text("SELECT status FROM sys_user WHERE id = 8")) == 1 + + +@pytest.mark.asyncio +async def test_dict_crud_preserves_java_duplicate_rule_and_invalidates_cache() -> None: + redis = FakeRedis() + async with sqlite_session(SYS_SCHEMA) as session: + await session.execute(text("INSERT INTO sys_user (id, username, status, super_admin) VALUES (7, 'root', 1, 1)")) + await session.execute( + text( + "INSERT INTO sys_dict_type " + "(id, dict_type, dict_name, sort, creator, updater) VALUES (1, 'MOBILE_AREA', 'Area', 1, 7, 7)" + ) + ) + await session.commit() + service = DictService(SysRepository(session), redis=redis) # type: ignore[arg-type] + + await service.save_type( + DictTypePayload(id=99, dict_type="CUSTOM", dict_name="Custom", sort=-1), + ADMIN, + ) + custom_sort = await session.scalar(text("SELECT sort FROM sys_dict_type WHERE id = 99")) + assert custom_sort == -1 + + await service.save_data( + DictDataPayload(dict_type_id=1, dict_label="China", dict_value="+86", sort=1), + ADMIN, + ) + row = (await session.execute(text("SELECT id FROM sys_dict_data"))).scalar_one() + assert await service.items("MOBILE_AREA") == [{"name": "China", "key": "+86"}] + cached_items = json.loads((await redis.get("sys:dict:data:MOBILE_AREA")).decode()) + assert cached_items[0] == "java.util.ArrayList" + assert cached_items[1][0]["@class"] == "xiaozhi.modules.sys.vo.SysDictDataItem" + + await service.update_data(DictDataPayload(id=row, dict_value="+1"), ADMIN) + partially_updated = ( + await session.execute( + text("SELECT dict_type_id, dict_label, dict_value FROM sys_dict_data WHERE id = :id"), + {"id": row}, + ) + ).mappings().one() + assert dict(partially_updated) == {"dict_type_id": 1, "dict_label": "China", "dict_value": "+1"} + # Java cannot derive the real cache key without dictTypeId and therefore leaves this stale entry in place. + assert await service.items("MOBILE_AREA") == [{"name": "China", "key": "+86"}] + + await service.update_data( + DictDataPayload(id=row, dict_type_id=1, dict_label="China mainland", dict_value="+86", sort=2), + ADMIN, + ) + assert await redis.get("sys:dict:data:MOBILE_AREA") is None + + # SysDictDataServiceImpl compares dictValue to the existing dictLabel; this intentionally tests that quirk. + with pytest.raises(AppError) as captured: + await service.save_data( + DictDataPayload(dict_type_id=1, dict_label="Unrelated", dict_value="China mainland", sort=3), + ADMIN, + ) + assert captured.value.code == 10128 + + +@pytest.mark.asyncio +async def test_server_action_websocket_hmac_headers_payload_and_one_time_registration() -> None: + redis = FakeRedis() + captured: dict[str, Any] = {} + + async def handler(websocket: Any) -> None: + captured["headers"] = dict(websocket.request.headers) + captured["payload"] = json.loads(await websocket.recv()) + await websocket.send(json.dumps({"status": "success", "type": "server", "content": {"action": "done"}})) + + async with serve(handler, "127.0.0.1", 0) as server: + socket = server.sockets[0] + port = socket.getsockname()[1] + websocket_url = f"ws://127.0.0.1:{port}/xiaozhi/v1/" + async with sqlite_session(SYS_SCHEMA) as session: + await session.execute( + text( + "INSERT INTO sys_params " + "(id, param_code, param_value, value_type, param_type) VALUES " + "(1, 'server.websocket', :url, 'string', 1)," + "(2, 'server.secret', 'local-test-secret', 'string', 1)" + ), + {"url": websocket_url + ";;"}, + ) + await session.commit() + param_service = SysParamService(SysRepository(session), redis=redis) # type: ignore[arg-type] + assert await ServerActionService(param_service, redis=redis).server_list() == [websocket_url] # type: ignore[arg-type] + result = await ServerActionService(param_service, redis=redis).emit( # type: ignore[arg-type] + EmitServerActionRequest(target_ws=websocket_url, action="RESTART") + ) + assert result is True + + headers = captured["headers"] + device_id = headers["device-id"] + client_id = headers["client-id"] + token, timestamp_text = headers["authorization"].removeprefix("Bearer ").rsplit(".", 1) + expected = hmac.new( + b"local-test-secret", + f"{client_id}|{device_id}|{timestamp_text}".encode(), + digestmod=hashlib.sha256, + ).digest() + assert token == base64.urlsafe_b64encode(expected).rstrip(b"=").decode() + assert captured["payload"] == { + "type": "server", + "action": "restart", + "content": {"secret": "local-test-secret"}, + } + assert JavaRedisCodec.decode(await redis.get(f"tmp_register_mac:{device_id}")) == "true" + assert 0 < await redis.ttl(f"tmp_register_mac:{device_id}") <= 300 + + with pytest.raises(AppError) as empty_urls: + await ParamExternalValidator().validate("server.websocket", ";") + assert empty_urls.value.code == 10098 + + +def test_sys_router_exposes_all_seven_controller_groups() -> None: + routes = {(method, route.path) for route in sys_router.routes for method in route.methods} + assert len(routes) == 23 + assert ("GET", "/admin/users") in routes + assert ("POST", "/admin/server/emit-action") in routes + assert ("POST", "/admin/params/delete") in routes + assert ("GET", "/admin/dict/data/type/{dict_type}") in routes diff --git a/main/manager-api-fastapi/tests/test_voiceclone.py b/main/manager-api-fastapi/tests/test_voiceclone.py new file mode 100644 index 00000000..0c7fb769 --- /dev/null +++ b/main/manager-api-fastapi/tests/test_voiceclone.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +import base64 +import json +from typing import Any, cast + +import httpx +import pytest +from fastapi import FastAPI +from redis.asyncio import Redis +from sqlalchemy import text + +from app.core.database import get_db +from app.core.errors import AppError +from app.core.security import AuthUser +from app.integrations.voice_clone import VoiceCloneIntegration +from app.routers.voiceclone import voiceclone_router +from app.schemas.voiceclone import VoiceResourceCreateRequest +from app.services.voiceclone import VoiceCloneService +from tests.domain_support import FakeRedis, sqlite_session + +VOICE_SCHEMA = [ + """ + CREATE TABLE ai_voice_clone ( + id VARCHAR(32) PRIMARY KEY, name VARCHAR(64), model_id VARCHAR(32), voice_id VARCHAR(32), + languages VARCHAR(50), user_id BIGINT, voice BLOB, train_status INTEGER, train_error VARCHAR(255), + creator BIGINT, create_date DATETIME + ) + """, + """ + CREATE TABLE ai_model_config ( + id VARCHAR(32) PRIMARY KEY, model_type VARCHAR(20), model_name VARCHAR(50), config_json JSON + ) + """, + "CREATE TABLE sys_user (id BIGINT PRIMARY KEY, username VARCHAR(50))", +] + + +def user(user_id: int = 8, *, super_admin: int = 0) -> AuthUser: + return AuthUser( + id=user_id, + username="voice-user", + super_admin=super_admin, + status=1, + token="test-token", # noqa: S106 - isolated authentication fixture + row={"id": user_id}, + ) + + +def test_voiceclone_router_closes_twelve_paths_with_static_paths_first() -> None: + routes = [route for route in voiceclone_router.routes if hasattr(route, "methods")] + pairs = {(next(iter(route.methods)), route.path) for route in routes} + assert pairs == { + ("GET", "/voiceResource/ttsPlatforms"), + ("GET", "/voiceResource/user/{user_id}"), + ("GET", "/voiceResource"), + ("GET", "/voiceResource/{voice_id}"), + ("POST", "/voiceResource"), + ("DELETE", "/voiceResource/{voice_id}"), + ("GET", "/voiceClone"), + ("POST", "/voiceClone/upload"), + ("POST", "/voiceClone/updateName"), + ("POST", "/voiceClone/audio/{voice_id}"), + ("GET", "/voiceClone/play/{download_id}"), + ("POST", "/voiceClone/cloneAudio"), + } + assert len(routes) == len(pairs) + paths = [route.path for route in routes] + assert paths.index("/voiceResource/ttsPlatforms") < paths.index("/voiceResource/{voice_id}") + assert paths.index("/voiceResource/user/{user_id}") < paths.index("/voiceResource/{voice_id}") + + +@pytest.mark.asyncio +async def test_voice_resource_create_page_upload_and_one_time_audio() -> None: + fake = cast(Redis, FakeRedis()) + async with sqlite_session(VOICE_SCHEMA) as session: + await session.execute(text("INSERT INTO sys_user VALUES (8, 'voice-user')")) + await session.execute( + text( + "INSERT INTO ai_model_config (id,model_type,model_name,config_json) " + "VALUES ('tts-1','TTS','火山双向流',:config)" + ), + {"config": json.dumps({"type": "huoshan_double_stream", "appid": "app", "access_token": "token"})}, + ) + await session.commit() + service = VoiceCloneService(session, redis_client=fake) + await service.create_resources( + VoiceResourceCreateRequest( + model_id="tts-1", + voice_ids=["S_voice_one"], + user_id=8, + languages="zh-CN", + ), + actor=user(super_admin=1), + ) + page = await service.page({"page": "1", "limit": "10"}, user_id=8) + assert page["total"] == 1 + item = page["list"][0] + assert item["model_name"] == "火山双向流" + assert item["user_name"] == "voice-user" + assert item["has_voice"] is False + voice_id = str(item["id"]) + await service.check_permission(voice_id, user()) + await service.upload_voice(voice_id, b"RIFF-test-wave") + download_id = await service.create_audio_id(voice_id) + assert await service.consume_audio(download_id) == b"RIFF-test-wave" + assert await service.consume_audio(download_id) is None + + +@pytest.mark.asyncio +async def test_voice_resources_by_user_keeps_java_created_at_query_failure() -> None: + async with sqlite_session(VOICE_SCHEMA) as session: + with pytest.raises(AppError) as caught: + await VoiceCloneService(session).get_by_user(9223372036854775806) + assert caught.value.code == 500 + + +@pytest.mark.asyncio +async def test_huoshan_clone_request_and_success_state_are_persisted() -> None: + fake = cast(Redis, FakeRedis()) + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + payload = json.loads(request.content) + assert payload["appid"] == "app-id" + assert base64.b64decode(payload["audios"][0]["audio_bytes"]) == b"voice-bytes" + assert payload["audios"][0]["audio_format"] == "wav" + assert payload["source"] == 2 + assert payload["language"] == 0 + assert payload["model_type"] == 1 + assert payload["speaker_id"] == "S_original" + return httpx.Response(200, json={"BaseResp": {"StatusCode": 0}, "speaker_id": "S_trained"}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = VoiceCloneIntegration(timeout_seconds=1, client=client, endpoint="https://mock.local/train") + async with sqlite_session(VOICE_SCHEMA) as session: + await _seed_clone(session) + service = VoiceCloneService(session, redis_client=fake, provider=provider) + await service.clone_audio("clone-1", accept_language="zh-CN") + row = ( + await session.execute( + text("SELECT voice_id,train_status,train_error FROM ai_voice_clone WHERE id='clone-1'") + ) + ).mappings().one() + assert len(requests) == 1 + assert requests[0].headers["Authorization"] == "Bearer;access-token" + assert requests[0].headers["Resource-Id"] == "seed-icl-1.0" + assert row == {"voice_id": "S_trained", "train_status": 2, "train_error": ""} + + +@pytest.mark.asyncio +async def test_huoshan_timeout_maps_to_training_error_without_retry() -> None: + fake = cast(Redis, FakeRedis()) + attempts = 0 + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + raise httpx.ReadTimeout("provider timeout", request=request) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = VoiceCloneIntegration(timeout_seconds=0.01, client=client, endpoint="https://mock.local/train") + async with sqlite_session(VOICE_SCHEMA) as session: + await _seed_clone(session) + service = VoiceCloneService(session, redis_client=fake, provider=provider) + with pytest.raises(AppError) as raised: + await service.clone_audio("clone-1", accept_language="en-US") + row = ( + await session.execute( + text("SELECT train_status,train_error FROM ai_voice_clone WHERE id='clone-1'") + ) + ).mappings().one() + assert attempts == 1 + assert raised.value.code == 10154 + assert row["train_status"] == 3 + assert "provider timeout" in row["train_error"] + + +@pytest.mark.asyncio +async def test_voice_play_route_consumes_link_and_preserves_audio_headers(monkeypatch: pytest.MonkeyPatch) -> None: + fake = cast(Redis, FakeRedis()) + monkeypatch.setattr("app.services.device.get_redis", lambda: fake) + await cast(Any, fake).set("voiceClone:audio:id:play-once", b'"clone-1"', ex=86_400) + async with sqlite_session(VOICE_SCHEMA) as session: + await session.execute( + text( + "INSERT INTO ai_voice_clone " + "(id,name,model_id,voice_id,user_id,voice,train_status,creator,create_date) " + "VALUES ('clone-1','voice','tts','S_voice',8,:voice,0,8,CURRENT_TIMESTAMP)" + ), + {"voice": b"RIFF-audio"}, + ) + await session.commit() + app = FastAPI() + app.include_router(voiceclone_router) + + async def override_db() -> Any: + yield session + + app.dependency_overrides[get_db] = override_db + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + first = await client.get("/voiceClone/play/play-once") + second = await client.get("/voiceClone/play/play-once") + assert first.status_code == 200 + assert first.content == b"RIFF-audio" + assert first.headers["content-type"] == "audio/wav" + assert first.headers["content-length"] == str(len(first.content)) + assert first.headers["content-disposition"] == "inline; filename=voice.wav" + assert second.status_code == 404 + + +async def _seed_clone(session: Any) -> None: + await session.execute(text("INSERT INTO sys_user VALUES (8, 'voice-user')")) + await session.execute( + text( + "INSERT INTO ai_model_config (id,model_type,model_name,config_json) " + "VALUES ('tts-1','TTS','火山双向流',:config)" + ), + { + "config": json.dumps( + {"type": "huoshan_double_stream", "appid": "app-id", "access_token": "access-token"} + ) + }, + ) + await session.execute( + text( + "INSERT INTO ai_voice_clone " + "(id,name,model_id,voice_id,languages,user_id,voice,train_status,creator,create_date) " + "VALUES ('clone-1','clone','tts-1','S_original','zh-CN',8,:voice,0,8,CURRENT_TIMESTAMP)" + ), + {"voice": b"voice-bytes"}, + ) + await session.commit() diff --git a/main/manager-api-fastapi/uv.lock b/main/manager-api-fastapi/uv.lock new file mode 100644 index 00000000..60b17b1b --- /dev/null +++ b/main/manager-api-fastapi/uv.lock @@ -0,0 +1,1266 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <3.13" + +[[package]] +name = "aiosqlite" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/7d/8bca2bf9a247c2c5dfeec1d7a5f40db6518f88d314b8bca9da29670d2671/aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3", size = 13454, upload-time = "2025-02-03T07:30:16.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "asyncmy" +version = "0.2.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/76/55cc0577f9e838c5a5213bf33159b9e484c9d9820a2bafd4d6bfa631bf86/asyncmy-0.2.10.tar.gz", hash = "sha256:f4b67edadf7caa56bdaf1c2e6cf451150c0a86f5353744deabe4426fe27aff4e", size = 63889, upload-time = "2024-12-12T14:45:09.2Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/c9/412b137c52f6c6437faba27412ccb32721571c42e59bc4f799796316866b/asyncmy-0.2.10-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:c2237c8756b8f374099bd320c53b16f7ec0cee8258f00d72eed5a2cd3d251066", size = 1803880, upload-time = "2024-12-13T02:36:20.194Z" }, + { url = "https://files.pythonhosted.org/packages/74/f3/c9520f489dc42a594c8ad3cbe2088ec511245a3c55c3333e6fa949838420/asyncmy-0.2.10-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:6e98d4fbf7ea0d99dfecb24968c9c350b019397ba1af9f181d51bb0f6f81919b", size = 1736363, upload-time = "2024-12-13T02:36:41.578Z" }, + { url = "https://files.pythonhosted.org/packages/52/9c/3c531a414290cbde9313cad54bb525caf6b1055ffa56bb271bf70512b533/asyncmy-0.2.10-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:b1b1ee03556c7eda6422afc3aca132982a84706f8abf30f880d642f50670c7ed", size = 4970043, upload-time = "2024-12-13T02:35:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/176ed8a79d3a24b2e8ba7a11b429553f29fea20276537651526f3a87660b/asyncmy-0.2.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e2b97672ea3f0b335c0ffd3da1a5727b530f82f5032cd87e86c3aa3ac6df7f3", size = 5168645, upload-time = "2024-12-13T02:35:50.999Z" }, + { url = "https://files.pythonhosted.org/packages/81/3f/46f126663649784ab6586bc9b482bca432a35588714170621db8d33d76e4/asyncmy-0.2.10-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c6471ce1f9ae1e6f0d55adfb57c49d0bcf5753a253cccbd33799ddb402fe7da2", size = 4988493, upload-time = "2024-12-13T02:35:54.062Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c6/acce7ea4b74e092582d65744418940b2b8c661102a22a638f58e7b651c6f/asyncmy-0.2.10-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:10e2a10fe44a2b216a1ae58fbdafa3fed661a625ec3c030c560c26f6ab618522", size = 5158496, upload-time = "2024-12-13T02:35:56.723Z" }, + { url = "https://files.pythonhosted.org/packages/d5/01/d8fa0291083e9a0d899addda1f7608da37d28fff9bb4df1bd6f7f37354db/asyncmy-0.2.10-cp310-cp310-win32.whl", hash = "sha256:a791ab117787eb075bc37ed02caa7f3e30cca10f1b09ec7eeb51d733df1d49fc", size = 1624372, upload-time = "2024-12-13T02:36:14.158Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a0/ad6669fd2870492749c189a72c881716a3727b7f0bc972fc8cea7a40879c/asyncmy-0.2.10-cp310-cp310-win_amd64.whl", hash = "sha256:bd16fdc0964a4a1a19aec9797ca631c3ff2530013fdcd27225fc2e48af592804", size = 1694174, upload-time = "2024-12-13T02:36:17.909Z" }, + { url = "https://files.pythonhosted.org/packages/72/1a/21b4af0d19862cc991f1095f006981a4f898599060dfa59f136e292b3e9a/asyncmy-0.2.10-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:7af0f1f31f800a8789620c195e92f36cce4def68ee70d625534544d43044ed2a", size = 1806974, upload-time = "2024-12-13T02:36:23.375Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/3579a88123ead38e60e0b6e744224907e3d7a668518f9a46ed584df4f788/asyncmy-0.2.10-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:800116ab85dc53b24f484fb644fefffac56db7367a31e7d62f4097d495105a2c", size = 1738218, upload-time = "2024-12-13T02:36:44.753Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/10646bbafce22025be25aa709e83f0cdd3fb9089304cf9d3169a80540850/asyncmy-0.2.10-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:39525e9d7e557b83db268ed14b149a13530e0d09a536943dba561a8a1c94cc07", size = 5346417, upload-time = "2024-12-13T02:36:00.17Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/3fb0d0481def3a0900778f7d04f50028a4a2d987087a2f1e718e6c236e01/asyncmy-0.2.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76e199d6b57918999efc702d2dbb182cb7ba8c604cdfc912517955219b16eaea", size = 5553197, upload-time = "2024-12-13T02:36:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/82/a5/8281e8c0999fc6303b5b522ee82d1e338157a74f8bbbaa020e392b69156a/asyncmy-0.2.10-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9ca8fdd7dbbf2d9b4c2d3a5fac42b058707d6a483b71fded29051b8ae198a250", size = 5337915, upload-time = "2024-12-13T02:36:09.126Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/425108f5c6976ceb67b8f95bc73480fe777a95e7a89a29299664f5cb380f/asyncmy-0.2.10-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0df23db54e38602c803dacf1bbc1dcc4237a87223e659681f00d1a319a4f3826", size = 5524662, upload-time = "2024-12-13T02:36:12.525Z" }, + { url = "https://files.pythonhosted.org/packages/ff/32/17291b12dce380abbbec888ea9d4e863fd2116530bf2c87c1ab40b39f9d1/asyncmy-0.2.10-cp311-cp311-win32.whl", hash = "sha256:a16633032be020b931acfd7cd1862c7dad42a96ea0b9b28786f2ec48e0a86757", size = 1622375, upload-time = "2024-12-13T02:36:22.276Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a3/76e65877de5e6fc853373908079adb711f80ed09aab4e152a533e0322375/asyncmy-0.2.10-cp311-cp311-win_amd64.whl", hash = "sha256:cca06212575922216b89218abd86a75f8f7375fc9c28159ea469f860785cdbc7", size = 1696693, upload-time = "2024-12-13T02:36:26.879Z" }, + { url = "https://files.pythonhosted.org/packages/b8/82/5a4b1aedae9b35f7885f10568437d80507d7a6704b51da2fc960a20c4948/asyncmy-0.2.10-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:42295530c5f36784031f7fa42235ef8dd93a75d9b66904de087e68ff704b4f03", size = 1783558, upload-time = "2024-12-13T02:36:28.922Z" }, + { url = "https://files.pythonhosted.org/packages/39/24/0fce480680531a29b51e1d2680a540c597e1a113aa1dc58cb7483c123a6b/asyncmy-0.2.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:641a853ffcec762905cbeceeb623839c9149b854d5c3716eb9a22c2b505802af", size = 1729268, upload-time = "2024-12-13T02:36:50.423Z" }, + { url = "https://files.pythonhosted.org/packages/c8/96/74dc1aaf1ab0bde88d3c6b3a70bd25f18796adb4e91b77ad580efe232df5/asyncmy-0.2.10-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:c554874223dd36b1cfc15e2cd0090792ea3832798e8fe9e9d167557e9cf31b4d", size = 5343513, upload-time = "2024-12-13T02:36:17.099Z" }, + { url = "https://files.pythonhosted.org/packages/9a/04/14662ff5b9cfab5cc11dcf91f2316e2f80d88fbd2156e458deef3e72512a/asyncmy-0.2.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd16e84391dde8edb40c57d7db634706cbbafb75e6a01dc8b68a63f8dd9e44ca", size = 5592344, upload-time = "2024-12-13T02:36:21.202Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ac/3cf0abb3acd4f469bd012a1b4a01968bac07a142fca510da946b6ab1bf4f/asyncmy-0.2.10-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:9f6b44c4bf4bb69a2a1d9d26dee302473099105ba95283b479458c448943ed3c", size = 5300819, upload-time = "2024-12-13T02:36:24.703Z" }, + { url = "https://files.pythonhosted.org/packages/5c/23/6d05254d1c89ad15e7f32eb3df277afc7bbb2220faa83a76bea0b7bc6407/asyncmy-0.2.10-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:16d398b1aad0550c6fe1655b6758455e3554125af8aaf1f5abdc1546078c7257", size = 5548799, upload-time = "2024-12-13T02:36:29.945Z" }, + { url = "https://files.pythonhosted.org/packages/fe/32/b7ce9782c741b6a821a0d11772f180f431a5c3ba6eaf2e6dfa1c3cbcf4df/asyncmy-0.2.10-cp312-cp312-win32.whl", hash = "sha256:59d2639dcc23939ae82b93b40a683c15a091460a3f77fa6aef1854c0a0af99cc", size = 1597544, upload-time = "2024-12-13T02:36:31.574Z" }, + { url = "https://files.pythonhosted.org/packages/94/08/7de4f4a17196c355e4706ceba0ab60627541c78011881a7c69f41c6414c5/asyncmy-0.2.10-cp312-cp312-win_amd64.whl", hash = "sha256:4c6674073be97ffb7ac7f909e803008b23e50281131fef4e30b7b2162141a574", size = 1679064, upload-time = "2024-12-13T02:36:39.479Z" }, + { url = "https://files.pythonhosted.org/packages/83/32/3317d5290737a3c4685343fe37e02567518357c46ed87c51f47139d31ded/asyncmy-0.2.10-pp310-pypy310_pp73-macosx_13_0_x86_64.whl", hash = "sha256:f10c977c60a95bd6ec6b8654e20c8f53bad566911562a7ad7117ca94618f05d3", size = 1627680, upload-time = "2024-12-13T02:36:42.605Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e1/afeb50deb0554006c48b9f4f7b6b726e0aa42fa96d7cfbd3fdd0800765e2/asyncmy-0.2.10-pp310-pypy310_pp73-macosx_14_0_arm64.whl", hash = "sha256:aab07fbdb9466beaffef136ffabe388f0d295d8d2adb8f62c272f1d4076515b9", size = 1593957, upload-time = "2024-12-13T02:37:00.344Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/56d3721e2b2eab84320058c3458da168d143446031eca3799aed481c33d2/asyncmy-0.2.10-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:63144322ade68262201baae73ad0c8a06b98a3c6ae39d1f3f21c41cc5287066a", size = 1756531, upload-time = "2024-12-13T02:36:59.477Z" }, + { url = "https://files.pythonhosted.org/packages/ac/1a/295f06eb8e5926749265e08da9e2dc0dc14e0244bf36843997a1c8e18a50/asyncmy-0.2.10-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9659d95c6f2a611aec15bdd928950df937bf68bc4bbb68b809ee8924b6756067", size = 1752746, upload-time = "2024-12-13T02:37:01.999Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/3a5351acc6273c28333cad8193184de0070c617fd8385fd8ba23d789e08d/asyncmy-0.2.10-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8ced4bd938e95ede0fb9fa54755773df47bdb9f29f142512501e613dd95cf4a4", size = 1614903, upload-time = "2024-12-13T02:36:53Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "bcrypt" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697, upload-time = "2025-02-28T01:24:09.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/22/5ada0b9af72b60cbc4c9a399fdde4af0feaa609d27eb0adc61607997a3fa/bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d", size = 498019, upload-time = "2025-02-28T01:23:05.838Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8c/252a1edc598dc1ce57905be173328eda073083826955ee3c97c7ff5ba584/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b", size = 279174, upload-time = "2025-02-28T01:23:07.274Z" }, + { url = "https://files.pythonhosted.org/packages/29/5b/4547d5c49b85f0337c13929f2ccbe08b7283069eea3550a457914fc078aa/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e", size = 283870, upload-time = "2025-02-28T01:23:09.151Z" }, + { url = "https://files.pythonhosted.org/packages/be/21/7dbaf3fa1745cb63f776bb046e481fbababd7d344c5324eab47f5ca92dd2/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59", size = 279601, upload-time = "2025-02-28T01:23:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6d/64/e042fc8262e971347d9230d9abbe70d68b0a549acd8611c83cebd3eaec67/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753", size = 297660, upload-time = "2025-02-28T01:23:12.989Z" }, + { url = "https://files.pythonhosted.org/packages/50/b8/6294eb84a3fef3b67c69b4470fcdd5326676806bf2519cda79331ab3c3a9/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761", size = 284083, upload-time = "2025-02-28T01:23:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/62/e6/baff635a4f2c42e8788fe1b1633911c38551ecca9a749d1052d296329da6/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb", size = 279237, upload-time = "2025-02-28T01:23:16.686Z" }, + { url = "https://files.pythonhosted.org/packages/39/48/46f623f1b0c7dc2e5de0b8af5e6f5ac4cc26408ac33f3d424e5ad8da4a90/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d", size = 283737, upload-time = "2025-02-28T01:23:18.897Z" }, + { url = "https://files.pythonhosted.org/packages/49/8b/70671c3ce9c0fca4a6cc3cc6ccbaa7e948875a2e62cbd146e04a4011899c/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f", size = 312741, upload-time = "2025-02-28T01:23:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/27/fb/910d3a1caa2d249b6040a5caf9f9866c52114d51523ac2fb47578a27faee/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732", size = 316472, upload-time = "2025-02-28T01:23:23.183Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cf/7cf3a05b66ce466cfb575dbbda39718d45a609daa78500f57fa9f36fa3c0/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef", size = 343606, upload-time = "2025-02-28T01:23:25.361Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b8/e970ecc6d7e355c0d892b7f733480f4aa8509f99b33e71550242cf0b7e63/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304", size = 362867, upload-time = "2025-02-28T01:23:26.875Z" }, + { url = "https://files.pythonhosted.org/packages/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589, upload-time = "2025-02-28T01:23:28.381Z" }, + { url = "https://files.pythonhosted.org/packages/29/07/416f0b99f7f3997c69815365babbc2e8754181a4b1899d921b3c7d5b6f12/bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", size = 152794, upload-time = "2025-02-28T01:23:30.187Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c1/3fa0e9e4e0bfd3fd77eb8b52ec198fd6e1fd7e9402052e43f23483f956dd/bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3", size = 498969, upload-time = "2025-02-28T01:23:31.945Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d4/755ce19b6743394787fbd7dff6bf271b27ee9b5912a97242e3caf125885b/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24", size = 279158, upload-time = "2025-02-28T01:23:34.161Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5d/805ef1a749c965c46b28285dfb5cd272a7ed9fa971f970435a5133250182/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef", size = 284285, upload-time = "2025-02-28T01:23:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/698580547a4a4988e415721b71eb45e80c879f0fb04a62da131f45987b96/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b", size = 279583, upload-time = "2025-02-28T01:23:38.021Z" }, + { url = "https://files.pythonhosted.org/packages/f2/87/62e1e426418204db520f955ffd06f1efd389feca893dad7095bf35612eec/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676", size = 297896, upload-time = "2025-02-28T01:23:39.575Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c6/8fedca4c2ada1b6e889c52d2943b2f968d3427e5d65f595620ec4c06fa2f/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1", size = 284492, upload-time = "2025-02-28T01:23:40.901Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4d/c43332dcaaddb7710a8ff5269fcccba97ed3c85987ddaa808db084267b9a/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe", size = 279213, upload-time = "2025-02-28T01:23:42.653Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/1e36379e169a7df3a14a1c160a49b7b918600a6008de43ff20d479e6f4b5/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0", size = 284162, upload-time = "2025-02-28T01:23:43.964Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0a/644b2731194b0d7646f3210dc4d80c7fee3ecb3a1f791a6e0ae6bb8684e3/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f", size = 312856, upload-time = "2025-02-28T01:23:46.011Z" }, + { url = "https://files.pythonhosted.org/packages/dc/62/2a871837c0bb6ab0c9a88bf54de0fc021a6a08832d4ea313ed92a669d437/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23", size = 316726, upload-time = "2025-02-28T01:23:47.575Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a1/9898ea3faac0b156d457fd73a3cb9c2855c6fd063e44b8522925cdd8ce46/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe", size = 343664, upload-time = "2025-02-28T01:23:49.059Z" }, + { url = "https://files.pythonhosted.org/packages/40/f2/71b4ed65ce38982ecdda0ff20c3ad1b15e71949c78b2c053df53629ce940/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505", size = 363128, upload-time = "2025-02-28T01:23:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598, upload-time = "2025-02-28T01:23:51.775Z" }, + { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" }, + { url = "https://files.pythonhosted.org/packages/55/2d/0c7e5ab0524bf1a443e34cdd3926ec6f5879889b2f3c32b2f5074e99ed53/bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c950d682f0952bafcceaf709761da0a32a942272fad381081b51096ffa46cea1", size = 275367, upload-time = "2025-02-28T01:23:54.578Z" }, + { url = "https://files.pythonhosted.org/packages/10/4f/f77509f08bdff8806ecc4dc472b6e187c946c730565a7470db772d25df70/bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:107d53b5c67e0bbc3f03ebf5b030e0403d24dda980f8e244795335ba7b4a027d", size = 280644, upload-time = "2025-02-28T01:23:56.547Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/7d9dc16a3a4d530d0a9b845160e9e5d8eb4f00483e05d44bb4116a1861da/bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b693dbb82b3c27a1604a3dff5bfc5418a7e6a781bb795288141e5f80cf3a3492", size = 274881, upload-time = "2025-02-28T01:23:57.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/c4/ae6921088adf1e37f2a3a6a688e72e7d9e45fdd3ae5e0bc931870c1ebbda/bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:b6354d3760fcd31994a14c89659dee887f1351a06e5dac3c1142307172a79f90", size = 280203, upload-time = "2025-02-28T01:23:59.331Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b1/1289e21d710496b88340369137cc4c5f6ee036401190ea116a7b4ae6d32a/bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a839320bf27d474e52ef8cb16449bb2ce0ba03ca9f44daba6d93fa1d8828e48a", size = 275103, upload-time = "2025-02-28T01:24:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/94/41/19be9fe17e4ffc5d10b7b67f10e459fc4eee6ffe9056a88de511920cfd8d/bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:bdc6a24e754a555d7316fa4774e64c6c3997d27ed2d1964d55920c7c227bc4ce", size = 280513, upload-time = "2025-02-28T01:24:02.243Z" }, + { url = "https://files.pythonhosted.org/packages/aa/73/05687a9ef89edebdd8ad7474c16d8af685eb4591c3c38300bb6aad4f0076/bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:55a935b8e9a1d2def0626c4269db3fcd26728cbff1e84f0341465c31c4ee56d8", size = 274685, upload-time = "2025-02-28T01:24:04.512Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/47bba97924ebe86a62ef83dc75b7c8a881d53c535f83e2c54c4bd701e05c/bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57967b7a28d855313a963aaea51bf6df89f833db4320da458e5b3c5ab6d4c938", size = 280110, upload-time = "2025-02-28T01:24:05.896Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/e9/6d7724983b3d5a0908dbf74f64038ade77c18646ff6636ec7894fd392ce1/cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0", size = 183837, upload-time = "2026-07-06T21:32:09.655Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/24580a278de21fd7322635556334d9b535f1cbc00b0a3919447cdf464c65/cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd", size = 184226, upload-time = "2026-07-06T21:32:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/88/a9/02cae418ec4beb282ace11958d9d4737793439d561fadc7e6d56f2e2b354/cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46", size = 211107, upload-time = "2026-07-06T21:32:12.328Z" }, + { url = "https://files.pythonhosted.org/packages/3b/30/c806937ed5e4c2c7ac30d9d6b76b5dc57ff8b75d83800d9bb11a8253cf2a/cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2", size = 218733, upload-time = "2026-07-06T21:32:13.67Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cf/398272b8bbfd58aa314fda5a7f1cdbb26d1d78ae324a11211521315dd1f0/cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd", size = 205543, upload-time = "2026-07-06T21:32:15.148Z" }, + { url = "https://files.pythonhosted.org/packages/45/ca/f91641185cdd90c36d317a9dc7f85e88ef8682d8b300977baff5e23c35d8/cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3", size = 205460, upload-time = "2026-07-06T21:32:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/38/66/04781a77b411f0bb5b234d62c1814754ab75ebe455ccff1b08e8d7aae98f/cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0", size = 218760, upload-time = "2026-07-06T21:32:17.98Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9a/bb1d5ed9c3fcae158e9f6391bf309c95d98c2ac37ed56573228471d0af5e/cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43", size = 221230, upload-time = "2026-07-06T21:32:19.407Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/3c1409cdd26094efacd1c36c66e0a6eb9d4296e4fd4f9901b8b2042f4323/cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c", size = 213524, upload-time = "2026-07-06T21:32:20.828Z" }, + { url = "https://files.pythonhosted.org/packages/fa/75/74dfb7c3fc6ebbd408038476bd4c1d7e925c62614e7b9c534ecc34218288/cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd", size = 220341, upload-time = "2026-07-06T21:32:21.9Z" }, + { url = "https://files.pythonhosted.org/packages/70/b6/9003c33a3e7d2c1306f5962e646457dcfe5a8cd8fce6bbe02d7af25db783/cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f", size = 174578, upload-time = "2026-07-06T21:32:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/8a/26/710688310447531c7a22f857c7f79d9855ec18b03e04494ced723fb37e2f/cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da", size = 185071, upload-time = "2026-07-06T21:32:24.671Z" }, + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/060ce69008ac97bbc01b1411b3e55b61f6f015659400b46749b662107831/coverage-7.15.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d", size = 221284, upload-time = "2026-07-15T18:53:29.52Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a3/d936e8b53edd9684100a6aefaf3fcabaa54728fe33324436c8d279c047aa/coverage-7.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846", size = 221799, upload-time = "2026-07-15T18:53:31.708Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a3/ca234b06aec7ee28226f11d39a696b4481fe5eddfce8e03bf39979bb8ffb/coverage-7.15.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09f5c6ec5901f667bd97dd140b5b9a2586b10efec66f46fb1e6d8135f8b95bdf", size = 248544, upload-time = "2026-07-15T18:53:33.212Z" }, + { url = "https://files.pythonhosted.org/packages/2b/89/dda79527bb7573ba91828b2fb91b3105d87378d6a2749ca0c0924ce0addd/coverage-7.15.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376", size = 250374, upload-time = "2026-07-15T18:53:34.683Z" }, + { url = "https://files.pythonhosted.org/packages/67/c6/c33755a34572f81f49a8c0cdf6b622f35ccb3238b136e1909daf0cdd4319/coverage-7.15.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d46e62cb35d91e6e2589fda6d28074426b0e276422b5d2ebef2c6b11dc60dbfd", size = 252239, upload-time = "2026-07-15T18:53:36.205Z" }, + { url = "https://files.pythonhosted.org/packages/b9/6f/dc341741b375be53a5baeee5b4bf0f0e525d38caed428f7932d23bb7bcb1/coverage-7.15.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dfd3db045e95960ae3683059571e597fda7cc610106a8916f77c5839048c1deb", size = 254150, upload-time = "2026-07-15T18:53:37.863Z" }, + { url = "https://files.pythonhosted.org/packages/e9/8d/966a18a5b195cb4e77b14c53f5f3dce22b5da05e6de7fafd1e08f2d2067a/coverage-7.15.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:affd532502d34c0472d0cdb181325c89f1d2c44992fef0c17e88e7b1576259a1", size = 249234, upload-time = "2026-07-15T18:53:39.394Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/8b2e367496ab48484d48e79984fec76cdc1b7cb5d3a00ee799a5602e3ec9/coverage-7.15.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d17d7512151fedfcc64c1821a8977fc9be0dbf495754669afcab7b57abc98ae9", size = 250276, upload-time = "2026-07-15T18:53:41.027Z" }, + { url = "https://files.pythonhosted.org/packages/63/92/1199318a200eb6c8c6ce0192c892c8710ac791abbe0f35099294620bbfda/coverage-7.15.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e26ff680768b8095e8874aabe0e9d3a47a2a9f176a8340d05f8604c56457c23a", size = 248283, upload-time = "2026-07-15T18:53:42.557Z" }, + { url = "https://files.pythonhosted.org/packages/56/da/be284a55c5619bda891a89c27dfd59324a2c6a14d755cf6aac6960ceebeb/coverage-7.15.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7e8f27131dc7cd53de2c137dd207b3720919320b3c20d499dc30aa9ee6173287", size = 252093, upload-time = "2026-07-15T18:53:44.271Z" }, + { url = "https://files.pythonhosted.org/packages/d4/53/ee112da833ddd77b73c6d781a98029b45b584b136615b4900ed0569f887e/coverage-7.15.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:728a33676d4c3f0db977990a4bd421dcaa3be3e53b5b6273036fff6666008e89", size = 248552, upload-time = "2026-07-15T18:53:45.7Z" }, + { url = "https://files.pythonhosted.org/packages/82/6a/802cfc802e9113494c80bf3f284cd4d72faeb1f24e244f61046af364f2ca/coverage-7.15.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29c052f7c83ccfcc5c577eaae025d2e4a9bb80daf03c0ac31c996e83b000ce88", size = 249154, upload-time = "2026-07-15T18:53:47.256Z" }, + { url = "https://files.pythonhosted.org/packages/2c/65/529808e91d651147edae408fd9e894abc3b8cad7f3e594bbc36719a3e13a/coverage-7.15.2-cp310-cp310-win32.whl", hash = "sha256:1268ac8fb9ddcd783d3948dbabaf80a5d53bfdaa0575e873e2139a692f797443", size = 223334, upload-time = "2026-07-15T18:53:48.768Z" }, + { url = "https://files.pythonhosted.org/packages/68/0f/0e1829d7001130876dfbc0b4e1c737ea7c155b809e3e4a98a0aa268e2369/coverage-7.15.2-cp310-cp310-win_amd64.whl", hash = "sha256:9f4432898c4bf2fba0435bbe35dd4437d7264565e5a88a21f5b49d8662a6b629", size = 223959, upload-time = "2026-07-15T18:53:50.429Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" }, + { url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" }, + { url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" }, + { url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" }, + { url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" }, + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "45.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/1e/49527ac611af559665f71cbb8f92b332b5ec9c6fbc4e88b0f8e92f5e85df/cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a", size = 744903, upload-time = "2025-07-02T13:06:25.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/fb/09e28bc0c46d2c547085e60897fea96310574c70fb21cd58a730a45f3403/cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8", size = 7043092, upload-time = "2025-07-02T13:05:01.514Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/2194432935e29b91fb649f6149c1a4f9e6d3d9fc880919f4ad1bcc22641e/cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d", size = 4205926, upload-time = "2025-07-02T13:05:04.741Z" }, + { url = "https://files.pythonhosted.org/packages/07/8b/9ef5da82350175e32de245646b1884fc01124f53eb31164c77f95a08d682/cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5", size = 4429235, upload-time = "2025-07-02T13:05:07.084Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e1/c809f398adde1994ee53438912192d92a1d0fc0f2d7582659d9ef4c28b0c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57", size = 4209785, upload-time = "2025-07-02T13:05:09.321Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8b/07eb6bd5acff58406c5e806eff34a124936f41a4fb52909ffa4d00815f8c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0", size = 3893050, upload-time = "2025-07-02T13:05:11.069Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ef/3333295ed58d900a13c92806b67e62f27876845a9a908c939f040887cca9/cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d", size = 4457379, upload-time = "2025-07-02T13:05:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9d/44080674dee514dbb82b21d6fa5d1055368f208304e2ab1828d85c9de8f4/cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9", size = 4209355, upload-time = "2025-07-02T13:05:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d8/0749f7d39f53f8258e5c18a93131919ac465ee1f9dccaf1b3f420235e0b5/cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27", size = 4456087, upload-time = "2025-07-02T13:05:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/09/d7/92acac187387bf08902b0bf0699816f08553927bdd6ba3654da0010289b4/cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e", size = 4332873, upload-time = "2025-07-02T13:05:18.743Z" }, + { url = "https://files.pythonhosted.org/packages/03/c2/840e0710da5106a7c3d4153c7215b2736151bba60bf4491bdb421df5056d/cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174", size = 4564651, upload-time = "2025-07-02T13:05:21.382Z" }, + { url = "https://files.pythonhosted.org/packages/2e/92/cc723dd6d71e9747a887b94eb3827825c6c24b9e6ce2bb33b847d31d5eaa/cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9", size = 2929050, upload-time = "2025-07-02T13:05:23.39Z" }, + { url = "https://files.pythonhosted.org/packages/1f/10/197da38a5911a48dd5389c043de4aec4b3c94cb836299b01253940788d78/cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63", size = 3403224, upload-time = "2025-07-02T13:05:25.202Z" }, + { url = "https://files.pythonhosted.org/packages/fe/2b/160ce8c2765e7a481ce57d55eba1546148583e7b6f85514472b1d151711d/cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8", size = 7017143, upload-time = "2025-07-02T13:05:27.229Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e7/2187be2f871c0221a81f55ee3105d3cf3e273c0a0853651d7011eada0d7e/cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd", size = 4197780, upload-time = "2025-07-02T13:05:29.299Z" }, + { url = "https://files.pythonhosted.org/packages/b9/cf/84210c447c06104e6be9122661159ad4ce7a8190011669afceeaea150524/cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e", size = 4420091, upload-time = "2025-07-02T13:05:31.221Z" }, + { url = "https://files.pythonhosted.org/packages/3e/6a/cb8b5c8bb82fafffa23aeff8d3a39822593cee6e2f16c5ca5c2ecca344f7/cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0", size = 4198711, upload-time = "2025-07-02T13:05:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/04/f7/36d2d69df69c94cbb2473871926daf0f01ad8e00fe3986ac3c1e8c4ca4b3/cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135", size = 3883299, upload-time = "2025-07-02T13:05:34.94Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f0ea40f016de72f81288e9fe8d1f6748036cb5ba6118774317a3ffc6022d/cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7", size = 4450558, upload-time = "2025-07-02T13:05:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/94b504dc1a3cdf642d710407c62e86296f7da9e66f27ab12a1ee6fdf005b/cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42", size = 4198020, upload-time = "2025-07-02T13:05:39.102Z" }, + { url = "https://files.pythonhosted.org/packages/05/2b/aaf0adb845d5dabb43480f18f7ca72e94f92c280aa983ddbd0bcd6ecd037/cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492", size = 4449759, upload-time = "2025-07-02T13:05:41.398Z" }, + { url = "https://files.pythonhosted.org/packages/91/e4/f17e02066de63e0100a3a01b56f8f1016973a1d67551beaf585157a86b3f/cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0", size = 4319991, upload-time = "2025-07-02T13:05:43.64Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2e/e2dbd629481b499b14516eed933f3276eb3239f7cee2dcfa4ee6b44d4711/cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a", size = 4554189, upload-time = "2025-07-02T13:05:46.045Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ea/a78a0c38f4c8736287b71c2ea3799d173d5ce778c7d6e3c163a95a05ad2a/cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f", size = 2911769, upload-time = "2025-07-02T13:05:48.329Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/28ac139109d9005ad3f6b6f8976ffede6706a6478e21c889ce36c840918e/cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97", size = 3390016, upload-time = "2025-07-02T13:05:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8b/34394337abe4566848a2bd49b26bcd4b07fd466afd3e8cce4cb79a390869/cryptography-45.0.5-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:206210d03c1193f4e1ff681d22885181d47efa1ab3018766a7b32a7b3d6e6afd", size = 3575762, upload-time = "2025-07-02T13:05:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5d/a19441c1e89afb0f173ac13178606ca6fab0d3bd3ebc29e9ed1318b507fc/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c648025b6840fe62e57107e0a25f604db740e728bd67da4f6f060f03017d5097", size = 4140906, upload-time = "2025-07-02T13:05:55.914Z" }, + { url = "https://files.pythonhosted.org/packages/4b/db/daceb259982a3c2da4e619f45b5bfdec0e922a23de213b2636e78ef0919b/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b8fa8b0a35a9982a3c60ec79905ba5bb090fc0b9addcfd3dc2dd04267e45f25e", size = 4374411, upload-time = "2025-07-02T13:05:57.814Z" }, + { url = "https://files.pythonhosted.org/packages/6a/35/5d06ad06402fc522c8bf7eab73422d05e789b4e38fe3206a85e3d6966c11/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:14d96584701a887763384f3c47f0ca7c1cce322aa1c31172680eb596b890ec30", size = 4140942, upload-time = "2025-07-02T13:06:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/65/79/020a5413347e44c382ef1f7f7e7a66817cd6273e3e6b5a72d18177b08b2f/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57c816dfbd1659a367831baca4b775b2a5b43c003daf52e9d57e1d30bc2e1b0e", size = 4374079, upload-time = "2025-07-02T13:06:02.043Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c5/c0e07d84a9a2a8a0ed4f865e58f37c71af3eab7d5e094ff1b21f3f3af3bc/cryptography-45.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b9e38e0a83cd51e07f5a48ff9691cae95a79bea28fe4ded168a8e5c6c77e819d", size = 3321362, upload-time = "2025-07-02T13:06:04.463Z" }, + { url = "https://files.pythonhosted.org/packages/c0/71/9bdbcfd58d6ff5084687fe722c58ac718ebedbc98b9f8f93781354e6d286/cryptography-45.0.5-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8c4a6ff8a30e9e3d38ac0539e9a9e02540ab3f827a3394f8852432f6b0ea152e", size = 3587878, upload-time = "2025-07-02T13:06:06.339Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/83516cfb87f4a8756eaa4203f93b283fda23d210fc14e1e594bd5f20edb6/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bd4c45986472694e5121084c6ebbd112aa919a25e783b87eb95953c9573906d6", size = 4152447, upload-time = "2025-07-02T13:06:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/22/11/d2823d2a5a0bd5802b3565437add16f5c8ce1f0778bf3822f89ad2740a38/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:982518cd64c54fcada9d7e5cf28eabd3ee76bd03ab18e08a48cad7e8b6f31b18", size = 4386778, upload-time = "2025-07-02T13:06:10.263Z" }, + { url = "https://files.pythonhosted.org/packages/5f/38/6bf177ca6bce4fe14704ab3e93627c5b0ca05242261a2e43ef3168472540/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:12e55281d993a793b0e883066f590c1ae1e802e3acb67f8b442e721e475e6463", size = 4151627, upload-time = "2025-07-02T13:06:13.097Z" }, + { url = "https://files.pythonhosted.org/packages/38/6a/69fc67e5266bff68a91bcb81dff8fb0aba4d79a78521a08812048913e16f/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:5aa1e32983d4443e310f726ee4b071ab7569f58eedfdd65e9675484a4eb67bd1", size = 4385593, upload-time = "2025-07-02T13:06:15.689Z" }, + { url = "https://files.pythonhosted.org/packages/f6/34/31a1604c9a9ade0fdab61eb48570e09a796f4d9836121266447b0eaf7feb/cryptography-45.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e357286c1b76403dd384d938f93c46b2b058ed4dfcdce64a770f0537ed3feb6f", size = 3331106, upload-time = "2025-07-02T13:06:18.058Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fastapi" +version = "0.116.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485, upload-time = "2025-07-11T16:22:32.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" }, +] + +[[package]] +name = "gmssl" +version = "3.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycryptodomex" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/b1/01d707a2edfaad77715b2d27e5fbf14b6bcd34dd72ea179a5facfe4b1dd7/gmssl-3.2.2-py3-none-any.whl", hash = "sha256:59f069a91eb19ef59b9e7be4d436ed01c92ce064d3d7d45a8778fc07fd2cd068", size = 10185, upload-time = "2023-02-23T06:01:39.296Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/a1/1f7f0c555f5858fd2906fe9f7b0a3554fddb85cb70df7a6aaec41dc292c2/greenlet-3.5.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c180d22d325fb613956b443c3c6f4406eb70e6defc70d3974da2a7b59e06f48c", size = 285838, upload-time = "2026-06-26T18:21:05.167Z" }, + { url = "https://files.pythonhosted.org/packages/0a/29/be9f43ed61677a5759b38c8a9389248133c8c731bbfc0574ecdff66c99fc/greenlet-3.5.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483d08c11181c83a6ce1a7a61df0f624a208ec40817a3bb2302714592eee4f04", size = 602342, upload-time = "2026-06-26T19:07:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/ba41c97ec36aa4b3ec25e5aa691d79561254805fad7f2f826dd6770587e2/greenlet-3.5.3-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1dae6e0091eae084317e411f047f0b7cb241c6db570f7c45fd6b900a274914ce", size = 615541, upload-time = "2026-06-26T19:10:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/2e/8c/231ca675b0df779816950ca66b40b1fa14dbff4a0ed9814a9a29ec399140/greenlet-3.5.3-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f6ff50ff8dbd51fae9b37f4101648b04ea0df19b3f50ab2beb5061e7716a5c8", size = 622473, upload-time = "2026-06-26T19:24:12.786Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c7/28747042e1df8a9cd120a1ebe15529fc4be3b486e13e8d551ff307a82412/greenlet-3.5.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcd2d72ccd70a1ec68ba6ef93e7fbb4420ef9997dabc7010d893bd4015e0bec", size = 615675, upload-time = "2026-06-26T18:32:14.444Z" }, + { url = "https://files.pythonhosted.org/packages/81/fe/dd97c483a3ff82849196ccd07851600edd3ac9de74669ca8a6022ada9ea1/greenlet-3.5.3-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:37bf9c538f5ae6e63d643f88dec37c0c83bdf0e2ebc62961dedcf458822f7b71", size = 418421, upload-time = "2026-06-26T19:25:34.503Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a8/b85525a6c8fba9f009a5f7c8df1545de8fb0f0bf3e0179194ef4e500317f/greenlet-3.5.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:73f152c895e09907e0dbe24f6c2db37beb085cd63db91c3825a0fcd0064124a8", size = 1575057, upload-time = "2026-06-26T19:09:00.264Z" }, + { url = "https://files.pythonhosted.org/packages/03/79/fb76edb218fe6735ab0edeba176c7ab80df9618f7c02ce4208979f3ae7db/greenlet-3.5.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8bdb43e1a1d1873721acab2be99c5befd4d2044ddfd52e4d610801019880a702", size = 1641692, upload-time = "2026-06-26T18:31:41.454Z" }, + { url = "https://files.pythonhosted.org/packages/6b/79/86fe3ee50ed55d9b3907eecd3208b5c3fe8a79515519aae98b4753c3fa1d/greenlet-3.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:0909f9355a9f24845d3299f3112e266a06afb68302041989fd26bd68894933db", size = 238742, upload-time = "2026-06-26T18:20:40.758Z" }, + { url = "https://files.pythonhosted.org/packages/51/58/5404031044f55afad7aad1aff8be3f22b1bed03e237cfeabbc7e5c8cfde0/greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4", size = 287424, upload-time = "2026-06-26T18:20:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bf/1c65e9b94a54d547068fa5b5a8a06f221f3316b48908e08668d29c77cb50/greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc", size = 606523, upload-time = "2026-06-26T19:07:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/b66baacc95775ad511287acb0137b95574a9ce5491902372b7564799d790/greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6", size = 618315, upload-time = "2026-06-26T19:10:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a0/68afd1ebad40db87dac0a28ffa120726b98bf9c7c40c481b0f63c105d298/greenlet-3.5.3-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e18619ba655ac05d78d80fc83cac4ba892bd6927b99e3b8237aee861aaacc8bb", size = 626155, upload-time = "2026-06-26T19:24:14.44Z" }, + { url = "https://files.pythonhosted.org/packages/78/2b/28ed29463522fdbe4c15b1f63922041626a7478316b34ab4adda3f0a4aba/greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7", size = 617381, upload-time = "2026-06-26T18:32:16.077Z" }, + { url = "https://files.pythonhosted.org/packages/07/7f/e327d912239ec4b3b49999e3967389bcf1ee8722b9ee9194d2752ecd558a/greenlet-3.5.3-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d27c0c653a60d9535f690226474a5cc1036a8b0d7b57504d1c4f89c44a07a80c", size = 421083, upload-time = "2026-06-26T19:25:35.804Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7b/ad04e9d1337fc04965dc9fc616b6a72cb65a24b800a014c011ec812f5489/greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8", size = 1577771, upload-time = "2026-06-26T19:09:01.537Z" }, + { url = "https://files.pythonhosted.org/packages/d8/33/6c87ab7ba663f70ca21f3022aad1ffe56d3f3e0521e836c2415e13abcc3c/greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8", size = 1644048, upload-time = "2026-06-26T18:31:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/1c/35/f0d8ee998b422cf8693b270f098e55d8d4ec8006b061b333f54f177d28d9/greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7", size = 239137, upload-time = "2026-06-26T18:23:21.664Z" }, + { url = "https://files.pythonhosted.org/packages/fb/96/b9820295576ef18c9edc404f10e260ae7215ceaf3781a54b720ed2627862/greenlet-3.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:ec6f1af59f6b5f3fc9678e2ea062d8377d22ac644f7844cb7a292910cf12ff44", size = 237630, upload-time = "2026-06-26T18:24:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, + { url = "https://files.pythonhosted.org/packages/fb/fb/d97dc261209c80744b7c8132693a30d70ec6e7315e632cb0a10b3fec94dd/greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23", size = 622351, upload-time = "2026-06-26T19:24:16.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/e5fee13cbbd0e8de312d9a146584b8a51891c68847330ef9dc8b5109d23f/greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c", size = 425395, upload-time = "2026-06-26T19:25:37.144Z" }, + { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, + { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, + { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hiredis" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/e2/1654d65851f39fd94e91a77a5655d09d4b64901fdc594020d8348db697b2/hiredis-3.4.0.tar.gz", hash = "sha256:da19331354433af6a2c54c21f2d70ba084933c0d7d2c43578ec5c5b446674ad5", size = 137169, upload-time = "2026-06-03T16:23:46.226Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/48/b0c0e2826eab7543c08980dfab871b3e7c83c47d7496134b04a94df55a1a/hiredis-3.4.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:69d0326f20354ce278cbb86f5ae47cb390e22bb94a66877031038af907c42fa5", size = 138470, upload-time = "2026-06-03T16:21:49.96Z" }, + { url = "https://files.pythonhosted.org/packages/6f/75/2a08a6062228720747570a07ab42e6f5826725f09c9a95d75b7b5b938022/hiredis-3.4.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:4863b99b1bf739eaa60961798efc709f657864fbf5a142cb9b99d3e36a37208e", size = 74496, upload-time = "2026-06-03T16:21:51.14Z" }, + { url = "https://files.pythonhosted.org/packages/e0/3d/28c61f9c628dfaf1f96bb8b8592cb006eaad3747248c95f9ae7f694abb47/hiredis-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:98e28c10e43d076f50ce9fa9f4017303d5796c3058b1b651f507c2a7d6ef402c", size = 70083, upload-time = "2026-06-03T16:21:52.125Z" }, + { url = "https://files.pythonhosted.org/packages/31/06/21e254be776b6ecd38e7c955c2fe205f2828c091621fbe400406bd2e382e/hiredis-3.4.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6774f1fe2723001ca0cd42bf5d8b1235301226273915c581c5c1260d4d114c43", size = 304408, upload-time = "2026-06-03T16:21:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c8/46d48dc674d0aacc66cdc8c400261fad3c08b352dd823e6ffa0a0536259d/hiredis-3.4.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:12eca9aea1450d1a85dc15574a985c227e52abbc2b6466f48ad2aa3b82124701", size = 336932, upload-time = "2026-06-03T16:21:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/50/29/05a3cf8f605f6cdeec2c6f54d022fa51242e3cf77fe4940e89fe1446b068/hiredis-3.4.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12ea5facb5b08fa23e4c101ec2151f3a3de8ecec412fec58dbde0a6eebca02c7", size = 347528, upload-time = "2026-06-03T16:21:55.541Z" }, + { url = "https://files.pythonhosted.org/packages/26/c9/4e9cd249afc101ac283943295fe3359bdd711a0bb8c667752eb0da80609d/hiredis-3.4.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4de6869be2b33490569dae0712366bb794b7f5e7a8b674de3e092b3e95712d6d", size = 310142, upload-time = "2026-06-03T16:21:56.534Z" }, + { url = "https://files.pythonhosted.org/packages/4a/71/d069db71ba4a5f40bb1390eebaf00e4d161c5c1f48e623880ca22a946618/hiredis-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4190bd07dd7879a8a7ddbb2a4f74d402721f3898276e35beb98851b85b5f539c", size = 298868, upload-time = "2026-06-03T16:21:57.559Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/83793cc2fb161ddd5d394adc7122ec023b0e1d9289a294d0f80214d910bf/hiredis-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e29267ecdd08758926f1a9221af2671d90f475480c40aff409921b1f362f1bd5", size = 328564, upload-time = "2026-06-03T16:21:58.57Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fa/66fed95ab85d85a4dc87acb8df69e22ff943a3bf7a26e791d5a1ff173577/hiredis-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:45c6c296056641b5df37cedafe7d1553f33bc247e2f81603a4d038b39261879b", size = 329725, upload-time = "2026-06-03T16:21:59.519Z" }, + { url = "https://files.pythonhosted.org/packages/a9/0d/ac2d1f1c30eb6d7ab5f099da17f76125f1bb0f9274623178508a6c736acf/hiredis-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f7c7596fbb2b5202e943180353958e89014e763c7f25877a92f70bbde6cd7f19", size = 309144, upload-time = "2026-06-03T16:22:00.691Z" }, + { url = "https://files.pythonhosted.org/packages/d6/41/ec1dc1c27e8fcbe2dc635d49cc751848972600a7d277569fb9ba77ee501c/hiredis-3.4.0-cp310-cp310-win32.whl", hash = "sha256:1bfb9ccfb13be63883e5f2e5ff7f6fc87bf256f8243af594257dfbed9dbc3cf0", size = 38820, upload-time = "2026-06-03T16:22:01.657Z" }, + { url = "https://files.pythonhosted.org/packages/24/9d/38b85c7fd3ec49c8b1b089288307f8f17e138439be7b079fab2221e113a8/hiredis-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:c2245c46b4ced5f689469e6dcdfc8a0895bf873840a6600f5ea759cdf1b26a8b", size = 40048, upload-time = "2026-06-03T16:22:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ce/bdc7602ddc7b60ada44be4c4246c1b4d54a0b444a2b5f17ec936c0ce0faf/hiredis-3.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:0bcb630add6bc9ea136fce691ddff0c46aa91cb860df4ca789fe44127eb7e90d", size = 36849, upload-time = "2026-06-03T16:22:03.3Z" }, + { url = "https://files.pythonhosted.org/packages/92/d1/09d7323c76d097ff3f6530228d2422c19817b6052716f9a652ecd6e2f68e/hiredis-3.4.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:7f7fc1535f6e1a190089eae46dee25f0c6b72bb221d377be07092803b8208733", size = 138467, upload-time = "2026-06-03T16:22:04.09Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c4ebeb0f7ecc8a23d4356efd3ef2b6243ed74d24584d86ff8065fa14a350/hiredis-3.4.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:ed1dba2695f6de009c67d63b39ff978cb43b8a79362f697acedffb7743e50d21", size = 74504, upload-time = "2026-06-03T16:22:04.998Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d7/4f456f36f5c5224bc11a2fad964116a3cc37259d09dd840628aea5fdbf28/hiredis-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3796094f616f72976ff51e4dc1a016e753c0f9af5393b2df96920b6bae1e19b", size = 70080, upload-time = "2026-06-03T16:22:05.76Z" }, + { url = "https://files.pythonhosted.org/packages/04/ba/a16d44b2bd71e72a10673faa94d07cc4e9de90240b65ce2511af0cce065b/hiredis-3.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ccc5c660e31d788ca534a20f2ccb7a80b946b960e18ed4e1db950fcac122b405", size = 304968, upload-time = "2026-06-03T16:22:06.614Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3a/78ca23fe899f8da7ee2caf9c502ac1a63da15d521f33a3fc617a7adbf2e0/hiredis-3.4.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f3c67f39b112dc35f68d5b59ee111db6121f037d1a60cf3840ecffbb2ec5686b", size = 337465, upload-time = "2026-06-03T16:22:07.622Z" }, + { url = "https://files.pythonhosted.org/packages/ba/11/2df9a12f170e9d61739e7df5f06712141414b2dce2cf385fc1fb6f31a46b/hiredis-3.4.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bca175f02a2b0150ffe7f5dc8bf49c798f34d2c7024d17ace0ec97a7583560e3", size = 348293, upload-time = "2026-06-03T16:22:08.677Z" }, + { url = "https://files.pythonhosted.org/packages/88/07/716ffeb049377d92da6261c5563e554b82336ce3eafb11eb4510c5558be7/hiredis-3.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43004b0b48abc628dda1ac3ac4871e1326c126f8cd9f11164d61934d827d7a3b", size = 310697, upload-time = "2026-06-03T16:22:09.661Z" }, + { url = "https://files.pythonhosted.org/packages/5d/03/ef3697bdee359b4521101bdc16e8e4965a5ebd8634b605fc7cf9c01b6b82/hiredis-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8aaaab18314fd25453b5cf59c8cdca4110e419455bcb4c0737d19d4151513e75", size = 299377, upload-time = "2026-06-03T16:22:10.777Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a7/2a12a2f828c2d611b74dcf2229998c4d2570fe6ed6b4903d6a4c3add84af/hiredis-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5359caad5b57da0bce11d2880f22617ba3710f0866121a924745447848448034", size = 329008, upload-time = "2026-06-03T16:22:11.82Z" }, + { url = "https://files.pythonhosted.org/packages/66/a9/cdfda214af93eeb9f93a83a099d06f26ae5569f188209ddc8a7c977ed446/hiredis-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:44660a91e0fbc803c29b337c1a9194c8d7b4cd3a3868d28f747cbec2df165483", size = 330103, upload-time = "2026-06-03T16:22:12.935Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/cdc7e2e07b56c716426db4644b917b260a4f6fdc8d16cc3bbac4b27d0a17/hiredis-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a315009b441a0105a373a9a780ebb1c6f7d9ead88ac6ea5f2a15791353c6f590", size = 309582, upload-time = "2026-06-03T16:22:14.157Z" }, + { url = "https://files.pythonhosted.org/packages/f2/36/304a0e029cb6e44add3b0d664315de25c483f6e8f8e1d413c68de969a3d0/hiredis-3.4.0-cp311-cp311-win32.whl", hash = "sha256:282c4310af72afbe18b07d416459f4febeaeb805a067a7df790136e0e550fcb2", size = 38823, upload-time = "2026-06-03T16:22:15.14Z" }, + { url = "https://files.pythonhosted.org/packages/f0/19/7ea1fdbee1c42cbac140005e66e60a1198548eea04456e17dab5c285e31b/hiredis-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:bb44efa4fa3e3ed7779ad0ade3c08ed5d75ca7a6336893e9a4f2722093b4168a", size = 40040, upload-time = "2026-06-03T16:22:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/20/e4/2122980b75a3fa8980540e2265028c757564ecc4d813b40298d29dd876ea/hiredis-3.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:4404c557fd49bcfe24dff41f1209e4221c76d1607df2fb2dfd39474b5b086dcb", size = 36851, upload-time = "2026-06-03T16:22:16.644Z" }, + { url = "https://files.pythonhosted.org/packages/d2/84/f74deb132d238a0d5a3eb1618bf7558c65230b279421f909a9753231c516/hiredis-3.4.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:9e88048a66dfffec7a3f578f2a2a0fd907c75b5bd85b3c9184f76f0149ea399f", size = 138679, upload-time = "2026-06-03T16:22:17.598Z" }, + { url = "https://files.pythonhosted.org/packages/a2/13/399fe51d399b8d4f5717aa68cb1dafcb8c244b19b1b9b0afaaa526c1be94/hiredis-3.4.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:8b3f1d03046765c0a83558bf1756811101e3947649c7ca22a71d9dc3c92929d1", size = 74657, upload-time = "2026-06-03T16:22:18.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cf/6a0bcf454b1642997c4dd007bd89beada43f38b22781afdf475060e427ac/hiredis-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:24751054bb11353016d242d09a4a902ecf8f25e3b56fe396cccb6f056fdda016", size = 70115, upload-time = "2026-06-03T16:22:19.649Z" }, + { url = "https://files.pythonhosted.org/packages/98/99/62340215f80e59680c79ae5080c5422311da105870c57bbefc5d87487025/hiredis-3.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:258f820cdd6ee6be39ae6a8ea94a76b8856d34113de6604f63bc81327ef06240", size = 306481, upload-time = "2026-06-03T16:22:20.608Z" }, + { url = "https://files.pythonhosted.org/packages/f1/be/97f349e5bb0dcab0ef28b15523443d9bbe81f8ccbd3dadff56594dfa82fe/hiredis-3.4.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3774461209688790734b5db8934400a4456493fc1a172fb5298cc5d72201aceb", size = 339560, upload-time = "2026-06-03T16:22:21.861Z" }, + { url = "https://files.pythonhosted.org/packages/1e/3f/eb6a9632bcc13a3fbefce5de90090052fb1ae1cd3d57faf687f20149d592/hiredis-3.4.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccdb63363c82ea9cea2d48126bc8e9241437b8b3b36413e967647a17add59643", size = 351549, upload-time = "2026-06-03T16:22:22.969Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/440369f727dcb856f3eeda238d6e67781b180feaa831bd28997d8af10c3b/hiredis-3.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:452cff764acb30c106d1e33f1bdf03fa9d4a9b0a9c995d722d4d39c998b40582", size = 313066, upload-time = "2026-06-03T16:22:23.987Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/3d76c4d5c46cd2e7b38641f7c8b325e0cab7d49d565ea573256eb3837d0c/hiredis-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fb0a139cd52535f3e5a532816b5c36b3aea95817410fbf28ca4a676026347a5", size = 300827, upload-time = "2026-06-03T16:22:25.287Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bc/d112dd9704ae47243a515fb021ec4d0b5a1b8d83a7a3eff3284c0248412d/hiredis-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:163d8c43e2706d23490532ea0de8736fc1493cfa52f0ee65f85b0f074f2fe017", size = 331284, upload-time = "2026-06-03T16:22:26.385Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7b/8a4dc0a15e4658c81a9e79b2c167fbfbf750e0c1c7ef13e00e69d4273ced/hiredis-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4b8f52844cd260d7805eca55c834e3e06b4c0d5b53a4178143b92242c2517c0d", size = 332962, upload-time = "2026-06-03T16:22:27.392Z" }, + { url = "https://files.pythonhosted.org/packages/1d/52/d3d0bb234de8deb4cbd432cdc63d001a6cad1f9c05fe07d2fa652f8cf412/hiredis-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03374d663b0e025e4039757ef5fad02e3ff714f7a01e5b34c88de2a9c91359dc", size = 311698, upload-time = "2026-06-03T16:22:28.442Z" }, + { url = "https://files.pythonhosted.org/packages/04/5b/54a052eccaf901703b57d7c28509e74341fa0da08d770f485345397ea1e5/hiredis-3.4.0-cp312-cp312-win32.whl", hash = "sha256:696e0a2118e1df5ccacf8ecf8abe528cf0c4f1f1d867f64c34579bef77778cdb", size = 38921, upload-time = "2026-06-03T16:22:29.39Z" }, + { url = "https://files.pythonhosted.org/packages/a7/64/6508236eda66765fbe873d1d0a0722e38059302e96dc9915b162ff17b35a/hiredis-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:ee6b4beb79a71df67af15a8451366babc2687fcac674d5c6eacec4197e4ce8c1", size = 40090, upload-time = "2026-06-03T16:22:30.204Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1c/7333aba1b4b7cef2591b244140aec0f1aad903397bbaa31c1858722b2fe4/hiredis-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:14524fdc751e3960d78d848872576b5442b40baae3cac14fbab1ba7ac523891f", size = 36875, upload-time = "2026-06-03T16:22:31.087Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/b9/be66eb0decd730d89b9c94f930e4b8d87787b05724bb84af98bfd825f72c/httptools-0.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826", size = 208805, upload-time = "2026-05-25T22:16:50.434Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f7/b4d41eaae2869d31356bc4bbf546f44fae83ff298af0a043ca0625b06773/httptools-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77", size = 113527, upload-time = "2026-05-25T22:16:51.672Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e4/77487e14fc7be47180fd0eb4267c7486d0cc59b74031839a3daf8650136b/httptools-0.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4", size = 450035, upload-time = "2026-05-25T22:16:53.313Z" }, + { url = "https://files.pythonhosted.org/packages/da/72/5a8f787e323f56fbd86c32a4be92a86776e4cfe8b4317db999f452028362/httptools-0.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb", size = 451101, upload-time = "2026-05-25T22:16:54.696Z" }, + { url = "https://files.pythonhosted.org/packages/ed/41/b44a25560955197674b6744cb903664300e239235a5eaa69df0890d87054/httptools-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813", size = 436140, upload-time = "2026-05-25T22:16:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/74/b0/054aac84c03d7e097bf4c605fb7e74eec3d65c0276adf64ee97f3a103ff5/httptools-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba", size = 437041, upload-time = "2026-05-25T22:16:57.716Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e8/86b85bbc0ac7892232f1a99ab96a9aa71936984fa06adfc0afc83ca7789e/httptools-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557", size = 90454, upload-time = "2026-05-25T22:16:58.871Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mypy" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/e3/034322d5a779685218ed69286c32faa505247f1f096251ef66c8fd203b08/mypy-1.17.0.tar.gz", hash = "sha256:e5d7ccc08ba089c06e2f5629c660388ef1fee708444f1dee0b9203fa031dee03", size = 3352114, upload-time = "2025-07-14T20:34:30.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/31/e762baa3b73905c856d45ab77b4af850e8159dffffd86a52879539a08c6b/mypy-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8e08de6138043108b3b18f09d3f817a4783912e48828ab397ecf183135d84d6", size = 10998313, upload-time = "2025-07-14T20:33:24.519Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c1/25b2f0d46fb7e0b5e2bee61ec3a47fe13eff9e3c2f2234f144858bbe6485/mypy-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ce4a17920ec144647d448fc43725b5873548b1aae6c603225626747ededf582d", size = 10128922, upload-time = "2025-07-14T20:34:06.414Z" }, + { url = "https://files.pythonhosted.org/packages/02/78/6d646603a57aa8a2886df1b8881fe777ea60f28098790c1089230cd9c61d/mypy-1.17.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ff25d151cc057fdddb1cb1881ef36e9c41fa2a5e78d8dd71bee6e4dcd2bc05b", size = 11913524, upload-time = "2025-07-14T20:33:19.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/19/dae6c55e87ee426fb76980f7e78484450cad1c01c55a1dc4e91c930bea01/mypy-1.17.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93468cf29aa9a132bceb103bd8475f78cacde2b1b9a94fd978d50d4bdf616c9a", size = 12650527, upload-time = "2025-07-14T20:32:44.095Z" }, + { url = "https://files.pythonhosted.org/packages/86/e1/f916845a235235a6c1e4d4d065a3930113767001d491b8b2e1b61ca56647/mypy-1.17.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:98189382b310f16343151f65dd7e6867386d3e35f7878c45cfa11383d175d91f", size = 12897284, upload-time = "2025-07-14T20:33:38.168Z" }, + { url = "https://files.pythonhosted.org/packages/ae/dc/414760708a4ea1b096bd214d26a24e30ac5e917ef293bc33cdb6fe22d2da/mypy-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:c004135a300ab06a045c1c0d8e3f10215e71d7b4f5bb9a42ab80236364429937", size = 9506493, upload-time = "2025-07-14T20:34:01.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/82efb502b0b0f661c49aa21cfe3e1999ddf64bf5500fc03b5a1536a39d39/mypy-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9d4fe5c72fd262d9c2c91c1117d16aac555e05f5beb2bae6a755274c6eec42be", size = 10914150, upload-time = "2025-07-14T20:31:51.985Z" }, + { url = "https://files.pythonhosted.org/packages/03/96/8ef9a6ff8cedadff4400e2254689ca1dc4b420b92c55255b44573de10c54/mypy-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d96b196e5c16f41b4f7736840e8455958e832871990c7ba26bf58175e357ed61", size = 10039845, upload-time = "2025-07-14T20:32:30.527Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/7ce359a56be779d38021d07941cfbb099b41411d72d827230a36203dbb81/mypy-1.17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73a0ff2dd10337ceb521c080d4147755ee302dcde6e1a913babd59473904615f", size = 11837246, upload-time = "2025-07-14T20:32:01.28Z" }, + { url = "https://files.pythonhosted.org/packages/82/16/b775047054de4d8dbd668df9137707e54b07fe18c7923839cd1e524bf756/mypy-1.17.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24cfcc1179c4447854e9e406d3af0f77736d631ec87d31c6281ecd5025df625d", size = 12571106, upload-time = "2025-07-14T20:34:26.942Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cf/fa33eaf29a606102c8d9ffa45a386a04c2203d9ad18bf4eef3e20c43ebc8/mypy-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c56f180ff6430e6373db7a1d569317675b0a451caf5fef6ce4ab365f5f2f6c3", size = 12759960, upload-time = "2025-07-14T20:33:42.882Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/3f5a29209f27e739ca57e6350bc6b783a38c7621bdf9cac3ab8a08665801/mypy-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:eafaf8b9252734400f9b77df98b4eee3d2eecab16104680d51341c75702cad70", size = 9503888, upload-time = "2025-07-14T20:32:34.392Z" }, + { url = "https://files.pythonhosted.org/packages/12/e9/e6824ed620bbf51d3bf4d6cbbe4953e83eaf31a448d1b3cfb3620ccb641c/mypy-1.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f986f1cab8dbec39ba6e0eaa42d4d3ac6686516a5d3dccd64be095db05ebc6bb", size = 11086395, upload-time = "2025-07-14T20:34:11.452Z" }, + { url = "https://files.pythonhosted.org/packages/ba/51/a4afd1ae279707953be175d303f04a5a7bd7e28dc62463ad29c1c857927e/mypy-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:51e455a54d199dd6e931cd7ea987d061c2afbaf0960f7f66deef47c90d1b304d", size = 10120052, upload-time = "2025-07-14T20:33:09.897Z" }, + { url = "https://files.pythonhosted.org/packages/8a/71/19adfeac926ba8205f1d1466d0d360d07b46486bf64360c54cb5a2bd86a8/mypy-1.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3204d773bab5ff4ebbd1f8efa11b498027cd57017c003ae970f310e5b96be8d8", size = 11861806, upload-time = "2025-07-14T20:32:16.028Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/d6120eca3835baf7179e6797a0b61d6c47e0bc2324b1f6819d8428d5b9ba/mypy-1.17.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1051df7ec0886fa246a530ae917c473491e9a0ba6938cfd0ec2abc1076495c3e", size = 12744371, upload-time = "2025-07-14T20:33:33.503Z" }, + { url = "https://files.pythonhosted.org/packages/1f/dc/56f53b5255a166f5bd0f137eed960e5065f2744509dfe69474ff0ba772a5/mypy-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f773c6d14dcc108a5b141b4456b0871df638eb411a89cd1c0c001fc4a9d08fc8", size = 12914558, upload-time = "2025-07-14T20:33:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/69/ac/070bad311171badc9add2910e7f89271695a25c136de24bbafc7eded56d5/mypy-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:1619a485fd0e9c959b943c7b519ed26b712de3002d7de43154a489a2d0fd817d", size = 9585447, upload-time = "2025-07-14T20:32:20.594Z" }, + { url = "https://files.pythonhosted.org/packages/e3/fc/ee058cc4316f219078464555873e99d170bde1d9569abd833300dbeb484a/mypy-1.17.0-py3-none-any.whl", hash = "sha256:15d9d0018237ab058e5de3d8fce61b6fa72cc59cc78fd91f1b474bce12abf496", size = 2283195, upload-time = "2025-07-14T20:31:54.753Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/5d/45a3553a253ac8763f3561371432a90bdbe6000fbdcf1397ffe502aa206c/pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860", size = 5316554, upload-time = "2025-07-01T09:13:39.342Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c8/67c12ab069ef586a25a4a79ced553586748fad100c77c0ce59bb4983ac98/pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad", size = 4686548, upload-time = "2025-07-01T09:13:41.835Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bd/6741ebd56263390b382ae4c5de02979af7f8bd9807346d068700dd6d5cf9/pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0", size = 5859742, upload-time = "2025-07-03T13:09:47.439Z" }, + { url = "https://files.pythonhosted.org/packages/ca/0b/c412a9e27e1e6a829e6ab6c2dca52dd563efbedf4c9c6aa453d9a9b77359/pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b", size = 7633087, upload-time = "2025-07-03T13:09:51.796Z" }, + { url = "https://files.pythonhosted.org/packages/59/9d/9b7076aaf30f5dd17e5e5589b2d2f5a5d7e30ff67a171eb686e4eecc2adf/pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50", size = 5963350, upload-time = "2025-07-01T09:13:43.865Z" }, + { url = "https://files.pythonhosted.org/packages/f0/16/1a6bf01fb622fb9cf5c91683823f073f053005c849b1f52ed613afcf8dae/pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae", size = 6631840, upload-time = "2025-07-01T09:13:46.161Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e6/6ff7077077eb47fde78739e7d570bdcd7c10495666b6afcd23ab56b19a43/pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9", size = 6074005, upload-time = "2025-07-01T09:13:47.829Z" }, + { url = "https://files.pythonhosted.org/packages/c3/3a/b13f36832ea6d279a697231658199e0a03cd87ef12048016bdcc84131601/pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e", size = 6708372, upload-time = "2025-07-01T09:13:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e4/61b2e1a7528740efbc70b3d581f33937e38e98ef3d50b05007267a55bcb2/pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6", size = 6277090, upload-time = "2025-07-01T09:13:53.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d3/60c781c83a785d6afbd6a326ed4d759d141de43aa7365725cbcd65ce5e54/pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f", size = 6985988, upload-time = "2025-07-01T09:13:55.699Z" }, + { url = "https://files.pythonhosted.org/packages/9f/28/4f4a0203165eefb3763939c6789ba31013a2e90adffb456610f30f613850/pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f", size = 2422899, upload-time = "2025-07-01T09:13:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" }, + { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" }, + { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" }, + { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" }, + { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" }, + { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" }, + { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" }, + { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" }, + { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" }, + { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8b/209bd6b62ce8367f47e68a218bffac88888fdf2c9fcf1ecadc6c3ec1ebc7/pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967", size = 5270556, upload-time = "2025-07-01T09:16:09.961Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e6/231a0b76070c2cfd9e260a7a5b504fb72da0a95279410fa7afd99d9751d6/pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe", size = 4654625, upload-time = "2025-07-01T09:16:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/13/f4/10cf94fda33cb12765f2397fc285fa6d8eb9c29de7f3185165b702fc7386/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c", size = 4874207, upload-time = "2025-07-03T13:11:10.201Z" }, + { url = "https://files.pythonhosted.org/packages/72/c9/583821097dc691880c92892e8e2d41fe0a5a3d6021f4963371d2f6d57250/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25", size = 6583939, upload-time = "2025-07-03T13:11:15.68Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8e/5c9d410f9217b12320efc7c413e72693f48468979a013ad17fd690397b9a/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27", size = 4957166, upload-time = "2025-07-01T09:16:13.74Z" }, + { url = "https://files.pythonhosted.org/packages/62/bb/78347dbe13219991877ffb3a91bf09da8317fbfcd4b5f9140aeae020ad71/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a", size = 5581482, upload-time = "2025-07-01T09:16:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/d9/28/1000353d5e61498aaeaaf7f1e4b49ddb05f2c6575f9d4f9f914a3538b6e1/pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f", size = 6984596, upload-time = "2025-07-01T09:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" }, + { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" }, + { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" }, + { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pycryptodomex" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" }, + { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" }, + { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b8/3e76d948c3c4ac71335bbe75dac53e154b40b0f8f1f022dfa295257a0c96/pycryptodomex-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ebfff755c360d674306e5891c564a274a47953562b42fb74a5c25b8fc1fb1cb5", size = 1627695, upload-time = "2025-05-17T17:23:17.38Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/80f4297a4820dfdfd1c88cf6c4666a200f204b3488103d027b5edd9176ec/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eca54f4bb349d45afc17e3011ed4264ef1cc9e266699874cdd1349c504e64798", size = 1675772, upload-time = "2025-05-17T17:23:19.202Z" }, + { url = "https://files.pythonhosted.org/packages/d1/42/1e969ee0ad19fe3134b0e1b856c39bd0b70d47a4d0e81c2a8b05727394c9/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2596e643d4365e14d0879dc5aafe6355616c61c2176009270f3048f6d9a61f", size = 1668083, upload-time = "2025-05-17T17:23:21.867Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c3/1de4f7631fea8a992a44ba632aa40e0008764c0fb9bf2854b0acf78c2cf2/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fdfac7cda115bca3a5abb2f9e43bc2fb66c2b65ab074913643803ca7083a79ea", size = 1706056, upload-time = "2025-05-17T17:23:24.031Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5f/af7da8e6f1e42b52f44a24d08b8e4c726207434e2593732d39e7af5e7256/pycryptodomex-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:14c37aaece158d0ace436f76a7bb19093db3b4deade9797abfc39ec6cd6cc2fe", size = 1806478, upload-time = "2025-05-17T17:23:26.066Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" }, + { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" }, + { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" }, + { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" }, + { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, + { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" }, + { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, +] + +[[package]] +name = "pytest-cov" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload-time = "2025-06-12T10:47:47.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, +] + +[[package]] +name = "redis" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/9a/0551e01ba52b944f97480721656578c8a7c46b51b99d66814f85fe3a4f3e/redis-6.2.0.tar.gz", hash = "sha256:e821f129b75dde6cb99dd35e5c76e8c49512a5a0d8dfdc560b2fbd44b85ca977", size = 4639129, upload-time = "2025-05-28T05:01:18.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/67/e60968d3b0e077495a8fee89cf3f2373db98e528288a48f1ee44967f6e8c/redis-6.2.0-py3-none-any.whl", hash = "sha256:c8ddf316ee0aab65f04a11229e94a64b2618451dab7a67cb2f77eb799d872d5e", size = 278659, upload-time = "2025-05-28T05:01:16.955Z" }, +] + +[package.optional-dependencies] +hiredis = [ + { name = "hiredis" }, +] + +[[package]] +name = "respx" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, +] + +[[package]] +name = "ruff" +version = "0.12.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/ce/8d7dbedede481245b489b769d27e2934730791a9a82765cb94566c6e6abd/ruff-0.12.4.tar.gz", hash = "sha256:13efa16df6c6eeb7d0f091abae50f58e9522f3843edb40d56ad52a5a4a4b6873", size = 5131435, upload-time = "2025-07-17T17:27:19.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/9f/517bc5f61bad205b7f36684ffa5415c013862dee02f55f38a217bdbe7aa4/ruff-0.12.4-py3-none-linux_armv6l.whl", hash = "sha256:cb0d261dac457ab939aeb247e804125a5d521b21adf27e721895b0d3f83a0d0a", size = 10188824, upload-time = "2025-07-17T17:26:31.412Z" }, + { url = "https://files.pythonhosted.org/packages/28/83/691baae5a11fbbde91df01c565c650fd17b0eabed259e8b7563de17c6529/ruff-0.12.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:55c0f4ca9769408d9b9bac530c30d3e66490bd2beb2d3dae3e4128a1f05c7442", size = 10884521, upload-time = "2025-07-17T17:26:35.084Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8d/756d780ff4076e6dd035d058fa220345f8c458391f7edfb1c10731eedc75/ruff-0.12.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a8224cc3722c9ad9044da7f89c4c1ec452aef2cfe3904365025dd2f51daeae0e", size = 10277653, upload-time = "2025-07-17T17:26:37.897Z" }, + { url = "https://files.pythonhosted.org/packages/8d/97/8eeee0f48ece153206dce730fc9e0e0ca54fd7f261bb3d99c0a4343a1892/ruff-0.12.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9949d01d64fa3672449a51ddb5d7548b33e130240ad418884ee6efa7a229586", size = 10485993, upload-time = "2025-07-17T17:26:40.68Z" }, + { url = "https://files.pythonhosted.org/packages/49/b8/22a43d23a1f68df9b88f952616c8508ea6ce4ed4f15353b8168c48b2d7e7/ruff-0.12.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:be0593c69df9ad1465e8a2d10e3defd111fdb62dcd5be23ae2c06da77e8fcffb", size = 10022824, upload-time = "2025-07-17T17:26:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/cd/70/37c234c220366993e8cffcbd6cadbf332bfc848cbd6f45b02bade17e0149/ruff-0.12.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7dea966bcb55d4ecc4cc3270bccb6f87a337326c9dcd3c07d5b97000dbff41c", size = 11524414, upload-time = "2025-07-17T17:26:46.219Z" }, + { url = "https://files.pythonhosted.org/packages/14/77/c30f9964f481b5e0e29dd6a1fae1f769ac3fd468eb76fdd5661936edd262/ruff-0.12.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:afcfa3ab5ab5dd0e1c39bf286d829e042a15e966b3726eea79528e2e24d8371a", size = 12419216, upload-time = "2025-07-17T17:26:48.883Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/af7fe0a4202dce4ef62c5e33fecbed07f0178f5b4dd9c0d2fcff5ab4a47c/ruff-0.12.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c057ce464b1413c926cdb203a0f858cd52f3e73dcb3270a3318d1630f6395bb3", size = 11976756, upload-time = "2025-07-17T17:26:51.754Z" }, + { url = "https://files.pythonhosted.org/packages/09/d1/33fb1fc00e20a939c305dbe2f80df7c28ba9193f7a85470b982815a2dc6a/ruff-0.12.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64b90d1122dc2713330350626b10d60818930819623abbb56535c6466cce045", size = 11020019, upload-time = "2025-07-17T17:26:54.265Z" }, + { url = "https://files.pythonhosted.org/packages/64/f4/e3cd7f7bda646526f09693e2e02bd83d85fff8a8222c52cf9681c0d30843/ruff-0.12.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2abc48f3d9667fdc74022380b5c745873499ff827393a636f7a59da1515e7c57", size = 11277890, upload-time = "2025-07-17T17:26:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d0/69a85fb8b94501ff1a4f95b7591505e8983f38823da6941eb5b6badb1e3a/ruff-0.12.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2b2449dc0c138d877d629bea151bee8c0ae3b8e9c43f5fcaafcd0c0d0726b184", size = 10348539, upload-time = "2025-07-17T17:26:59.381Z" }, + { url = "https://files.pythonhosted.org/packages/16/a0/91372d1cb1678f7d42d4893b88c252b01ff1dffcad09ae0c51aa2542275f/ruff-0.12.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:56e45bb11f625db55f9b70477062e6a1a04d53628eda7784dce6e0f55fd549eb", size = 10009579, upload-time = "2025-07-17T17:27:02.462Z" }, + { url = "https://files.pythonhosted.org/packages/23/1b/c4a833e3114d2cc0f677e58f1df6c3b20f62328dbfa710b87a1636a5e8eb/ruff-0.12.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:478fccdb82ca148a98a9ff43658944f7ab5ec41c3c49d77cd99d44da019371a1", size = 10942982, upload-time = "2025-07-17T17:27:05.343Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ce/ce85e445cf0a5dd8842f2f0c6f0018eedb164a92bdf3eda51984ffd4d989/ruff-0.12.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0fc426bec2e4e5f4c4f182b9d2ce6a75c85ba9bcdbe5c6f2a74fcb8df437df4b", size = 11343331, upload-time = "2025-07-17T17:27:08.652Z" }, + { url = "https://files.pythonhosted.org/packages/35/cf/441b7fc58368455233cfb5b77206c849b6dfb48b23de532adcc2e50ccc06/ruff-0.12.4-py3-none-win32.whl", hash = "sha256:4de27977827893cdfb1211d42d84bc180fceb7b72471104671c59be37041cf93", size = 10267904, upload-time = "2025-07-17T17:27:11.814Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7e/20af4a0df5e1299e7368d5ea4350412226afb03d95507faae94c80f00afd/ruff-0.12.4-py3-none-win_amd64.whl", hash = "sha256:fe0b9e9eb23736b453143d72d2ceca5db323963330d5b7859d60d101147d461a", size = 11209038, upload-time = "2025-07-17T17:27:14.417Z" }, + { url = "https://files.pythonhosted.org/packages/11/02/8857d0dfb8f44ef299a5dfd898f673edefb71e3b533b3b9d2db4c832dd13/ruff-0.12.4-py3-none-win_arm64.whl", hash = "sha256:0618ec4442a83ab545e5b71202a5c0ed7791e8471435b94e655b570a5031a98e", size = 10469336, upload-time = "2025-07-17T17:27:16.913Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424, upload-time = "2025-05-14T17:10:32.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/12/d7c445b1940276a828efce7331cb0cb09d6e5f049651db22f4ebb0922b77/sqlalchemy-2.0.41-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1f09b6821406ea1f94053f346f28f8215e293344209129a9c0fcc3578598d7b", size = 2117967, upload-time = "2025-05-14T17:48:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b8/cb90f23157e28946b27eb01ef401af80a1fab7553762e87df51507eaed61/sqlalchemy-2.0.41-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1936af879e3db023601196a1684d28e12f19ccf93af01bf3280a3262c4b6b4e5", size = 2107583, upload-time = "2025-05-14T17:48:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/eef84283a1c8164a207d898e063edf193d36a24fb6a5bb3ce0634b92a1e8/sqlalchemy-2.0.41-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2ac41acfc8d965fb0c464eb8f44995770239668956dc4cdf502d1b1ffe0d747", size = 3186025, upload-time = "2025-05-14T17:51:51.226Z" }, + { url = "https://files.pythonhosted.org/packages/bd/72/49d52bd3c5e63a1d458fd6d289a1523a8015adedbddf2c07408ff556e772/sqlalchemy-2.0.41-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81c24e0c0fde47a9723c81d5806569cddef103aebbf79dbc9fcbb617153dea30", size = 3186259, upload-time = "2025-05-14T17:55:22.526Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9e/e3ffc37d29a3679a50b6bbbba94b115f90e565a2b4545abb17924b94c52d/sqlalchemy-2.0.41-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23a8825495d8b195c4aa9ff1c430c28f2c821e8c5e2d98089228af887e5d7e29", size = 3126803, upload-time = "2025-05-14T17:51:53.277Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/56b21e363f6039978ae0b72690237b38383e4657281285a09456f313dd77/sqlalchemy-2.0.41-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:60c578c45c949f909a4026b7807044e7e564adf793537fc762b2489d522f3d11", size = 3148566, upload-time = "2025-05-14T17:55:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/11b8e1b69bf191bc69e300a99badbbb5f2f1102f2b08b39d9eee2e21f565/sqlalchemy-2.0.41-cp310-cp310-win32.whl", hash = "sha256:118c16cd3f1b00c76d69343e38602006c9cfb9998fa4f798606d28d63f23beda", size = 2086696, upload-time = "2025-05-14T17:55:59.136Z" }, + { url = "https://files.pythonhosted.org/packages/5c/88/2d706c9cc4502654860f4576cd54f7db70487b66c3b619ba98e0be1a4642/sqlalchemy-2.0.41-cp310-cp310-win_amd64.whl", hash = "sha256:7492967c3386df69f80cf67efd665c0f667cee67032090fe01d7d74b0e19bb08", size = 2110200, upload-time = "2025-05-14T17:56:00.757Z" }, + { url = "https://files.pythonhosted.org/packages/37/4e/b00e3ffae32b74b5180e15d2ab4040531ee1bef4c19755fe7926622dc958/sqlalchemy-2.0.41-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6375cd674fe82d7aa9816d1cb96ec592bac1726c11e0cafbf40eeee9a4516b5f", size = 2121232, upload-time = "2025-05-14T17:48:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/ef/30/6547ebb10875302074a37e1970a5dce7985240665778cfdee2323709f749/sqlalchemy-2.0.41-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f8c9fdd15a55d9465e590a402f42082705d66b05afc3ffd2d2eb3c6ba919560", size = 2110897, upload-time = "2025-05-14T17:48:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/9e/21/59df2b41b0f6c62da55cd64798232d7349a9378befa7f1bb18cf1dfd510a/sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32f9dc8c44acdee06c8fc6440db9eae8b4af8b01e4b1aee7bdd7241c22edff4f", size = 3273313, upload-time = "2025-05-14T17:51:56.205Z" }, + { url = "https://files.pythonhosted.org/packages/62/e4/b9a7a0e5c6f79d49bcd6efb6e90d7536dc604dab64582a9dec220dab54b6/sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c11ceb9a1f482c752a71f203a81858625d8df5746d787a4786bca4ffdf71c6", size = 3273807, upload-time = "2025-05-14T17:55:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/d8/79f2427251b44ddee18676c04eab038d043cff0e764d2d8bb08261d6135d/sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:911cc493ebd60de5f285bcae0491a60b4f2a9f0f5c270edd1c4dbaef7a38fc04", size = 3209632, upload-time = "2025-05-14T17:51:59.384Z" }, + { url = "https://files.pythonhosted.org/packages/d4/16/730a82dda30765f63e0454918c982fb7193f6b398b31d63c7c3bd3652ae5/sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03968a349db483936c249f4d9cd14ff2c296adfa1290b660ba6516f973139582", size = 3233642, upload-time = "2025-05-14T17:55:29.901Z" }, + { url = "https://files.pythonhosted.org/packages/04/61/c0d4607f7799efa8b8ea3c49b4621e861c8f5c41fd4b5b636c534fcb7d73/sqlalchemy-2.0.41-cp311-cp311-win32.whl", hash = "sha256:293cd444d82b18da48c9f71cd7005844dbbd06ca19be1ccf6779154439eec0b8", size = 2086475, upload-time = "2025-05-14T17:56:02.095Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8e/8344f8ae1cb6a479d0741c02cd4f666925b2bf02e2468ddaf5ce44111f30/sqlalchemy-2.0.41-cp311-cp311-win_amd64.whl", hash = "sha256:3d3549fc3e40667ec7199033a4e40a2f669898a00a7b18a931d3efb4c7900504", size = 2110903, upload-time = "2025-05-14T17:56:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2a/f1f4e068b371154740dd10fb81afb5240d5af4aa0087b88d8b308b5429c2/sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9", size = 2119645, upload-time = "2025-05-14T17:55:24.854Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/c664a7e73d36fbfc4730f8cf2bf930444ea87270f2825efbe17bf808b998/sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1", size = 2107399, upload-time = "2025-05-14T17:55:28.097Z" }, + { url = "https://files.pythonhosted.org/packages/5c/78/8a9cf6c5e7135540cb682128d091d6afa1b9e48bd049b0d691bf54114f70/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70", size = 3293269, upload-time = "2025-05-14T17:50:38.227Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/f74add3978c20de6323fb11cb5162702670cc7a9420033befb43d8d5b7a4/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e", size = 3303364, upload-time = "2025-05-14T17:51:49.829Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d4/c990f37f52c3f7748ebe98883e2a0f7d038108c2c5a82468d1ff3eec50b7/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078", size = 3229072, upload-time = "2025-05-14T17:50:39.774Z" }, + { url = "https://files.pythonhosted.org/packages/15/69/cab11fecc7eb64bc561011be2bd03d065b762d87add52a4ca0aca2e12904/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae", size = 3268074, upload-time = "2025-05-14T17:51:51.736Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/0c19ec16858585d37767b167fc9602593f98998a68a798450558239fb04a/sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6", size = 2084514, upload-time = "2025-05-14T17:55:49.915Z" }, + { url = "https://files.pythonhosted.org/packages/7f/23/4c2833d78ff3010a4e17f984c734f52b531a8c9060a50429c9d4b0211be6/sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0", size = 2111557, upload-time = "2025-05-14T17:55:51.349Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224, upload-time = "2025-05-14T17:39:42.154Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + +[[package]] +name = "starlette" +version = "0.47.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/b9/cc3017f9a9c9b6e27c5106cc10cc7904653c3eec0729793aec10479dd669/starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9", size = 2584144, upload-time = "2025-08-24T13:36:42.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/5a/2bf22ecb24916983bf1cc0095e7dea2741d14d6553b0d6a2ac8bc96eca93/watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9", size = 400471, upload-time = "2026-05-18T04:31:08.908Z" }, + { url = "https://files.pythonhosted.org/packages/55/70/dea1f6a0e76607841a60fb51af150e70124864673f61704abb62b90cdcc7/watchfiles-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4", size = 394599, upload-time = "2026-05-18T04:30:19.845Z" }, + { url = "https://files.pythonhosted.org/packages/18/52/752dcc7dc817baef5e89518732925795ce52e36a683a9a3c9fb68b21504e/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631", size = 455458, upload-time = "2026-05-18T04:30:29.126Z" }, + { url = "https://files.pythonhosted.org/packages/12/48/366ebbb22fcc504c2f72b45f0b7e72f40a18795cc01752c16066d597b67a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994", size = 460513, upload-time = "2026-05-18T04:31:40.85Z" }, + { url = "https://files.pythonhosted.org/packages/ad/44/1f9e1b15e7a729062e0d0c3d0d7225ea4ab98b2267ef87287153be2495fc/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e", size = 493616, upload-time = "2026-05-18T04:30:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/7e/55/8b1086dcc8a1d6a697a62767bd7ea368e74c61c6fd171683cfe24a3fe5d2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19", size = 573154, upload-time = "2026-05-18T04:30:37.903Z" }, + { url = "https://files.pythonhosted.org/packages/14/7a/242f400cc77fafa7b18d53d19d9cb64fc6a6f61f28c55913bae7c674d92a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8", size = 467046, upload-time = "2026-05-18T04:30:41.869Z" }, + { url = "https://files.pythonhosted.org/packages/02/c8/79eee650c62d2c186598489814468e389b5def0ebe755399ff645b35b1b2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07", size = 457100, upload-time = "2026-05-18T04:31:13.064Z" }, + { url = "https://files.pythonhosted.org/packages/81/36/519f6dbb7a95e4fe7c1513ed25b1520295ef9905a27f1f2226a73892bfb7/watchfiles-1.2.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551", size = 467038, upload-time = "2026-05-18T04:30:32.915Z" }, + { url = "https://files.pythonhosted.org/packages/2f/12/951af6b9f89097e02511122258402cb3578443021930b70cf968d6310dc0/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310", size = 632563, upload-time = "2026-05-18T04:30:11.539Z" }, + { url = "https://files.pythonhosted.org/packages/28/cc/0cba1f0a6117b7ec117271bdc3cb3a5a252005959755a2c09a745e0942cc/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df", size = 660851, upload-time = "2026-05-18T04:31:53.186Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/26347558cc8bf6877845e66b315f644d03c173906aa09e233a3f4fd23928/watchfiles-1.2.0-cp310-cp310-win32.whl", hash = "sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1", size = 277023, upload-time = "2026-05-18T04:30:18.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/68/a5e67b6b68e94f4c1511d61c46c55eba0737583620b6febf194c7b9cc23f/watchfiles-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d", size = 290107, upload-time = "2026-05-18T04:32:09.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "xiaozhi-manager-api-fastapi" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "aiosqlite" }, + { name = "asyncmy" }, + { name = "bcrypt" }, + { name = "cryptography" }, + { name = "fastapi" }, + { name = "gmssl" }, + { name = "httpx" }, + { name = "pillow" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "redis", extra = ["hiredis"] }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "uvicorn", extra = ["standard"] }, + { name = "websockets" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "respx" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiosqlite", specifier = "==0.21.0" }, + { name = "asyncmy", specifier = "==0.2.10" }, + { name = "bcrypt", specifier = "==4.3.0" }, + { name = "cryptography", specifier = "==45.0.5" }, + { name = "fastapi", specifier = "==0.116.1" }, + { name = "gmssl", specifier = "==3.2.2" }, + { name = "httpx", specifier = "==0.28.1" }, + { name = "pillow", specifier = "==11.3.0" }, + { name = "pydantic", specifier = "==2.11.7" }, + { name = "pydantic-settings", specifier = "==2.10.1" }, + { name = "python-multipart", specifier = "==0.0.20" }, + { name = "pyyaml", specifier = "==6.0.2" }, + { name = "redis", extras = ["hiredis"], specifier = "==6.2.0" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = "==2.0.41" }, + { name = "uvicorn", extras = ["standard"], specifier = "==0.35.0" }, + { name = "websockets", specifier = "==15.0.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = "==1.17.0" }, + { name = "pytest", specifier = "==8.4.1" }, + { name = "pytest-asyncio", specifier = "==1.1.0" }, + { name = "pytest-cov", specifier = "==6.2.1" }, + { name = "respx", specifier = "==0.22.0" }, + { name = "ruff", specifier = "==0.12.4" }, +] diff --git a/scripts/restart-local-services.sh b/scripts/restart-local-services.sh new file mode 100755 index 00000000..98c09b26 --- /dev/null +++ b/scripts/restart-local-services.sh @@ -0,0 +1,193 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +REPOSITORY_ROOT=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd) +RUNTIME_ROOT="${REPOSITORY_ROOT}/.runtime" +LOG_DIR="${RUNTIME_ROOT}/logs" +FASTAPI_DIR="${REPOSITORY_ROOT}/main/manager-api-fastapi" +JAVA_API_DIR="${REPOSITORY_ROOT}/main/manager-api" +WEB_DIR="${REPOSITORY_ROOT}/main/manager-web" +SERVER_DIR="${REPOSITORY_ROOT}/main/xiaozhi-server" + +MANAGER_API=fastapi +WAIT_SECONDS=0 +CHECK_ONLY=false + +usage() { + cat <<'EOF' +Usage: scripts/restart-local-services.sh [options] + +Restart the local manager API, manager web UI, and xiaozhi-server in named screen sessions. +The FastAPI manager is the default; the retained Java implementation remains selectable. + +Options: + --manager-api fastapi|java Select the manager API implementation (default: fastapi) + --wait SECONDS Wait for all HTTP readiness probes + --check Validate local prerequisites without starting or stopping anything + -h, --help Show this help +EOF +} + +fail() { + echo "error: $*" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "required command is missing: $1" +} + +require_executable() { + [ -x "$1" ] || fail "required executable is missing: $1" +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --manager-api) + [ "$#" -ge 2 ] || fail "--manager-api requires fastapi or java" + MANAGER_API=$2 + shift 2 + ;; + --wait) + [ "$#" -ge 2 ] || fail "--wait requires a non-negative number of seconds" + WAIT_SECONDS=$2 + shift 2 + ;; + --check) + CHECK_ONLY=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unknown option: $1" + ;; + esac +done + +case "${MANAGER_API}" in + fastapi|java) ;; + *) fail "--manager-api must be fastapi or java" ;; +esac +case "${WAIT_SECONDS}" in + ''|*[!0-9]*) fail "--wait must be a non-negative integer" ;; +esac + +require_command screen +require_command curl +require_command npm +require_executable "${WEB_DIR}/node_modules/.bin/vue-cli-service" + +if [ -n "${XIAOZHI_PYTHON_BIN:-}" ]; then + XIAOZHI_PYTHON=${XIAOZHI_PYTHON_BIN} +elif [ -x "${RUNTIME_ROOT}/py310/bin/python" ]; then + XIAOZHI_PYTHON=${RUNTIME_ROOT}/py310/bin/python +else + XIAOZHI_PYTHON=${REPOSITORY_ROOT}/.venv-xiaozhi/bin/python +fi +XIAOZHI_BIN_DIR=$(dirname "${XIAOZHI_PYTHON}") +require_executable "${XIAOZHI_PYTHON}" + +if [ "${MANAGER_API}" = fastapi ]; then + require_executable "${FASTAPI_DIR}/.venv/bin/python" + require_executable "${FASTAPI_DIR}/scripts/start-api.sh" + require_executable "${FASTAPI_DIR}/scripts/start-jobs.sh" +else + require_executable "${RUNTIME_ROOT}/jdk/bin/java" + require_executable "${RUNTIME_ROOT}/maven/bin/mvn" +fi + +if [ "${CHECK_ONLY}" = true ]; then + echo "Local service prerequisites are available (manager-api=${MANAGER_API})." + exit 0 +fi + +mkdir -p "${LOG_DIR}" + +stop_session() { + session_name=$1 + session_ids=$(screen -list 2>/dev/null | awk -v wanted="${session_name}" ' + $1 ~ /^[0-9]+\./ { + name = $1 + sub(/^[0-9]+\./, "", name) + if (name == wanted) print $1 + } + ') + for session_id in ${session_ids}; do + # Let foreground children (notably npm -> vue-cli-service) terminate before + # removing the pseudo-terminal, otherwise they can survive as orphaned + # listeners and make a later service bind to the wrong port. + screen -S "${session_id}" -p 0 -X stuff "$(printf '\003')" >/dev/null 2>&1 || true + remaining=10 + while [ "${remaining}" -gt 0 ] && \ + screen -list 2>/dev/null | awk '{print $1}' | grep -Fqx "${session_id}"; do + sleep 1 + remaining=$((remaining - 1)) + done + screen -S "${session_id}" -X quit >/dev/null 2>&1 || true + done +} + +stop_session xiaozhi-manager-api +stop_session xiaozhi-manager-api-jobs +stop_session xiaozhi-manager-web +stop_session xiaozhi-server + +if [ "${MANAGER_API}" = fastapi ]; then + screen -dmS xiaozhi-manager-api sh -c \ + 'cd "$1" && exec "$2" >>"$3" 2>&1' \ + sh "${FASTAPI_DIR}" "${FASTAPI_DIR}/scripts/start-api.sh" "${LOG_DIR}/manager-api-fastapi.log" + screen -dmS xiaozhi-manager-api-jobs sh -c \ + 'cd "$1" && exec "$2" >>"$3" 2>&1' \ + sh "${FASTAPI_DIR}" "${FASTAPI_DIR}/scripts/start-jobs.sh" "${LOG_DIR}/manager-api-jobs.log" + MANAGER_HEALTH=http://127.0.0.1:8002/xiaozhi/health/ready +else + screen -dmS xiaozhi-manager-api sh -c \ + 'cd "$1" && exec env JAVA_HOME="$2" PATH="$2/bin:$3:$PATH" "$3/mvn" "-Dmaven.repo.local=$4" spring-boot:run >>"$5" 2>&1' \ + sh "${JAVA_API_DIR}" "${RUNTIME_ROOT}/jdk" "${RUNTIME_ROOT}/maven/bin" \ + "${RUNTIME_ROOT}/m2" "${LOG_DIR}/manager-api-java.log" + MANAGER_HEALTH=http://127.0.0.1:8002/xiaozhi/doc.html +fi + +screen -dmS xiaozhi-manager-web sh -c \ + 'cd "$1" && exec "$2" run serve >>"$3" 2>&1' \ + sh "${WEB_DIR}" "$(command -v npm)" "${LOG_DIR}/manager-web.log" +screen -dmS xiaozhi-server sh -c \ + 'cd "$1" && exec env PATH="$3:$PATH" "$2" app.py >>"$4" 2>&1' \ + sh "${SERVER_DIR}" "${XIAOZHI_PYTHON}" "${XIAOZHI_BIN_DIR}" \ + "${LOG_DIR}/xiaozhi-server.log" + +echo "Started screen sessions (manager-api=${MANAGER_API}):" +screen -list | grep -E 'xiaozhi-(manager-api|manager-api-jobs|manager-web|server)' || true + +if [ "${WAIT_SECONDS}" -eq 0 ]; then + exit 0 +fi + +DEADLINE=$(( $(date +%s) + WAIT_SECONDS )) +wait_for_url() { + name=$1 + url=$2 + expected=${3:-} + while [ "$(date +%s)" -le "${DEADLINE}" ]; do + body=$(curl --fail --silent --show-error --max-time 2 "${url}" 2>/dev/null || true) + if [ -n "${body}" ] && \ + { [ -z "${expected}" ] || printf '%s' "${body}" | grep -Fq "${expected}"; }; then + echo "ready: ${name} (${url})" + return 0 + fi + sleep 1 + done + echo "not ready before timeout: ${name} (${url})" >&2 + return 1 +} + +FAILED=0 +wait_for_url manager-api "${MANAGER_HEALTH}" || FAILED=1 +wait_for_url manager-web http://127.0.0.1:8001/ || FAILED=1 +wait_for_url xiaozhi-server http://127.0.0.1:8003/mcp/vision/explain \ + 'MCP Vision 接口运行正常' || FAILED=1 +[ "${FAILED}" -eq 0 ] || fail "one or more local services failed readiness; inspect ${LOG_DIR}"