mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 16:05:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae5eaa46b4 | ||
|
|
65d5b32eba | ||
|
|
6c158447be | ||
|
|
81d939bd03 | ||
|
|
4a22479dff | ||
|
|
3ee454b750 | ||
|
|
b5bd63b585 | ||
|
|
286031a441 | ||
|
|
5358de53dd | ||
|
|
d814582c09 | ||
|
|
a9ce01f91f | ||
|
|
0fc25bfce9 | ||
|
|
eed8c93ae2 | ||
|
|
d865a00768 | ||
|
|
a0671fb70b | ||
|
|
fce543d4ce | ||
|
|
219888d5c2 | ||
|
|
5b80340135 | ||
|
|
b2dab66a80 | ||
|
|
f0cf8e7198 | ||
|
|
a9a86071bf | ||
|
|
27cef052c9 | ||
|
|
ef40088303 | ||
|
|
05ba116eef | ||
|
|
3ef4743e1a | ||
|
|
9e02e52fec | ||
|
|
42ff5b74d0 | ||
|
|
eb2eefd51e | ||
|
|
abd60ce60a | ||
|
|
2f96c02ea0 | ||
|
|
0f575e279a | ||
|
|
634b8fc674 | ||
|
|
00bfb1699d | ||
|
|
956ca70a14 | ||
|
|
24dd7b94f4 | ||
|
|
94c769fc9b | ||
|
|
eea16f5275 | ||
|
|
8a406ff594 | ||
|
|
676044c43f | ||
|
|
e7161da277 | ||
|
|
97faca0c45 | ||
|
|
45b473c7e2 | ||
|
|
cc69368f77 | ||
|
|
67691d18d3 | ||
|
|
85c64d35d5 | ||
|
|
9bf86f7a7c | ||
|
|
6c56a23508 | ||
|
|
b5f3cb402c | ||
|
|
57e284d7d1 | ||
|
|
21597f9e98 | ||
|
|
fa0b87dd0a | ||
|
|
389dc63633 | ||
|
|
78b2250faa | ||
|
|
bbb8278281 | ||
|
|
7a5521953f | ||
|
|
f78b1ca487 | ||
|
|
661649cc90 | ||
|
|
705f0fad06 | ||
|
|
f0c60f501b | ||
|
|
d1febdd249 | ||
|
|
53d4086cc0 | ||
|
|
ce03e8119d | ||
|
|
3f05b6450c | ||
|
|
a4b73099b5 | ||
|
|
d875a62652 | ||
|
|
98704dfde8 | ||
|
|
b07bca312a | ||
|
|
60966d935e | ||
|
|
52981f40f9 | ||
|
|
a51c4cd640 | ||
|
|
bda6d973a8 | ||
|
|
66a7e4a695 | ||
|
|
9483f2c91b | ||
|
|
faf50c28ad | ||
|
|
cb397ae53e | ||
|
|
63802f9b1a | ||
|
|
de599ed768 | ||
|
|
3315ddbff6 | ||
|
|
6c583e341a | ||
|
|
fb15f3e36c | ||
|
|
a940eb599f | ||
|
|
22a848b360 | ||
|
|
754da83b02 | ||
|
|
f174320e2a | ||
|
|
9db8528758 | ||
|
|
119032c067 | ||
|
|
3d1dc79f5d | ||
|
|
128ced4093 | ||
|
|
693025afa4 | ||
|
|
0bfeff28fc | ||
|
|
cb7daffb66 | ||
|
|
d922c3af60 | ||
|
|
366bc5f1ca | ||
|
|
a5e4041295 | ||
|
|
b27877c99b | ||
|
|
7505459ece | ||
|
|
102d064ab0 | ||
|
|
7616fdf4ab | ||
|
|
5cfe0c68cc | ||
|
|
7940735866 | ||
|
|
7741503b03 | ||
|
|
74f749f49d | ||
|
|
c4d8732a73 | ||
|
|
0a47b5efc0 | ||
|
|
35a7fa5a73 | ||
|
|
0ce81eea1d | ||
|
|
22acbd1d10 | ||
|
|
78ba7e412e | ||
|
|
f8439eb4b9 | ||
|
|
55062228e4 | ||
|
|
f7585ede0c | ||
|
|
94ae5f693f | ||
|
|
4961788acf | ||
|
|
24f39f7f0e | ||
|
|
9c5fbdba70 | ||
|
|
28a57cbde5 | ||
|
|
1c8a74e572 | ||
|
|
7c266b469b | ||
|
|
d37dc32731 | ||
|
|
9a749d1a03 | ||
|
|
fef52b3717 | ||
|
|
45e72682e6 | ||
|
|
90e241f74b | ||
|
|
a0e8f768dd | ||
|
|
318f8a748e | ||
|
|
0dcba14572 | ||
|
|
ab3a2e30c5 | ||
|
|
0490e28cfc |
@@ -19,7 +19,7 @@ description: Codex Proxy RS 仓库的开发、修复、排障、代码审查和
|
||||
| 开发、排障、代码审查 | [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),需要定位代码时按 AGENTS 的 CodeGraph 约定执行 |
|
||||
| 模块或调用链变更 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)及所属模块章节,不通读整份架构 |
|
||||
| Rust 开发或审查 | `$rust-best-practices`、所属模块、[后端自审](../../../docs/architecture.md#后端自审),验证时读[命令](../../../docs/architecture.md#验证命令) |
|
||||
| 页面开发 | `$frontend-design`、[文案与信息层级](../../../docs/theme.md#界面文案与信息层级)、[界面验证](../../../CONTRIBUTING.md#界面验证);修改已有页面前留存截图,主题算法仅在涉及时读取 |
|
||||
| 页面开发 | `$frontend-design`、[前端职责](../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../docs/theme.md#界面文案与信息层级)、[界面验证](../../../CONTRIBUTING.md#界面验证);修改已有页面前留存截图,主题算法仅在涉及时读取 |
|
||||
| 接口、部署或迁移 | 分别定位 [API](../../../docs/api.md)、[部署](../../../deploy/README.md)、[迁移](../../../backend/migrations/README.md)的对应章节 |
|
||||
| SDK、Runtime、宿主扩展或独立插件 | [插件职责边界](references/plugin-boundaries.md);只有创作、排查独立网关插件时使用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) |
|
||||
| 修改或审查文档 | [文档检查](references/documentation.md)及目标章节,不默认读取代码开发、页面验收或发布流程 |
|
||||
@@ -40,6 +40,12 @@ description: Codex Proxy RS 仓库的开发、修复、排障、代码审查和
|
||||
3. 按变更范围读取[验证](../../../CONTRIBUTING.md#验证),区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
|
||||
4. 交付说明实际结果与验证缺口;页面证据和后端生命周期检查按上表执行,不把构建通过当作行为验收
|
||||
|
||||
## 后端注释
|
||||
|
||||
- 每个 Rust 文件顶层使用简短的中文 `//!` 注释说明文件职责,覆盖生产源码、测试与构建脚本;测试文件说明测试范围或辅助用途,生成文件同步维护生成器
|
||||
- 注释不使用中文或英文句号,多句说明按语义分行;保留 URL、版本号、标识符和代码示例中有语义的点号
|
||||
- 文件说明聚焦当前功能与职责边界,局部注释解释原因和约束,避免复述代码或记录修改经过
|
||||
|
||||
## 文档硬约束
|
||||
|
||||
**常规文档只描述当前状态,不写变更历史**,这项检查适用于每次开发收尾,不限于专门的文档任务
|
||||
|
||||
@@ -13,7 +13,7 @@ description: 开发、排查或打包 Codex Proxy RS 独立网关插件;仅改
|
||||
|
||||
- 确认用途、工程位置、目标宿主版本与平台,沿用已有工程;位置无法推断时只补齐该信息,不把插件源码塞进宿主 workspace
|
||||
- 核对运行宿主、[SDK 版本](../../../backend/crates/gateway-plugin/sdk/Cargo.toml)、[宿主支持清单](../../../backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json)及所用 CLI,清单、进程和能力版本分别判断
|
||||
- `contributes` 是能力,`permissions` 是访问域,`bindings` 是挂载与匹配范围;安装成功不等于功能生效,`trustedProcess` 也不是操作系统沙箱
|
||||
- `contributes` 声明处理器,`bindings` 选择挂载与匹配范围;安装成功不等于功能生效。当前清单拒绝 `permissions`,安装者完整信任插件,`trustedProcess` 不提供操作系统沙箱或访问域隔离,见[完整信任](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#完整信任)
|
||||
- 涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考;宿主能力以目标版本的 SDK 为准
|
||||
|
||||
## 按当前步骤读取
|
||||
@@ -33,7 +33,7 @@ description: 开发、排查或打包 Codex Proxy RS 独立网关插件;仅改
|
||||
|
||||
## 交付
|
||||
|
||||
说明源码与产物位置、兼容范围、能力与权限、实际验证及缺口,只报告真正生成的安装包或完成的业务验证
|
||||
说明源码与产物位置、兼容范围、能力与绑定、实际验证及缺口,只报告真正生成的安装包或完成的业务验证
|
||||
|
||||
修改源码不默认授权在目标实例安装启用、调用真实上游或发布,沿用用户当前授权判断后续动作
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
| [codex-proxy-ui](https://github.com/zyycn/codex-proxy-ui) | 可选的 Vue UI 库、组件文档与 playground |
|
||||
|
||||
先检查用户给定工程、`modules/plugins` 子模块或同级仓库是否存在,读取实际仓库约定及当前步骤所需的 README 章节、依赖与锁文件,不预读无关前端配置
|
||||
只需中间件时不复制示例的管理 API、页面和相关权限;CLI 等能力从 SDK 对应合同起步,不给示例添加无关能力。
|
||||
只需中间件时不复制示例的管理 API 和页面;CLI 等能力从 SDK 对应合同起步,不给示例添加无关能力
|
||||
|
||||
本仓库子模块联调使用[开发文档](../../../../docs/development.md#源码联调)中的 source 模式和本地 SDK 验证入口,不改写正式依赖或锁文件
|
||||
其他工程按其自身联调配置临时使用包级链接或 override,路径须真实可解析,不从业务源码跨仓 `import .../src/...`
|
||||
@@ -37,7 +37,7 @@
|
||||
| 包内静态资源构建 | `examples/workbench/frontend/vite.config.ts` |
|
||||
|
||||
若派生新插件,同时修改包名、二进制名、插件身份、扩展项 ID、注册描述和构建目标,不只改展示名称。
|
||||
保留当前项目确实需要的依赖、资源和权限;不要把示例中的全请求范围直接用到共享环境。
|
||||
保留当前项目确实需要的依赖、资源和能力声明;不要把示例中的全请求范围直接用到共享环境
|
||||
|
||||
## 构建与打包
|
||||
|
||||
@@ -58,7 +58,7 @@ cargo build --manifest-path /path/to/plugin/Cargo.toml --release --locked --targ
|
||||
需要从目标宿主源码构建工具时,在主仓根目录运行以下命令,把 `--help` 换成实际参数即可,不必全局安装:
|
||||
|
||||
```bash
|
||||
cargo run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help
|
||||
cargo +1.97.0 run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help
|
||||
```
|
||||
|
||||
直接打包的示意命令:
|
||||
@@ -92,7 +92,7 @@ cpr-plugin package \
|
||||
|
||||
## 验证与交付
|
||||
|
||||
按[插件使用](../../../../docs/plugins.md)验证“安装包 → 查看权限并安装 → 自动准备默认配置 → 实际使用”;只在缺少业务必填值时补配置,不添加授权或资源选择向导。
|
||||
按[插件使用](../../../../docs/plugins.md)验证“校验安装包 → 确认来源并接受完整信任 → 自动准备默认配置 → 实际使用”;只在缺少业务必填值时补配置,不添加权限或资源选择向导
|
||||
只有获得目标环境操作授权才安装和启用;不把修改源码的授权扩展到生产实例或真实凭据操作。
|
||||
需要 API 自动化时查[插件管理 API](../../../../docs/api.md#12-插件管理),不要依据记忆拼路由。
|
||||
|
||||
@@ -100,7 +100,8 @@ cpr-plugin package \
|
||||
|
||||
| 实现范围 | 应验证的行为 |
|
||||
| --- | --- |
|
||||
| 中间件 | 匹配/不匹配范围、授权不足、下游错误与取消;支持流式时检查多帧、终态和断开;若涉及续链,使用真实目标客户端检查 HTTP/SSE、WS 及后续轮次 |
|
||||
| 中间件 | 匹配/不匹配范围、单次 `next`、未改写字节保留、下游错误与取消;支持流式时检查多帧、终态和断开;若涉及续链,使用真实目标客户端检查 HTTP/SSE、WS 及后续轮次 |
|
||||
| 上游适配器 | 绑定匹配、账号认证与代理、HTTP/SSE 或 WS 事件解析、发送状态、用量和终态;支持续接时核对 Key、账号、凭据版本与代次隔离,失败不能透明重放已发送操作 |
|
||||
| 模型路由/调度 | 真实入口能命中规则,未匹配请求保持原有行为;使用官方客户端正常携带的业务头和会话别名,确认投影不会拒绝合法请求,也不暴露身份字段 |
|
||||
| 生命周期/用量/WS 观察 | 完成一次实际模型请求并核对对应事件;WS 观察需确认实际上游采用 WS,HTTP 请求成功不能证明 WS 观察生效 |
|
||||
| 管理页面 | 从宿主打开,标题/副标题、明暗主题、加载与错误状态;管理桥请求成功;停用或版本切换后旧入口失效 |
|
||||
@@ -111,5 +112,5 @@ cpr-plugin package \
|
||||
外部示例测试若依赖未提供的仓库或制品,应记录为未执行;当前示例可用于实际能力联调,但不能把被忽略的测试计为通过。
|
||||
临时账号、Key 或配置只在明确的测试范围内创建,记录并清理自己创建的对象,不输出密钥、原始请求或插件 secret。
|
||||
|
||||
交付简述:源码与产物位置、兼容范围、所需能力/权限/绑定、执行过的命令及结果、尚未验证的场景。
|
||||
交付简述:源码与产物位置、兼容范围、所需能力与绑定、执行过的命令及结果、尚未验证的场景
|
||||
只有确实生成了产物才报告“已打包”,只有目标环境业务验证成功才报告“已生效”;提交、推送与发布按用户授权另行执行。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
## 清单与处理器
|
||||
|
||||
- `publisher` 与机器短名 `name` 派生插件 ID,`displayName` 用于展示;复制示例后同时调整扩展项 ID 与注册结果
|
||||
- `contributes` 每种能力最多一项,普通贡献项可省略派生 ID、能力版本和固定阶段;中间件阶段仍需显式选择。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
|
||||
- `contributes` 每种能力最多一项,普通贡献项可省略派生 ID、能力版本和固定阶段;中间件必须声明版本 `4` 并显式选择阶段,上游适配器必须声明版本 `2`。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
|
||||
- 作者清单用 `Manifest::from_author_slice` 规范化,不手写 `package`、固定阶段或重复注册;CLI 生成平台、协议与资源摘要,版本范围不能使用全版本通配
|
||||
- 安装后宿主会按 `configurationSchema` 准备默认配置,配置完整即可启用;默认绑定的空范围不限制请求。需要业务参数时声明真实必填项,通过 `secretFields` 声明敏感字段,不依赖用户再走一遍自建安装向导
|
||||
- Rust 插件只依赖公开 `gateway-plugin-sdk`;需要异步会话辅助时开启 `io`,使用 `PluginSession` 管理握手、回调关联、流控、取消和关闭
|
||||
@@ -16,9 +16,10 @@
|
||||
## 中间件与宿主服务
|
||||
|
||||
- `request` 包裹整个逻辑请求,`attempt` 在每次选定账号的尝试中执行;根据作用范围选择,避免重试时重复副作用
|
||||
- `next` 只能消费一次。使用 SDK 的正文保留与流式映射能力,不自行拼一套 SSE/取消/流控机制;请求、响应及策略处理使用 `requests` 域
|
||||
- `next` 只能消费一次。使用 SDK 的正文保留与流式映射能力,不自行拼一套 SSE/取消/流控机制;HTTP、WebSocket、服务和模型请求按各自的类型化视图处理
|
||||
- 持久化状态使用已声明命名空间的 `host.state.*`,账号读写使用 `host.auth.*`,不直接访问宿主数据库。见[账号与凭据](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#账号与凭据)和[状态、日志与迁移](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#状态日志与迁移)
|
||||
- 权限从目标 SDK 的[访问域](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#访问域)选择,不在技能中维护枚举副本;日志和自身状态无需授权项,安装器不配置 Key/账号白名单,资源选择属于插件业务
|
||||
- 按[完整信任](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#完整信任)使用宿主回调,不添加权限或访问域声明;资源选择属于插件业务,父调用、期限、取消、实例 revision 与事务校验仍须满足
|
||||
- 模型、网络和亲和查询使用当前父调用的受管回调。管理/CLI 等独立入口调用模型时传明确的 Key ID;请求处理阶段继承父身份。见[Key、模型与模型调用](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#key模型与模型调用)
|
||||
- SDK 操作名不是管理员 HTTP 路由,不能据此拼接口地址;宿主身份、账单事实和调用授权也不能由插件覆盖
|
||||
- SDK 操作名不是管理员 HTTP 路由,不能据此拼接口地址;主动调用 HTTP 使用 `dispatch_http`,类型化服务使用 `HostClient::service`,实际执行与账单事实仍由宿主持有
|
||||
- 路由与调度只读取宿主投影的请求事实,身份头可能被隐藏;使用已有会话和亲和合同,不依赖原始认证头或根据缓存键补造客户端身份
|
||||
- 需要自定义上游编码、路径与响应解析时使用[受管上游适配器](../../../../backend/crates/gateway-plugin/sdk/docs/upstream-adapters.md),它是已选号后的终端,不调用 `next` 或自行组织换号重试;普通请求和响应加工仍使用中间件
|
||||
|
||||
@@ -117,8 +117,9 @@ runs:
|
||||
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml)
|
||||
"${compose[@]}" up -d --no-build --wait postgres redis
|
||||
# 基础设施使用 UTC,业务展示和调度时区由应用配置独立控制。
|
||||
test "$("${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
|
||||
psql -At -U codex_proxy -d codex_proxy -c "SHOW TimeZone;" | tr -d '\r')" = Asia/Shanghai
|
||||
psql -At -U codex_proxy -d codex_proxy -c "SHOW TimeZone;" | tr -d '\r')" = UTC
|
||||
"${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
|
||||
psql -v ON_ERROR_STOP=1 -U codex_proxy -d codex_proxy -c \
|
||||
"CREATE TABLE cpr_ci_persistence_probe (value text PRIMARY KEY); INSERT INTO cpr_ci_persistence_probe VALUES ('persisted');"
|
||||
@@ -148,8 +149,8 @@ runs:
|
||||
test "$status" = 204
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs id -u | tr -d '\r')" = 10001
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs id -g | tr -d '\r')" = 10001
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs date +%z | tr -d '\r')" = +0800
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs cat /etc/timezone | tr -d '\r')" = Asia/Shanghai
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs date +%z | tr -d '\r')" = +0000
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs cat /etc/timezone | tr -d '\r')" = UTC
|
||||
"${compose[@]}" exec -T codex-proxy-rs sh -c \
|
||||
'touch /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write && rm /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write'
|
||||
|
||||
|
||||
@@ -177,8 +177,6 @@ jobs:
|
||||
host_path = "backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json"
|
||||
host = JSON.parse(File.read(host_path))
|
||||
abort "Invalid plugin host compatibility declaration: #{host_path}" unless host["schema_version"] == 2
|
||||
# 保留旧更新器可解析的传输格式,实际支持范围仍取自当前宿主合同。
|
||||
host = host.merge("schema_version" => 1, "permissions" => [])
|
||||
# 清单只依赖版本与兼容声明,平台归档和镜像消费同一份封口结果。
|
||||
manifest = {
|
||||
"schema_version" => 1,
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
## 快速预览
|
||||
|
||||
无需部署,打开 [快速预览服务](https://codex-proxy-rs.ainz.cc) 即可体验管理端的系统概览、账号分组、代理管理与用量统计
|
||||
无需部署即可体验管理端的系统概览、账号分组、代理管理与用量统计:
|
||||
|
||||
| 登录信息 | 默认值 |
|
||||
| --- | --- |
|
||||
@@ -53,8 +53,7 @@ curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/in
|
||||
[安装脚本](deploy/install.sh) 默认安装到当前目录下的 `codex-proxy-rs/`,下载同一正式 Release 的部署文件,
|
||||
自动生成密码、设置目录权限并启动服务
|
||||
|
||||
完成后会显示访问地址和管理员密码,请保存密码,再按下方步骤
|
||||
[添加账号与客户端密钥](#添加账号与客户端密钥)
|
||||
完成后保存显示的访问地址和管理员密码,再[登录管理端](#登录管理端)并[添加账号与客户端密钥](#添加账号与客户端密钥)
|
||||
|
||||
可在执行 `bash install.sh` 时传入环境变量:
|
||||
|
||||
|
||||
Generated
+129
-81
@@ -31,6 +31,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocation-counter"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "beb9e990c0a33699f1984d85a6abead615ccc72dd8130bf3e15dcabe2ca149c9"
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -268,9 +274,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-s3"
|
||||
version = "1.149.0"
|
||||
version = "1.152.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d48257fb0eb1907a02a965ff7d1b910770a6de2c59a2d03461678df989c5cdcd"
|
||||
checksum = "004d5acbd6850ab58789a43822e0a2ef23927a46b012c17f8fa51253103db9f8"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
@@ -297,7 +303,7 @@ dependencies = [
|
||||
"lru",
|
||||
"percent-encoding",
|
||||
"regex-lite",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
@@ -319,7 +325,7 @@ dependencies = [
|
||||
"hmac",
|
||||
"http",
|
||||
"percent-encoding",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"time",
|
||||
"tracing",
|
||||
]
|
||||
@@ -352,7 +358,7 @@ dependencies = [
|
||||
"md-5",
|
||||
"pin-project-lite",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -369,9 +375,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-http"
|
||||
version = "0.64.0"
|
||||
version = "0.64.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d"
|
||||
checksum = "639b4d8f8555f24a9be649811c3eb0b4d4616f4d61daf0c32e28873bc1ea9af1"
|
||||
dependencies = [
|
||||
"aws-smithy-eventstream",
|
||||
"aws-smithy-runtime-api",
|
||||
@@ -391,9 +397,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-http-client"
|
||||
version = "1.4.2"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7bd25384a4e437aa8d8f339afad4b69e786b936a7cb10db668a7aaf66717b1a8"
|
||||
checksum = "51c89cc3f1f281d659a67a519a1b5c6d445b5ce09fa7e5aee40c2c2707e9509d"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api",
|
||||
@@ -435,9 +441,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime"
|
||||
version = "1.15.0"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "591e0024cdf2a8de8711860f7e1d5cfbca26b800ce13793a9aa2f4f5918e6d95"
|
||||
checksum = "d6e302ac1d88b99652489df31abdec6ac42a2ab2ac3982ad0ac49f64dfaf28ba"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
@@ -459,9 +465,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime-api"
|
||||
version = "1.18.0"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bb0deb40a69de59f7809251cfe1ab16de135f42c49d222d1202b4d0ce19720f"
|
||||
checksum = "c0730c16f91124c6a2abb4932c77e299288b3dd9f967ea2e9ec48cc6731e87a4"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api-macros",
|
||||
@@ -567,6 +573,7 @@ dependencies = [
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"multer",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
@@ -870,7 +877,7 @@ dependencies = [
|
||||
"gateway-plugin-sdk",
|
||||
"hex",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
@@ -926,9 +933,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "config"
|
||||
version = "0.15.26"
|
||||
version = "0.15.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "790374c727a4cb20877dd2cc99ce7011cbe03b0b8df083acccd37df0d161304d"
|
||||
checksum = "38e9fbc53afcec54441422f1c596cd054655b32e70e1cbac956651b7b175a404"
|
||||
dependencies = [
|
||||
"pathdiff",
|
||||
"serde_core",
|
||||
@@ -1674,6 +1681,7 @@ dependencies = [
|
||||
"gateway-core",
|
||||
"heck",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"prettyplease",
|
||||
"quote",
|
||||
"rand_core 0.6.4",
|
||||
@@ -1681,9 +1689,9 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.6",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
@@ -1712,7 +1720,7 @@ dependencies = [
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
@@ -1728,19 +1736,21 @@ dependencies = [
|
||||
name = "gateway-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"allocation-counter",
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"event-listener",
|
||||
"futures",
|
||||
"futures-timer",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -1779,7 +1789,8 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_ignored",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"serde_path_to_error",
|
||||
"sha2",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
@@ -1820,7 +1831,7 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tar",
|
||||
"tempfile",
|
||||
@@ -1848,6 +1859,8 @@ dependencies = [
|
||||
name = "gateway-protocol"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"http",
|
||||
"httpdate",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1875,7 +1888,7 @@ dependencies = [
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
@@ -1968,9 +1981,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.16"
|
||||
version = "0.4.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
|
||||
checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -2098,9 +2111,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
@@ -2434,9 +2447,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "0.57.0"
|
||||
version = "0.58.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71160ed5f6dbe36a2d6be79f4ca03ee09a99d2866f340faed49458913449aefa"
|
||||
checksum = "f622c574a29fb3034a0a90645afee194dfedc75fc124228801b6e77041075bd1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytecount",
|
||||
@@ -2462,18 +2475,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-regex"
|
||||
version = "0.57.0"
|
||||
version = "0.58.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48d2120d8466ffcdc1b3be4b88ff0e9191bf5b6b09b1b1af1eef9aff8f465ea3"
|
||||
checksum = "a968fffd387b2ab005bf00c1851f5d26e90df05b6f7ad609ed0d620033cca5e7"
|
||||
dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-value"
|
||||
version = "0.57.0"
|
||||
version = "0.58.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fbfa40a42415369d940b3848f3ce08099f1f1ac04d73e8580f4d652617c4f44"
|
||||
checksum = "39b7670345e5ce87371339d13705dbb9276b7e3e934bbe1efa2c6734a1f65a96"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytecount",
|
||||
@@ -2513,6 +2526,15 @@ version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.37.0"
|
||||
@@ -2687,6 +2709,23 @@ dependencies = [
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-util",
|
||||
"http",
|
||||
"httparse",
|
||||
"memchr",
|
||||
"mime",
|
||||
"spin 0.9.9",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
@@ -2813,6 +2852,24 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-system-configuration"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -2901,7 +2958,7 @@ dependencies = [
|
||||
"elliptic-curve",
|
||||
"primefield",
|
||||
"primeorder",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3161,12 +3218,12 @@ checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3209,6 +3266,7 @@ dependencies = [
|
||||
name = "provider-openai"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"allocation-counter",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"aws-lc-rs",
|
||||
@@ -3224,6 +3282,7 @@ dependencies = [
|
||||
"gateway-core",
|
||||
"gateway-protocol",
|
||||
"getrandom 0.4.3",
|
||||
"h2",
|
||||
"hex",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
@@ -3231,6 +3290,7 @@ dependencies = [
|
||||
"native-tls",
|
||||
"openssl",
|
||||
"openssl-sys",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"roxmltree",
|
||||
"rustls",
|
||||
@@ -3240,7 +3300,7 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"tar",
|
||||
"tempfile",
|
||||
@@ -3282,7 +3342,7 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -3342,9 +3402,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.16"
|
||||
version = "0.11.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
|
||||
checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
@@ -3378,9 +3438,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
@@ -3528,9 +3588,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.57.0"
|
||||
version = "0.58.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b06f6798be4fed305e74df8b1fb90cf59c6b8cc17aa3febeb1830c0c6b627b3e"
|
||||
checksum = "558f3855529c0a7e78a48e409fa653456e55f1b167eab4109c16a255b7727e3f"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"fluent-uri",
|
||||
@@ -3545,9 +3605,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.13.0"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2"
|
||||
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
@@ -3993,17 +4053,6 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
@@ -4124,8 +4173,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
dependencies = [
|
||||
"sqlx-core",
|
||||
"sqlx-macros",
|
||||
@@ -4137,8 +4185,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-core"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -4161,7 +4208,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
@@ -4175,8 +4222,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-macros"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4188,8 +4234,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-macros-core"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dotenvy",
|
||||
@@ -4200,7 +4245,7 @@ dependencies = [
|
||||
"quote",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sha2",
|
||||
"sqlx-core",
|
||||
"sqlx-mysql",
|
||||
"sqlx-postgres",
|
||||
@@ -4214,8 +4259,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-mysql"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
@@ -4232,7 +4276,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"sqlx-core",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
@@ -4242,8 +4286,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-postgres"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64 0.22.1",
|
||||
@@ -4266,7 +4309,7 @@ dependencies = [
|
||||
"rand 0.10.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
@@ -4279,8 +4322,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-sqlite"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"chrono",
|
||||
@@ -4965,9 +5007,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.26.1"
|
||||
version = "1.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
|
||||
checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
@@ -5152,6 +5194,12 @@ name = "whoami"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libredox",
|
||||
"objc2-system-configuration",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
@@ -5499,9 +5547,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
@@ -59,3 +59,5 @@ panic = "abort"
|
||||
[patch.crates-io]
|
||||
# Upstream SOCKS IPv6 fix; remove once a release includes hyperium/hyper-util#302.
|
||||
hyper-util = { git = "https://github.com/hyperium/hyper-util", rev = "191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6" }
|
||||
# 固定上游 BEGIN 取消回滚修复,避免未结束事务复用;见 transact-rs/sqlx#4394
|
||||
sqlx = { git = "https://github.com/transact-rs/sqlx", rev = "f1e94ec34dd5f11ad6488a3a8ac7988439a3f757" }
|
||||
|
||||
@@ -1,459 +0,0 @@
|
||||
//! 网关唯一组合根:加载包级配置并连接各 Bundle。
|
||||
|
||||
use gateway_core::engine::provider::{ProviderRegistry, RegistryError};
|
||||
use gateway_host::{ConfigError, HostConfig, LoadableConfig};
|
||||
use serde::Deserialize;
|
||||
|
||||
const CONFIG_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// 顶层配置只组合各包拥有的配置段,不解释任何业务字段。
|
||||
/// 各启动配置忽略未知字段,允许升级时保留旧配置;已知字段仍按类型和业务约束校验。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GatewayConfig {
|
||||
schema_version: u32,
|
||||
host: HostConfig,
|
||||
store: gateway_store::StoreConfig,
|
||||
admin: gateway_admin::AdminConfig,
|
||||
#[serde(default)]
|
||||
client: gateway_admin::ClientConfig,
|
||||
api: gateway_api::ApiConfig,
|
||||
#[serde(default)]
|
||||
openai: provider_openai::OpenAiConfig,
|
||||
}
|
||||
|
||||
impl LoadableConfig for GatewayConfig {
|
||||
const EXTERNAL_SECTIONS: &'static [&'static str] = &["services"];
|
||||
|
||||
fn resolve_and_validate(&mut self, source_dir: &std::path::Path) -> Result<(), ConfigError> {
|
||||
if self.schema_version != CONFIG_SCHEMA_VERSION {
|
||||
return Err(ConfigError::InvalidField("schema_version"));
|
||||
}
|
||||
self.api
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("api"))?;
|
||||
self.host
|
||||
.resolve_and_validate(source_dir, &self.api.asset_directory)?;
|
||||
let runtime_data_dir = self.host.runtime_data_dir().to_path_buf();
|
||||
self.store
|
||||
.resolve_and_validate(&runtime_data_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("store"))?;
|
||||
self.admin
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("admin"))?;
|
||||
self.client
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("client"))?;
|
||||
self.openai
|
||||
.resolve_and_validate(&runtime_data_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("openai"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 按冻结顺序初始化全部 Bundle,并把进程阻塞权交给 Host。
|
||||
pub async fn run() -> Result<(), BootstrapError> {
|
||||
launch(None).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// 这里只接收插件命名空间内的参数,不转交宿主配置或其他启动 secret。
|
||||
pub struct PluginCommand {
|
||||
pub instance_id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub arguments: Vec<String>,
|
||||
}
|
||||
|
||||
impl PluginCommand {
|
||||
fn is_help(&self) -> bool {
|
||||
self.name.is_none()
|
||||
|| self
|
||||
.arguments
|
||||
.iter()
|
||||
.any(|value| matches!(value.as_str(), "--help" | "-h"))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn plugin_command(
|
||||
command: PluginCommand,
|
||||
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
|
||||
launch(Some(command)).await
|
||||
}
|
||||
|
||||
async fn launch(
|
||||
command: Option<PluginCommand>,
|
||||
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
|
||||
macro_rules! or_shutdown {
|
||||
($runtime:expr, $result:expr) => {
|
||||
match $result {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
$runtime.shutdown().await;
|
||||
return Err(error.into());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let config = gateway_host::load_config::<GatewayConfig>()?;
|
||||
let GatewayConfig {
|
||||
schema_version: _,
|
||||
host,
|
||||
store,
|
||||
admin,
|
||||
client,
|
||||
api,
|
||||
openai,
|
||||
} = config;
|
||||
|
||||
let plugin_cache = host.runtime_data_dir().join("plugins");
|
||||
let host = if command.is_some() {
|
||||
gateway_host::initialize_command_line(host).await?
|
||||
} else {
|
||||
gateway_host::initialize(host).await?
|
||||
};
|
||||
host.report_startup_ready("Host");
|
||||
let help = command.as_ref().is_some_and(PluginCommand::is_help);
|
||||
let mut store = if help {
|
||||
gateway_store::initialize_read_only(store).await?
|
||||
} else if command.is_some() {
|
||||
gateway_store::initialize_command_line(store).await?
|
||||
} else {
|
||||
gateway_store::initialize(store).await?
|
||||
};
|
||||
host.report_startup_ready("Store");
|
||||
let version = host
|
||||
.system_operations()
|
||||
.version()
|
||||
.await
|
||||
.map_err(|_| ConfigError::InvalidField("host.version"))?
|
||||
.version;
|
||||
let plugin_http = std::sync::Arc::new(gateway_host::outbound::HttpClient::new()?);
|
||||
if let Some(command) = command.as_ref().filter(|_| help) {
|
||||
let runtime = prepare_plugins(&store, plugin_cache, &version, plugin_http.clone())?;
|
||||
let commands = or_shutdown!(runtime, runtime.prepare_command_line().await);
|
||||
let stdout = commands.help(command.instance_id.as_deref(), command.name.as_deref());
|
||||
commands.shutdown().await;
|
||||
runtime.shutdown().await;
|
||||
return Ok(gateway_plugin_runtime::PluginCommandOutput {
|
||||
stdout: stdout?,
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
saved_accounts: 0,
|
||||
});
|
||||
}
|
||||
let provider_ports = store.provider_ports();
|
||||
let mut openai = provider_openai::initialize(openai, provider_ports.clone()).await?;
|
||||
host.report_startup_ready("OpenAI Provider");
|
||||
let mut xai = provider_xai::initialize(provider_ports).await?;
|
||||
host.report_startup_ready("xAI Provider");
|
||||
let providers = ProviderRegistry::new([openai.core_provider(), xai.core_provider()])?;
|
||||
let plugin_runtime = prepare_plugins(&store, plugin_cache, &version, plugin_http.clone())?;
|
||||
let admin_providers = gateway_admin::ports::provider::ProviderAdminRegistry::new([
|
||||
openai.admin_provider(),
|
||||
xai.admin_provider(),
|
||||
])
|
||||
.map_err(|_| gateway_admin::model::AdminError::invalid("Provider 管理身份冲突"))?;
|
||||
if let Some(command) = command {
|
||||
let instance_id = or_shutdown!(
|
||||
plugin_runtime,
|
||||
command
|
||||
.instance_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| gateway_admin::model::AdminError::invalid("插件实例 ID 缺失"))
|
||||
);
|
||||
let name = or_shutdown!(
|
||||
plugin_runtime,
|
||||
command
|
||||
.name
|
||||
.as_deref()
|
||||
.ok_or_else(|| gateway_admin::model::AdminError::invalid("插件命令名缺失"))
|
||||
);
|
||||
let core = gateway_core::prepare_command_plane(
|
||||
store.core_ports(),
|
||||
providers.clone(),
|
||||
Some(plugin_runtime.clone()),
|
||||
Some(plugin_runtime.observer_registry()),
|
||||
Some(plugin_runtime.policy_registry()),
|
||||
Some(plugin_runtime.middleware_registry()),
|
||||
);
|
||||
let accounts = gateway_admin::initialize_plugin_accounts(
|
||||
admin_providers.clone(),
|
||||
store.admin_ports().accounts(),
|
||||
core.snapshot_control(),
|
||||
);
|
||||
or_shutdown!(plugin_runtime, plugin_runtime.bind_account_ports(&accounts));
|
||||
let keys = gateway_admin::initialize_plugin_client_keys(
|
||||
admin_providers.clone(),
|
||||
store.admin_ports().client_keys(),
|
||||
core.snapshot_control(),
|
||||
);
|
||||
or_shutdown!(plugin_runtime, plugin_runtime.bind_client_key_ports(&keys));
|
||||
let plugin_resources = gateway_admin::initialize_plugin_resources(
|
||||
store.admin_ports().plugin_resources(),
|
||||
core.snapshot_control(),
|
||||
);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_resource_ports(&plugin_resources)
|
||||
);
|
||||
let core = or_shutdown!(plugin_runtime, core.activate().await);
|
||||
let nested_models = core.nested_model_execution_port();
|
||||
let affinity_lookup = core.affinity_lookup_port();
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
|
||||
);
|
||||
let settings = gateway_admin::initialize_settings(
|
||||
store.admin_ports().settings(),
|
||||
core.snapshot_control(),
|
||||
admin_providers,
|
||||
std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
|
||||
);
|
||||
let mut services = gateway_admin::service::Registry::new({
|
||||
let snapshots = core.snapshots();
|
||||
let middleware = plugin_runtime.middleware_registry();
|
||||
std::sync::Arc::new(move || {
|
||||
let snapshot = snapshots.snapshot_for_diagnostics()?;
|
||||
middleware.resolve(snapshot.extensions()?)
|
||||
})
|
||||
});
|
||||
or_shutdown!(plugin_runtime, services.register_settings(&settings));
|
||||
let services = std::sync::Arc::new(services);
|
||||
or_shutdown!(plugin_runtime, plugin_runtime.bind_services(&services));
|
||||
let commands = or_shutdown!(plugin_runtime, plugin_runtime.prepare_command_line().await);
|
||||
or_shutdown!(plugin_runtime, store.start_command_line_writes());
|
||||
let result = commands
|
||||
.execute_cancellable(instance_id, name, &command.arguments, &host.cancellation())
|
||||
.await;
|
||||
commands.shutdown().await;
|
||||
plugin_runtime.shutdown().await;
|
||||
store.shutdown_command_line_writes().await?;
|
||||
return Ok(result?);
|
||||
}
|
||||
let core = gateway_core::prepare(
|
||||
store.core_ports(),
|
||||
providers,
|
||||
Some(plugin_runtime.clone()),
|
||||
Some(plugin_runtime.observer_registry()),
|
||||
Some(plugin_runtime.policy_registry()),
|
||||
Some(plugin_runtime.middleware_registry()),
|
||||
Some(plugin_runtime.frontend_authentication_registry()),
|
||||
);
|
||||
let plugin_accounts = gateway_admin::initialize_plugin_accounts(
|
||||
admin_providers.clone(),
|
||||
store.admin_ports().accounts(),
|
||||
core.snapshot_control(),
|
||||
);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_account_ports(&plugin_accounts)
|
||||
);
|
||||
let plugin_keys = gateway_admin::initialize_plugin_client_keys(
|
||||
admin_providers.clone(),
|
||||
store.admin_ports().client_keys(),
|
||||
core.snapshot_control(),
|
||||
);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_client_key_ports(&plugin_keys)
|
||||
);
|
||||
let plugin_resources = gateway_admin::initialize_plugin_resources(
|
||||
store.admin_ports().plugin_resources(),
|
||||
core.snapshot_control(),
|
||||
);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_resource_ports(&plugin_resources)
|
||||
);
|
||||
let mut core = or_shutdown!(plugin_runtime, core.activate().await);
|
||||
let nested_models = core.nested_model_execution_port();
|
||||
let affinity_lookup = core.affinity_lookup_port();
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
|
||||
);
|
||||
host.report_startup_ready("Core");
|
||||
let host_version = or_shutdown!(
|
||||
plugin_runtime,
|
||||
version
|
||||
.trim_start_matches('v')
|
||||
.parse()
|
||||
.map_err(|_| ConfigError::InvalidField("host.version"))
|
||||
);
|
||||
let plugin_inspector = std::sync::Arc::new(gateway_plugin_runtime::PackageInspector::new(
|
||||
gateway_plugin_runtime::PackageLimits::default(),
|
||||
host_version,
|
||||
));
|
||||
let plugin_distribution = std::sync::Arc::new(or_shutdown!(
|
||||
plugin_runtime,
|
||||
gateway_host::plugin_distribution::HttpPluginDistribution::new(plugin_http)
|
||||
));
|
||||
let mut admin = or_shutdown!(
|
||||
plugin_runtime,
|
||||
gateway_admin::initialize_with_plugin_accounts(
|
||||
admin,
|
||||
client,
|
||||
store.admin_ports(),
|
||||
gateway_admin::AdminRuntimePorts {
|
||||
service_middleware: {
|
||||
let snapshots = core.snapshots();
|
||||
let middleware = plugin_runtime.middleware_registry();
|
||||
std::sync::Arc::new(move || {
|
||||
let snapshot = snapshots.snapshot_for_diagnostics()?;
|
||||
middleware.resolve(snapshot.extensions()?)
|
||||
})
|
||||
},
|
||||
providers: admin_providers,
|
||||
plugin_preparation: plugin_runtime.clone(),
|
||||
plugin_management: plugin_runtime.clone(),
|
||||
published_snapshot: core.snapshots(),
|
||||
plugin_inspector,
|
||||
plugin_distribution,
|
||||
pricing_source: std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
|
||||
snapshot: core.snapshot_control(),
|
||||
account_probe: core.account_probe(),
|
||||
proxy_probe: host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
|
||||
client_distribution: host.client_distribution_resolver(),
|
||||
system: host.system_operations(),
|
||||
client_key_verifier: core.client_key_verifier(),
|
||||
},
|
||||
plugin_accounts,
|
||||
)
|
||||
.await
|
||||
);
|
||||
let official_files = host.official_plugin_release_files();
|
||||
let official_identity = host.official_plugin_release_identity();
|
||||
let official_import = or_shutdown!(
|
||||
plugin_runtime,
|
||||
admin
|
||||
.services()
|
||||
.plugins()
|
||||
.import_official_release(
|
||||
official_files.as_ref(),
|
||||
&official_identity,
|
||||
&gateway_admin::model::MutationContext {
|
||||
actor: gateway_admin::model::MutationActor::System,
|
||||
request_id: "startup-official-plugin-import".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
);
|
||||
if official_import.artifacts > 0 {
|
||||
host.report_startup_ready("Official plugins");
|
||||
}
|
||||
host.report_startup_ready("Admin");
|
||||
|
||||
let mut probes = store.health_probes();
|
||||
probes.extend(core.health_probes());
|
||||
probes.push(host.logging_health_probe());
|
||||
let api = or_shutdown!(
|
||||
plugin_runtime,
|
||||
gateway_api::initialize(
|
||||
api,
|
||||
core.execution_service(),
|
||||
admin.services(),
|
||||
probes,
|
||||
host.worker_health(),
|
||||
host.connection_lifecycle(),
|
||||
)
|
||||
)
|
||||
.with_middleware({
|
||||
let snapshots = core.snapshots();
|
||||
let middleware = plugin_runtime.middleware_registry();
|
||||
move |snapshot| {
|
||||
if let Some(snapshot) = snapshot {
|
||||
return middleware.resolve(snapshot.extensions()?);
|
||||
}
|
||||
// 数据面未就绪时,管理与诊断路由仍可使用发布候选中的插件。
|
||||
let diagnostic = snapshots.snapshot_for_diagnostics()?;
|
||||
middleware.resolve(diagnostic.extensions()?)
|
||||
}
|
||||
});
|
||||
let http_dispatcher = api.dispatcher();
|
||||
or_shutdown!(plugin_runtime, plugin_runtime.bind_http(&http_dispatcher));
|
||||
host.report_startup_ready("API");
|
||||
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_services(&admin.services().public_services())
|
||||
);
|
||||
|
||||
let mut plan = store.take_worker_contributions();
|
||||
plan.push(gateway_host::retention::worker(store.retention())?);
|
||||
plan.extend(core.take_worker_contributions());
|
||||
plan.extend(openai.take_worker_contributions());
|
||||
plan.extend(xai.take_worker_contributions());
|
||||
plan.extend(admin.take_worker_contributions());
|
||||
plan.push(plugin_runtime.maintenance_worker(core.snapshots())?);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
host.start_workers(plan, store.worker_leader_lease())
|
||||
);
|
||||
host.report_startup_ready("Workers");
|
||||
let served = host.serve(api.router()).await;
|
||||
plugin_runtime.shutdown().await;
|
||||
served?;
|
||||
Ok(gateway_plugin_runtime::PluginCommandOutput {
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
saved_accounts: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn prepare_plugins(
|
||||
store: &gateway_store::StoreBundle,
|
||||
plugin_cache: std::path::PathBuf,
|
||||
version: &str,
|
||||
http: std::sync::Arc<gateway_host::outbound::HttpClient>,
|
||||
) -> Result<std::sync::Arc<gateway_plugin_runtime::PluginRuntime>, BootstrapError> {
|
||||
Ok(std::sync::Arc::new(
|
||||
gateway_plugin_runtime::PluginRuntime::new(
|
||||
store.admin_ports().plugins(),
|
||||
store.admin_ports().plugin_state(),
|
||||
gateway_plugin_runtime::PluginRuntimeConfig {
|
||||
cache_directory: plugin_cache,
|
||||
host_version: version
|
||||
.trim_start_matches('v')
|
||||
.parse()
|
||||
.map_err(|_| ConfigError::InvalidField("host.version"))?,
|
||||
package_limits: gateway_plugin_runtime::PackageLimits::default(),
|
||||
rpc_limits: gateway_plugin_runtime::RpcLimits::default(),
|
||||
restart_circuit: Default::default(),
|
||||
},
|
||||
http,
|
||||
std::sync::Arc::new(gateway_host::process::ProcessSupervisor::default()),
|
||||
)
|
||||
.with_oauth_pending(store.provider_ports().oauth_pending()),
|
||||
))
|
||||
}
|
||||
|
||||
/// 组合根只保留包级错误分类,不展开内部实现或敏感配置。
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BootstrapError {
|
||||
#[error(transparent)]
|
||||
Worker(#[from] gateway_core::task::WorkerDefinitionError),
|
||||
#[error(transparent)]
|
||||
PluginCommand(#[from] gateway_plugin_runtime::PluginCommandError),
|
||||
#[error(transparent)]
|
||||
Config(#[from] gateway_host::ConfigError),
|
||||
#[error(transparent)]
|
||||
Host(#[from] gateway_host::HostError),
|
||||
#[error(transparent)]
|
||||
Outbound(#[from] gateway_host::outbound::HttpError),
|
||||
#[error(transparent)]
|
||||
Store(#[from] gateway_store::StoreError),
|
||||
#[error(transparent)]
|
||||
CommandStoreDrain(#[from] gateway_store::CommandStoreDrainError),
|
||||
#[error(transparent)]
|
||||
OpenAi(#[from] provider_openai::OpenAiInitializeError),
|
||||
#[error(transparent)]
|
||||
Xai(#[from] provider_xai::XaiInitializeError),
|
||||
#[error(transparent)]
|
||||
Registry(#[from] RegistryError),
|
||||
#[error(transparent)]
|
||||
Core(#[from] gateway_core::CoreError),
|
||||
#[error(transparent)]
|
||||
Admin(#[from] gateway_admin::model::AdminError),
|
||||
#[error(transparent)]
|
||||
Api(#[from] gateway_api::ApiError),
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! 插件帮助只读启动;命令仅组装命令平面和必要写泵
|
||||
|
||||
use super::{
|
||||
BootstrapError, GatewayConfig,
|
||||
plugins::{ManagementPorts, or_shutdown, prepare_plugins, service_middleware},
|
||||
startup::{Environment, Mode, Providers},
|
||||
};
|
||||
|
||||
/// 这里只接收插件命名空间内的参数,不转交宿主配置或其他启动 secret
|
||||
pub struct PluginCommand {
|
||||
pub instance_id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub arguments: Vec<String>,
|
||||
}
|
||||
|
||||
impl PluginCommand {
|
||||
fn is_help(&self) -> bool {
|
||||
self.name.is_none()
|
||||
|| self
|
||||
.arguments
|
||||
.iter()
|
||||
.any(|value| matches!(value.as_str(), "--help" | "-h"))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn plugin_command(
|
||||
command: PluginCommand,
|
||||
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
|
||||
let config = gateway_host::load_config::<GatewayConfig>()?;
|
||||
if command.is_help() {
|
||||
let environment = Environment::initialize(config.host, config.store, Mode::Help).await?;
|
||||
help(command, environment).await
|
||||
} else {
|
||||
let environment = Environment::initialize(config.host, config.store, Mode::Command).await?;
|
||||
let providers = Providers::initialize(config.openai, &environment).await?;
|
||||
execute(command, environment, providers).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn help(
|
||||
command: PluginCommand,
|
||||
environment: Environment,
|
||||
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
|
||||
let runtime = prepare_plugins(
|
||||
&environment.store,
|
||||
environment.plugin_cache,
|
||||
&environment.version,
|
||||
environment.plugin_http,
|
||||
)?;
|
||||
let commands = or_shutdown!(runtime, runtime.prepare_command_line().await);
|
||||
let stdout = commands.help(command.instance_id.as_deref(), command.name.as_deref());
|
||||
commands.shutdown().await;
|
||||
runtime.shutdown().await;
|
||||
Ok(gateway_plugin_runtime::PluginCommandOutput {
|
||||
stdout: stdout?,
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
saved_accounts: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
command: PluginCommand,
|
||||
environment: Environment,
|
||||
providers: Providers,
|
||||
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
|
||||
let Environment {
|
||||
host,
|
||||
mut store,
|
||||
plugin_cache,
|
||||
version,
|
||||
plugin_http,
|
||||
..
|
||||
} = environment;
|
||||
let plugin_runtime = prepare_plugins(&store, plugin_cache, &version, plugin_http)?;
|
||||
let instance_id = or_shutdown!(
|
||||
plugin_runtime,
|
||||
command
|
||||
.instance_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| gateway_admin::model::AdminError::invalid("插件实例 ID 缺失"))
|
||||
);
|
||||
let name = or_shutdown!(
|
||||
plugin_runtime,
|
||||
command
|
||||
.name
|
||||
.as_deref()
|
||||
.ok_or_else(|| gateway_admin::model::AdminError::invalid("插件命令名缺失"))
|
||||
);
|
||||
let core = gateway_core::prepare_command_plane(
|
||||
store.core_ports(),
|
||||
providers.core,
|
||||
Some(plugin_runtime.clone()),
|
||||
Some(plugin_runtime.observer_registry()),
|
||||
Some(plugin_runtime.policy_registry()),
|
||||
Some(plugin_runtime.execution_registry()),
|
||||
);
|
||||
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
|
||||
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
|
||||
let core = or_shutdown!(plugin_runtime, core.activate().await);
|
||||
let nested_models = core.nested_model_execution_port();
|
||||
let affinity_lookup = core.affinity_lookup_port();
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
|
||||
);
|
||||
let settings = gateway_admin::initialize_settings(
|
||||
store.admin_ports().settings(),
|
||||
core.snapshot_control(),
|
||||
providers.admin,
|
||||
std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
|
||||
);
|
||||
let mut services = gateway_admin::public_service::Registry::new(service_middleware(
|
||||
core.snapshots(),
|
||||
&plugin_runtime,
|
||||
));
|
||||
or_shutdown!(plugin_runtime, services.register_settings(&settings));
|
||||
let services = std::sync::Arc::new(services);
|
||||
or_shutdown!(plugin_runtime, plugin_runtime.bind_services(&services));
|
||||
let commands = or_shutdown!(plugin_runtime, plugin_runtime.prepare_command_line().await);
|
||||
let result = async {
|
||||
store.start_command_line_writes()?;
|
||||
Ok::<_, BootstrapError>(
|
||||
commands
|
||||
.execute_cancellable(instance_id, name, &command.arguments, &host.cancellation())
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
.await;
|
||||
commands.shutdown().await;
|
||||
plugin_runtime.shutdown().await;
|
||||
store.shutdown_command_line_writes().await?;
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! 网关顶层配置的加载、路径解析与跨模块参数校验
|
||||
|
||||
use gateway_host::{ConfigError, HostConfig, LoadableConfig};
|
||||
use serde::Deserialize;
|
||||
|
||||
const CONFIG_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// 顶层配置只组合各包拥有的配置段,不解释任何业务字段
|
||||
/// 各启动配置忽略未知字段,允许升级时保留旧配置;已知字段仍按类型和业务约束校验
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GatewayConfig {
|
||||
schema_version: u32,
|
||||
pub(super) host: HostConfig,
|
||||
pub(super) store: gateway_store::StoreConfig,
|
||||
pub(super) admin: gateway_admin::AdminConfig,
|
||||
#[serde(default)]
|
||||
pub(super) client: gateway_admin::ClientConfig,
|
||||
pub(super) api: gateway_api::ApiConfig,
|
||||
#[serde(default)]
|
||||
pub(super) openai: provider_openai::OpenAiConfig,
|
||||
}
|
||||
|
||||
impl LoadableConfig for GatewayConfig {
|
||||
const EXTERNAL_SECTIONS: &'static [&'static str] = &["services"];
|
||||
|
||||
fn resolve_and_validate(&mut self, source_dir: &std::path::Path) -> Result<(), ConfigError> {
|
||||
if self.schema_version != CONFIG_SCHEMA_VERSION {
|
||||
return Err(ConfigError::InvalidField("schema_version"));
|
||||
}
|
||||
self.api
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("api"))?;
|
||||
self.host
|
||||
.resolve_and_validate(source_dir, &self.api.asset_directory)?;
|
||||
let runtime_data_dir = self.host.runtime_data_dir().to_path_buf();
|
||||
self.store
|
||||
.resolve_and_validate(&runtime_data_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("store"))?;
|
||||
self.admin
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("admin"))?;
|
||||
self.client
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("client"))?;
|
||||
self.openai
|
||||
.resolve_and_validate(&runtime_data_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("openai"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! 网关唯一组合根:按启动模式连接各 Bundle
|
||||
|
||||
mod command;
|
||||
mod config;
|
||||
mod plugins;
|
||||
mod server;
|
||||
mod startup;
|
||||
|
||||
pub use command::{PluginCommand, plugin_command};
|
||||
pub use config::GatewayConfig;
|
||||
pub use server::run;
|
||||
|
||||
/// 组合根只保留包级错误分类,不展开内部实现或敏感配置
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BootstrapError {
|
||||
#[error(transparent)]
|
||||
Worker(#[from] gateway_core::task::WorkerDefinitionError),
|
||||
#[error(transparent)]
|
||||
PluginCommand(#[from] gateway_plugin_runtime::PluginCommandError),
|
||||
#[error(transparent)]
|
||||
Config(#[from] gateway_host::ConfigError),
|
||||
#[error(transparent)]
|
||||
Host(#[from] gateway_host::HostError),
|
||||
#[error(transparent)]
|
||||
Outbound(#[from] gateway_host::outbound::HttpError),
|
||||
#[error(transparent)]
|
||||
Store(#[from] gateway_store::StoreError),
|
||||
#[error(transparent)]
|
||||
CommandStoreDrain(#[from] gateway_store::CommandStoreDrainError),
|
||||
#[error(transparent)]
|
||||
OpenAi(#[from] provider_openai::OpenAiInitializeError),
|
||||
#[error(transparent)]
|
||||
Xai(#[from] provider_xai::XaiInitializeError),
|
||||
#[error(transparent)]
|
||||
Registry(#[from] gateway_core::engine::provider::RegistryError),
|
||||
#[error(transparent)]
|
||||
Core(#[from] gateway_core::CoreError),
|
||||
#[error(transparent)]
|
||||
Admin(#[from] gateway_admin::model::AdminError),
|
||||
#[error(transparent)]
|
||||
Api(#[from] gateway_api::ApiError),
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! 插件 Runtime 的组装与回调资源持有;服务和命令模式复用同一组管理端口
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gateway_admin::{
|
||||
model::AdminError,
|
||||
ports::{
|
||||
plugin_accounts::PluginAccountAccess, plugin_client_keys::PluginClientKeyAccess,
|
||||
plugin_resources::PluginResourceAccess, provider::ProviderAdminRegistry,
|
||||
},
|
||||
};
|
||||
use gateway_core::runtime::{RuntimeSnapshotHandle, SnapshotControl};
|
||||
use gateway_host::ConfigError;
|
||||
use gateway_plugin_runtime::PluginRuntime;
|
||||
|
||||
use super::BootstrapError;
|
||||
|
||||
// 必须在调用方仍持有 Core 和回调资源时异步关闭,不能先退出该作用域再清理
|
||||
macro_rules! or_shutdown {
|
||||
($runtime:expr, $result:expr) => {
|
||||
match $result {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
$runtime.shutdown().await;
|
||||
return Err(error.into());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(super) use or_shutdown;
|
||||
|
||||
pub(super) struct ManagementPorts {
|
||||
// Runtime 保存 Weak;组合根持有这些 Arc,直到插件全部关闭
|
||||
pub(super) accounts: Arc<dyn PluginAccountAccess>,
|
||||
keys: Arc<dyn PluginClientKeyAccess>,
|
||||
resources: Arc<dyn PluginResourceAccess>,
|
||||
}
|
||||
|
||||
impl ManagementPorts {
|
||||
pub(super) fn new(
|
||||
store: &gateway_store::StoreBundle,
|
||||
providers: ProviderAdminRegistry,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Self {
|
||||
let ports = store.admin_ports();
|
||||
Self {
|
||||
accounts: gateway_admin::initialize_plugin_accounts(
|
||||
providers.clone(),
|
||||
ports.accounts(),
|
||||
snapshot.clone(),
|
||||
),
|
||||
keys: gateway_admin::initialize_plugin_client_keys(
|
||||
providers,
|
||||
ports.client_keys(),
|
||||
snapshot.clone(),
|
||||
),
|
||||
resources: gateway_admin::initialize_plugin_resources(
|
||||
ports.plugin_resources(),
|
||||
snapshot,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn bind(&self, runtime: &PluginRuntime) -> Result<(), AdminError> {
|
||||
runtime.bind_account_ports(&self.accounts)?;
|
||||
runtime.bind_client_key_ports(&self.keys)?;
|
||||
runtime.bind_resource_ports(&self.resources)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn service_middleware(
|
||||
snapshots: RuntimeSnapshotHandle,
|
||||
runtime: &PluginRuntime,
|
||||
) -> gateway_admin::public_service::PlanSource {
|
||||
let middleware = runtime.execution_registry();
|
||||
Arc::new(move || {
|
||||
let snapshot = snapshots.snapshot_for_diagnostics()?;
|
||||
middleware.middleware(snapshot.extensions()?)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn prepare_plugins(
|
||||
store: &gateway_store::StoreBundle,
|
||||
plugin_cache: std::path::PathBuf,
|
||||
version: &str,
|
||||
http: Arc<gateway_host::outbound::HttpClient>,
|
||||
) -> Result<Arc<gateway_plugin_runtime::PluginRuntime>, BootstrapError> {
|
||||
Ok(Arc::new(
|
||||
gateway_plugin_runtime::PluginRuntime::new(
|
||||
store.admin_ports().plugins(),
|
||||
store.admin_ports().plugin_state(),
|
||||
gateway_plugin_runtime::PluginRuntimeConfig {
|
||||
cache_directory: plugin_cache,
|
||||
host_version: version
|
||||
.trim_start_matches('v')
|
||||
.parse()
|
||||
.map_err(|_| ConfigError::InvalidField("host.version"))?,
|
||||
package_limits: gateway_plugin_runtime::PackageLimits::default(),
|
||||
rpc_limits: gateway_plugin_runtime::RpcLimits::default(),
|
||||
restart_circuit: Default::default(),
|
||||
},
|
||||
http,
|
||||
Arc::new(gateway_host::process::ProcessSupervisor::default()),
|
||||
)
|
||||
.with_oauth_pending(store.provider_ports().oauth_pending()),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! 服务模式完成全部组装后交给 Host 监听,并在停止监听与 Worker 收尾后关闭插件
|
||||
|
||||
use gateway_host::ConfigError;
|
||||
|
||||
use super::{
|
||||
BootstrapError, GatewayConfig,
|
||||
plugins::{ManagementPorts, or_shutdown, prepare_plugins, service_middleware},
|
||||
startup::{Environment, Mode, Providers},
|
||||
};
|
||||
|
||||
/// 按冻结顺序初始化全部 Bundle,并把进程阻塞权交给 Host
|
||||
pub async fn run() -> Result<(), BootstrapError> {
|
||||
let config = gateway_host::load_config::<GatewayConfig>()?;
|
||||
let environment = Environment::initialize(config.host, config.store, Mode::Server).await?;
|
||||
let mut providers = Providers::initialize(config.openai, &environment).await?;
|
||||
let Environment {
|
||||
host,
|
||||
mut store,
|
||||
timezone,
|
||||
plugin_cache,
|
||||
version,
|
||||
plugin_http,
|
||||
} = environment;
|
||||
let plugin_runtime = prepare_plugins(&store, plugin_cache, &version, plugin_http.clone())?;
|
||||
let core = gateway_core::prepare(
|
||||
store.core_ports(),
|
||||
providers.core,
|
||||
Some(plugin_runtime.clone()),
|
||||
Some(plugin_runtime.observer_registry()),
|
||||
Some(plugin_runtime.policy_registry()),
|
||||
Some(plugin_runtime.execution_registry()),
|
||||
Some(plugin_runtime.frontend_authentication_registry()),
|
||||
);
|
||||
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
|
||||
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
|
||||
let mut core = or_shutdown!(plugin_runtime, core.activate().await);
|
||||
let nested_models = core.nested_model_execution_port();
|
||||
let affinity_lookup = core.affinity_lookup_port();
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
|
||||
);
|
||||
host.report_startup_ready("Core");
|
||||
let host_version = or_shutdown!(
|
||||
plugin_runtime,
|
||||
version
|
||||
.trim_start_matches('v')
|
||||
.parse()
|
||||
.map_err(|_| ConfigError::InvalidField("host.version"))
|
||||
);
|
||||
let plugin_inspector = std::sync::Arc::new(gateway_plugin_runtime::PackageInspector::new(
|
||||
gateway_plugin_runtime::PackageLimits::default(),
|
||||
host_version,
|
||||
));
|
||||
let plugin_distribution = std::sync::Arc::new(or_shutdown!(
|
||||
plugin_runtime,
|
||||
gateway_host::plugin_distribution::HttpPluginDistribution::new(plugin_http)
|
||||
));
|
||||
let analytics_worker = or_shutdown!(
|
||||
plugin_runtime,
|
||||
store.request_analytics_worker(std::sync::Arc::new(
|
||||
gateway_admin::ProviderUsageBillingProjector::new(providers.admin.clone()),
|
||||
))
|
||||
);
|
||||
let mut admin = or_shutdown!(
|
||||
plugin_runtime,
|
||||
gateway_admin::initialize_with_plugin_accounts(
|
||||
config.admin,
|
||||
config.client,
|
||||
store.admin_ports(),
|
||||
gateway_admin::AdminRuntimePorts {
|
||||
timezone,
|
||||
service_middleware: service_middleware(core.snapshots(), &plugin_runtime),
|
||||
providers: providers.admin,
|
||||
plugin_preparation: plugin_runtime.clone(),
|
||||
plugin_management: plugin_runtime.clone(),
|
||||
published_snapshot: core.snapshots(),
|
||||
plugin_inspector,
|
||||
plugin_distribution,
|
||||
pricing_source: std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
|
||||
snapshot: core.snapshot_control(),
|
||||
account_probe: core.account_probe(),
|
||||
proxy_probe: host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
|
||||
client_distribution: host.client_distribution_resolver(),
|
||||
system: host.system_operations(),
|
||||
client_key_verifier: core.client_key_verifier(),
|
||||
},
|
||||
management.accounts.clone(),
|
||||
)
|
||||
.await
|
||||
);
|
||||
let official_files = host.official_plugin_release_files();
|
||||
let official_identity = host.official_plugin_release_identity();
|
||||
let official_import = or_shutdown!(
|
||||
plugin_runtime,
|
||||
admin
|
||||
.services()
|
||||
.plugins()
|
||||
.import_official_release(
|
||||
official_files.as_ref(),
|
||||
&official_identity,
|
||||
&gateway_admin::model::MutationContext {
|
||||
actor: gateway_admin::model::MutationActor::System,
|
||||
request_id: "startup-official-plugin-import".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
);
|
||||
if official_import.artifacts > 0 {
|
||||
host.report_startup_ready("Official plugins");
|
||||
}
|
||||
host.report_startup_ready("Admin");
|
||||
|
||||
let mut probes = store.health_probes();
|
||||
probes.extend(core.health_probes());
|
||||
probes.push(host.logging_health_probe());
|
||||
let api = or_shutdown!(
|
||||
plugin_runtime,
|
||||
gateway_api::initialize(
|
||||
config.api,
|
||||
core.execution_service(),
|
||||
admin.services(),
|
||||
probes,
|
||||
host.worker_health(),
|
||||
host.connection_lifecycle(),
|
||||
store.diagnostics(),
|
||||
)
|
||||
)
|
||||
.with_middleware({
|
||||
let snapshots = core.snapshots();
|
||||
let middleware = plugin_runtime.execution_registry();
|
||||
move |snapshot| {
|
||||
if let Some(snapshot) = snapshot {
|
||||
return middleware.middleware(snapshot.extensions()?);
|
||||
}
|
||||
// 数据面未就绪时,管理与诊断路由仍可使用发布候选中的插件
|
||||
let diagnostic = snapshots.snapshot_for_diagnostics()?;
|
||||
middleware.middleware(diagnostic.extensions()?)
|
||||
}
|
||||
});
|
||||
let http_dispatcher = api.dispatcher();
|
||||
or_shutdown!(plugin_runtime, plugin_runtime.bind_http(&http_dispatcher));
|
||||
host.report_startup_ready("API");
|
||||
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_services(&admin.services().public_services())
|
||||
);
|
||||
|
||||
let mut plan = store.take_worker_contributions();
|
||||
plan.push(analytics_worker);
|
||||
plan.push(or_shutdown!(
|
||||
plugin_runtime,
|
||||
gateway_host::retention::worker(store.retention())
|
||||
));
|
||||
plan.extend(core.take_worker_contributions());
|
||||
plan.extend(providers.openai.take_worker_contributions());
|
||||
plan.extend(providers.xai.take_worker_contributions());
|
||||
plan.extend(admin.take_worker_contributions());
|
||||
plan.push(or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.maintenance_worker(core.snapshots())
|
||||
));
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
host.start_workers(plan, store.worker_leader_lease(), store.diagnostics())
|
||||
);
|
||||
host.report_startup_ready("Workers");
|
||||
let served = host.serve(api.router()).await;
|
||||
plugin_runtime.shutdown().await;
|
||||
Ok(served?)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! 启动模式共享的基础设施与 Provider 组装,不启动 HTTP 或 Worker
|
||||
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use gateway_admin::ports::provider::ProviderAdminRegistry;
|
||||
use gateway_core::{engine::provider::ProviderRegistry, time::DeploymentTimeZone};
|
||||
use gateway_host::{ConfigError, HostBundle, HostConfig, outbound::HttpClient};
|
||||
use gateway_store::{StoreBundle, StoreConfig};
|
||||
|
||||
use super::BootstrapError;
|
||||
|
||||
pub(super) enum Mode {
|
||||
Server,
|
||||
Command,
|
||||
Help,
|
||||
}
|
||||
|
||||
pub(super) struct Environment {
|
||||
pub(super) host: HostBundle,
|
||||
pub(super) store: StoreBundle,
|
||||
pub(super) timezone: DeploymentTimeZone,
|
||||
pub(super) plugin_cache: PathBuf,
|
||||
pub(super) version: String,
|
||||
pub(super) plugin_http: Arc<HttpClient>,
|
||||
}
|
||||
|
||||
impl Environment {
|
||||
pub(super) async fn initialize(
|
||||
host: HostConfig,
|
||||
store: StoreConfig,
|
||||
mode: Mode,
|
||||
) -> Result<Self, BootstrapError> {
|
||||
let timezone = host.timezone;
|
||||
let store = store.with_timezone(timezone);
|
||||
let plugin_cache = host.runtime_data_dir().join("plugins");
|
||||
let host = match mode {
|
||||
Mode::Server => gateway_host::initialize(host).await?,
|
||||
Mode::Command | Mode::Help => gateway_host::initialize_command_line(host).await?,
|
||||
};
|
||||
host.report_startup_ready("Host");
|
||||
let store = match mode {
|
||||
Mode::Server => gateway_store::initialize(store).await?,
|
||||
Mode::Command => gateway_store::initialize_command_line(store).await?,
|
||||
Mode::Help => gateway_store::initialize_read_only(store).await?,
|
||||
};
|
||||
host.report_startup_ready("Store");
|
||||
let version = host
|
||||
.system_operations()
|
||||
.version()
|
||||
.await
|
||||
.map_err(|_| ConfigError::InvalidField("host.version"))?
|
||||
.version;
|
||||
let plugin_http = Arc::new(HttpClient::new()?);
|
||||
Ok(Self {
|
||||
host,
|
||||
store,
|
||||
timezone,
|
||||
plugin_cache,
|
||||
version,
|
||||
plugin_http,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Providers {
|
||||
pub(super) openai: provider_openai::ProviderBundle,
|
||||
pub(super) xai: provider_xai::ProviderBundle,
|
||||
pub(super) core: ProviderRegistry,
|
||||
pub(super) admin: ProviderAdminRegistry,
|
||||
}
|
||||
|
||||
impl Providers {
|
||||
pub(super) async fn initialize(
|
||||
config: provider_openai::OpenAiConfig,
|
||||
environment: &Environment,
|
||||
) -> Result<Self, BootstrapError> {
|
||||
let ports = environment.store.provider_ports();
|
||||
let openai =
|
||||
provider_openai::initialize(config.with_timezone(environment.timezone), ports.clone())
|
||||
.await?;
|
||||
environment.host.report_startup_ready("OpenAI Provider");
|
||||
let xai = provider_xai::initialize(ports).await?;
|
||||
environment.host.report_startup_ready("xAI Provider");
|
||||
let core = ProviderRegistry::new([openai.core_provider(), xai.core_provider()])?;
|
||||
let admin = ProviderAdminRegistry::new([openai.admin_provider(), xai.admin_provider()])
|
||||
.map_err(|_| gateway_admin::model::AdminError::invalid("Provider 管理身份冲突"))?;
|
||||
Ok(Self {
|
||||
openai,
|
||||
xai,
|
||||
core,
|
||||
admin,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
//! 网关进程组装根。
|
||||
//! 网关进程组装根
|
||||
|
||||
pub mod bootstrap;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! 网关命令行入口,选择启动模式并创建异步运行时
|
||||
|
||||
use std::{
|
||||
env,
|
||||
error::Error,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! docs/architecture.md 依赖 DAG 与生产源码纪律的 workspace 级机器校验。
|
||||
//! docs/architecture.md 依赖 DAG 与生产源码纪律的 workspace 级机器校验
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
@@ -8,7 +8,7 @@ use std::{
|
||||
|
||||
use syn::{Item, visit::Visit};
|
||||
|
||||
/// workspace 成员冻结清单;新增 crate 必须同步扩展本文件的依赖规则。
|
||||
/// workspace 成员冻结清单;新增 crate 必须同步扩展本文件的依赖规则
|
||||
pub(super) const WORKSPACE_MEMBERS: &[&str] = &[
|
||||
"apps/gateway",
|
||||
"apps/plugin-cli",
|
||||
@@ -79,18 +79,25 @@ fn core_value_owners_do_not_depend_on_execution_or_routing() {
|
||||
path if path.starts_with("policy/") => {
|
||||
Some(&["account", "identity", "policy", "validation"])
|
||||
}
|
||||
"settings/values.rs" | "settings/compiled.rs" => {
|
||||
Some(&["account", "concurrency", "identity", "metering", "policy"])
|
||||
}
|
||||
path if path.starts_with("settings/") => Some(&[
|
||||
"account",
|
||||
"concurrency",
|
||||
"identity",
|
||||
"metering",
|
||||
"policy",
|
||||
"settings",
|
||||
]),
|
||||
path if path.starts_with("account/") => Some(&["account", "identity", "validation"]),
|
||||
_ => None,
|
||||
};
|
||||
let Some(allowed) = allowed else { continue };
|
||||
let source = fs::read_to_string(root.join(&relative)).expect("read core source");
|
||||
let syntax = syn::parse_file(&source).expect("parse core source");
|
||||
let mut references = CrateReferences::default();
|
||||
let mut references = CrateReferences::in_module(&relative);
|
||||
references.visit_file(&syntax);
|
||||
for dependency in references.0 {
|
||||
// 相对路径中的 self 指向当前 owner,不是跨 owner 依赖
|
||||
references.owners.remove(&references.module_path[0]);
|
||||
for dependency in references.owners {
|
||||
assert!(
|
||||
allowed.contains(&dependency.as_str()),
|
||||
"{} must not depend on crate::{dependency}",
|
||||
@@ -100,49 +107,155 @@ fn core_value_owners_do_not_depend_on_execution_or_routing() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_owner_paths_form_a_dag() {
|
||||
let root = backend_root().join("crates/gateway-core/src");
|
||||
let mut graph = BTreeMap::<String, BTreeSet<String>>::new();
|
||||
for relative in super::rust_files(&root) {
|
||||
if relative == Path::new("lib.rs") {
|
||||
continue;
|
||||
}
|
||||
let mut references = CrateReferences::in_module(&relative);
|
||||
let owner = references.module_path[0].clone();
|
||||
let source = fs::read_to_string(root.join(&relative)).expect("read core source");
|
||||
references.visit_file(&syn::parse_file(&source).expect("parse core source"));
|
||||
references.owners.remove(&owner);
|
||||
graph.entry(owner).or_default().extend(references.owners);
|
||||
}
|
||||
for owner in graph.keys() {
|
||||
let mut pending = graph[owner].iter().cloned().collect::<Vec<_>>();
|
||||
let mut visited = BTreeSet::new();
|
||||
while let Some(dependency) = pending.pop() {
|
||||
assert_ne!(
|
||||
&dependency, owner,
|
||||
"Core owner dependency cycle reaches {owner}"
|
||||
);
|
||||
if visited.insert(dependency.clone())
|
||||
&& let Some(next) = graph.get(&dependency)
|
||||
{
|
||||
pending.extend(next.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_reference_scan_resolves_groups_reexports_and_relative_paths() {
|
||||
let syntax = syn::parse_file(
|
||||
r#"
|
||||
pub use crate::{engine::AttemptContext, settings::SettingsValues as Values};
|
||||
use super::super::runtime as publication;
|
||||
use self::compiled::Value;
|
||||
// crate::ignored::Comment
|
||||
const EXAMPLE: &str = "crate::ignored::Literal";
|
||||
"#,
|
||||
)
|
||||
.expect("parse reference fixture");
|
||||
let mut references = CrateReferences::in_module(Path::new("routing/snapshot.rs"));
|
||||
references.visit_file(&syntax);
|
||||
assert_eq!(
|
||||
references.owners,
|
||||
BTreeSet::from([
|
||||
"engine".to_owned(),
|
||||
"settings".to_owned(),
|
||||
"runtime".to_owned(),
|
||||
"routing".to_owned(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CrateReferences(BTreeSet<String>);
|
||||
struct CrateReferences {
|
||||
owners: BTreeSet<String>,
|
||||
module_path: Vec<String>,
|
||||
}
|
||||
|
||||
impl<'ast> Visit<'ast> for CrateReferences {
|
||||
fn visit_path(&mut self, path: &'ast syn::Path) {
|
||||
let mut segments = path.segments.iter();
|
||||
if segments
|
||||
.next()
|
||||
.is_some_and(|segment| segment.ident == "crate")
|
||||
&& let Some(owner) = segments.next()
|
||||
{
|
||||
self.0.insert(owner.ident.to_string());
|
||||
}
|
||||
self.record(
|
||||
&path
|
||||
.segments
|
||||
.iter()
|
||||
.map(|part| part.ident.to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
syn::visit::visit_path(self, path);
|
||||
}
|
||||
|
||||
fn visit_item_use(&mut self, item: &'ast syn::ItemUse) {
|
||||
self.use_tree(&item.tree, false);
|
||||
self.use_tree(&item.tree, &mut Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
impl CrateReferences {
|
||||
fn use_tree(&mut self, tree: &syn::UseTree, crate_root: bool) {
|
||||
fn in_module(relative: &Path) -> Self {
|
||||
let mut module = relative
|
||||
.with_extension("")
|
||||
.components()
|
||||
.map(|part| {
|
||||
part.as_os_str()
|
||||
.to_str()
|
||||
.expect("UTF-8 module path")
|
||||
.to_owned()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if module.last().is_some_and(|part| part == "mod") {
|
||||
module.pop();
|
||||
}
|
||||
Self {
|
||||
owners: BTreeSet::new(),
|
||||
module_path: module,
|
||||
}
|
||||
}
|
||||
|
||||
fn record(&mut self, path: &[String]) {
|
||||
let Some(first) = path.first() else { return };
|
||||
let owner = if first == "crate" {
|
||||
path.get(1).cloned()
|
||||
} else if first == "self" || first == "super" {
|
||||
let mut module = self.module_path.clone();
|
||||
let mut rest = path;
|
||||
while rest
|
||||
.first()
|
||||
.is_some_and(|part| part == "super" || part == "self")
|
||||
{
|
||||
if rest[0] == "super" {
|
||||
module.pop();
|
||||
}
|
||||
rest = &rest[1..];
|
||||
}
|
||||
module.first().or_else(|| rest.first()).cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(owner) = owner {
|
||||
self.owners.insert(owner);
|
||||
}
|
||||
}
|
||||
|
||||
fn use_tree(&mut self, tree: &syn::UseTree, prefix: &mut Vec<String>) {
|
||||
match tree {
|
||||
syn::UseTree::Path(path) if crate_root => {
|
||||
self.0.insert(path.ident.to_string());
|
||||
syn::UseTree::Path(path) => {
|
||||
prefix.push(path.ident.to_string());
|
||||
self.use_tree(&path.tree, prefix);
|
||||
prefix.pop();
|
||||
}
|
||||
syn::UseTree::Name(name) if crate_root => {
|
||||
self.0.insert(name.ident.to_string());
|
||||
syn::UseTree::Name(name) => {
|
||||
prefix.push(name.ident.to_string());
|
||||
self.record(prefix);
|
||||
prefix.pop();
|
||||
}
|
||||
syn::UseTree::Rename(rename) if crate_root => {
|
||||
self.0.insert(rename.ident.to_string());
|
||||
syn::UseTree::Rename(rename) => {
|
||||
prefix.push(rename.ident.to_string());
|
||||
self.record(prefix);
|
||||
prefix.pop();
|
||||
}
|
||||
syn::UseTree::Path(path) => self.use_tree(&path.tree, path.ident == "crate"),
|
||||
syn::UseTree::Group(group) => {
|
||||
for item in &group.items {
|
||||
self.use_tree(item, crate_root);
|
||||
self.use_tree(item, prefix);
|
||||
}
|
||||
}
|
||||
syn::UseTree::Glob(_) if crate_root => {
|
||||
self.0.insert("*".to_owned());
|
||||
}
|
||||
_ => {}
|
||||
syn::UseTree::Glob(_) => self.record(prefix),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,7 +291,7 @@ fn gateway_admin_stays_free_of_infrastructure_dependencies() {
|
||||
);
|
||||
}
|
||||
|
||||
/// workspace 包名到冻结成员路径的映射。
|
||||
/// workspace 包名到冻结成员路径的映射
|
||||
const PACKAGE_TO_MEMBER: &[(&str, &str)] = &[
|
||||
("codex-proxy-rs", "apps/gateway"),
|
||||
("codex-proxy-plugin-cli", "apps/plugin-cli"),
|
||||
@@ -194,7 +307,7 @@ const PACKAGE_TO_MEMBER: &[(&str, &str)] = &[
|
||||
("provider-xai", "crates/providers/xai"),
|
||||
];
|
||||
|
||||
/// SDK 与 Adapter/provider 根门面的稳定合同模块;任何增减都必须同步完成边界审计。
|
||||
/// SDK 与 Adapter/provider 根门面的稳定合同模块;任何增减都必须同步完成边界审计
|
||||
const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
|
||||
("crates/gateway-plugin/sdk", &["call", "client"]),
|
||||
("crates/gateway-plugin/runtime", &[]),
|
||||
@@ -221,16 +334,16 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
|
||||
"crates/providers/openai",
|
||||
&["config", "credential", "transport"],
|
||||
),
|
||||
("crates/providers/xai", &["credential", "transport"]),
|
||||
("crates/providers/xai", &["transport"]),
|
||||
];
|
||||
|
||||
/// 不对应单一生产模块、而是校验 crate/workspace 整体契约的根级测试场景。
|
||||
/// 不对应单一生产模块、而是校验 crate/workspace 整体契约的根级测试场景
|
||||
const ROOT_TEST_SCENARIOS: &[(&str, &[&str])] = &[
|
||||
("apps/gateway", &["architecture"]),
|
||||
("crates/gateway-api", &["architecture"]),
|
||||
];
|
||||
|
||||
/// 冻结的 workspace 内部运行时依赖边;新增/删除任何边都必须同步本表。
|
||||
/// 冻结的 workspace 内部运行时依赖边;新增/删除任何边都必须同步本表
|
||||
const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[
|
||||
("codex-proxy-plugin-cli", "gateway-plugin-sdk"),
|
||||
("codex-proxy-rs", "gateway-admin"),
|
||||
@@ -475,7 +588,7 @@ fn integration_tests_mirror_production_module_tree() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rust 子进程 fixture 是 Cargo 的独立 crate 根,不能要求它由测试模块再次声明。
|
||||
/// Rust 子进程 fixture 是 Cargo 的独立 crate 根,不能要求它由测试模块再次声明
|
||||
fn test_source_roots() -> BTreeMap<PathBuf, Vec<String>> {
|
||||
let metadata = cargo_metadata_json();
|
||||
let mut roots = BTreeMap::<PathBuf, Vec<String>>::new();
|
||||
@@ -529,7 +642,7 @@ fn root_test_scenario_allowed(member: &str, module: &Path) -> bool {
|
||||
|
||||
fn assert_module_tree(root: &Path, crate_roots: &[&str]) {
|
||||
let files = super::rust_files(root);
|
||||
// 同一父模块的所有子文件复用一次语法解析,计数仍保留重复声明检查。
|
||||
// 同一父模块的所有子文件复用一次语法解析,计数仍保留重复声明检查
|
||||
let mut declarations = BTreeMap::<PathBuf, BTreeMap<String, usize>>::new();
|
||||
for relative in &files {
|
||||
if crate_roots
|
||||
@@ -649,7 +762,7 @@ fn cargo_metadata_json() -> serde_json::Value {
|
||||
serde_json::from_slice(&output.stdout).expect("parse cargo metadata")
|
||||
}
|
||||
|
||||
/// 提取成员 `[dependencies]` 段内声明的依赖名;段落以下一个 `[` 表头结束。
|
||||
/// 提取成员 `[dependencies]` 段内声明的依赖名;段落以下一个 `[` 表头结束
|
||||
fn dependency_names(member: &str) -> Vec<String> {
|
||||
let manifest = fs::read_to_string(backend_root().join(member).join("Cargo.toml"))
|
||||
.expect("read member manifest");
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! 验证网关基础命令无需加载配置,并拒绝非法或多余参数
|
||||
|
||||
use std::{fs, process::Command};
|
||||
|
||||
#[test]
|
||||
fn basic_cli_options_do_not_load_configuration_and_reject_unexpected_arguments() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(directory.path().join("deploy")).unwrap();
|
||||
fs::write(directory.path().join("deploy/config.yaml"), "invalid: [").unwrap();
|
||||
for flag in ["--help", "-h", "help", "--version", "-V"] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
|
||||
.arg(flag)
|
||||
.current_dir(directory.path())
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{flag}");
|
||||
assert!(!output.stdout.is_empty());
|
||||
assert!(output.stderr.is_empty());
|
||||
}
|
||||
for args in [
|
||||
vec!["unknown"],
|
||||
vec!["--help", "extra"],
|
||||
vec!["serve", "extra"],
|
||||
] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
|
||||
.args(args)
|
||||
.current_dir(directory.path())
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(!String::from_utf8_lossy(&output.stderr).contains("configuration"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_utf8_command_is_not_interpreted_as_serve() {
|
||||
use std::os::unix::ffi::OsStringExt as _;
|
||||
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
|
||||
.arg(std::ffi::OsString::from_vec(vec![0xff]))
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("command must be UTF-8"));
|
||||
}
|
||||
+44
-206
@@ -1,207 +1,16 @@
|
||||
//! 验证顶层配置加载、路径解析、显式覆盖与敏感字段脱敏
|
||||
|
||||
use std::{fs, process::Command};
|
||||
|
||||
use codex_proxy_rs::bootstrap::GatewayConfig;
|
||||
use gateway_host::LoadableConfig;
|
||||
|
||||
const CONFIG_EXAMPLE: &str = include_str!("../../../../deploy/config.example.yaml");
|
||||
const CONFIG_EXAMPLE: &str = include_str!("../../../../../deploy/config.example.yaml");
|
||||
const POSTGRES_PASSWORD: &str = "111111111111111111111111111111111111111111111111";
|
||||
const REDIS_PASSWORD: &str = "222222222222222222222222222222222222222222222222";
|
||||
const ADMIN_PASSWORD: &str = "test-admin-password";
|
||||
const TOPOLOGY_CHILD_ENV: &str = "CPR_TEST_TOPOLOGY_CHILD";
|
||||
|
||||
#[test]
|
||||
fn basic_cli_options_do_not_load_configuration_and_reject_unexpected_arguments() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(directory.path().join("deploy")).unwrap();
|
||||
fs::write(directory.path().join("deploy/config.yaml"), "invalid: [").unwrap();
|
||||
for flag in ["--help", "-h", "help", "--version", "-V"] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
|
||||
.arg(flag)
|
||||
.current_dir(directory.path())
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{flag}");
|
||||
assert!(!output.stdout.is_empty());
|
||||
assert!(output.stderr.is_empty());
|
||||
}
|
||||
for args in [
|
||||
vec!["unknown"],
|
||||
vec!["--help", "extra"],
|
||||
vec!["serve", "extra"],
|
||||
] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
|
||||
.args(args)
|
||||
.current_dir(directory.path())
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(!String::from_utf8_lossy(&output.stderr).contains("configuration"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_utf8_command_is_not_interpreted_as_serve() {
|
||||
use std::os::unix::ffi::OsStringExt as _;
|
||||
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
|
||||
.arg(std::ffi::OsString::from_vec(vec![0xff]))
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("command must be UTF-8"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() {
|
||||
use gateway_admin::ports::proxy::ProxyProbe;
|
||||
use gateway_core::account::OutboundProxy;
|
||||
use gateway_host::proxy_probe::HttpProxyProbe;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
|
||||
use tokio_rustls::{
|
||||
TlsAcceptor,
|
||||
rustls::{
|
||||
ServerConfig,
|
||||
pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject as _},
|
||||
},
|
||||
};
|
||||
|
||||
const CHILD_ENV: &str = "CPR_TEST_PROXY_TLS_DIRECTORY";
|
||||
let Ok(directory) = std::env::var(CHILD_ENV) else {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
generate_proxy_test_certificates(directory.path());
|
||||
// 使用子进程隔离环境变量,避免并行测试读取到临时 CA 配置。
|
||||
for ca_env in ["CODEX_CA_CERTIFICATE", "SSL_CERT_FILE"] {
|
||||
let mut child = Command::new(std::env::current_exe().unwrap());
|
||||
child
|
||||
.args([
|
||||
"--exact",
|
||||
"bootstrap::proxy_probe_should_use_provider_custom_ca_for_https_proxies",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_ENV, directory.path())
|
||||
.env_remove("CODEX_CA_CERTIFICATE")
|
||||
.env_remove("SSL_CERT_FILE")
|
||||
.env(ca_env, directory.path().join("ca.pem"));
|
||||
if ca_env == "CODEX_CA_CERTIFICATE" {
|
||||
child.env(
|
||||
"SSL_CERT_FILE",
|
||||
directory.path().join("missing-fallback.pem"),
|
||||
);
|
||||
}
|
||||
let output = child.output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{ca_env}: {}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
return;
|
||||
};
|
||||
provider_openai::ensure_rustls_provider();
|
||||
let directory = std::path::Path::new(&directory);
|
||||
let certificate = CertificateDer::from_pem_file(directory.join("server.pem")).unwrap();
|
||||
let key = PrivateKeyDer::from_pem_file(directory.join("server.key")).unwrap();
|
||||
let acceptor = TlsAcceptor::from(Arc::new(
|
||||
ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(vec![certificate], key)
|
||||
.unwrap(),
|
||||
));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let proxy =
|
||||
OutboundProxy::parse(&format!("https://{}", listener.local_addr().unwrap())).unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (socket, _) = listener.accept().await.unwrap();
|
||||
let mut stream = acceptor.accept(socket).await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
while !request.ends_with(b"\r\n\r\n") {
|
||||
assert!(request.len() < 8192);
|
||||
request.push(stream.read_u8().await.unwrap());
|
||||
}
|
||||
assert!(request.starts_with(b"GET http://unresolvable.invalid/ip "));
|
||||
let body = "{\"ip\":\"203.0.113.8\"}";
|
||||
stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
|
||||
stream.shutdown().await.unwrap();
|
||||
});
|
||||
let probe = HttpProxyProbe::new("http://unresolvable.invalid/ip")
|
||||
.with_client_builder(provider_openai::build_reqwest_client_with_custom_ca);
|
||||
let result = probe.test(&proxy, false).await;
|
||||
assert!(result.success, "{}", result.message);
|
||||
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
|
||||
tokio::time::timeout(Duration::from_secs(5), server)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn generate_proxy_test_certificates(directory: &std::path::Path) {
|
||||
let openssl = |args: &[&str]| {
|
||||
let output = Command::new("openssl")
|
||||
.args(args)
|
||||
.current_dir(directory)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
};
|
||||
openssl(&[
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
"ca.key",
|
||||
"-out",
|
||||
"ca.pem",
|
||||
"-days",
|
||||
"1",
|
||||
"-subj",
|
||||
"/CN=Proxy Test CA",
|
||||
]);
|
||||
openssl(&[
|
||||
"req",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
"server.key",
|
||||
"-out",
|
||||
"server.csr",
|
||||
"-subj",
|
||||
"/CN=localhost",
|
||||
]);
|
||||
fs::write(directory.join("extensions"), "subjectAltName=IP:127.0.0.1\nbasicConstraints=critical,CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n").unwrap();
|
||||
openssl(&[
|
||||
"x509",
|
||||
"-req",
|
||||
"-in",
|
||||
"server.csr",
|
||||
"-CA",
|
||||
"ca.pem",
|
||||
"-CAkey",
|
||||
"ca.key",
|
||||
"-CAcreateserial",
|
||||
"-out",
|
||||
"server.pem",
|
||||
"-days",
|
||||
"1",
|
||||
"-extfile",
|
||||
"extensions",
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_load_complete_terminal_example() {
|
||||
parse_config(&valid_config()).expect("terminal config example");
|
||||
@@ -220,7 +29,7 @@ fn config_loader_should_resolve_paths_relative_to_config_file() {
|
||||
fn config_loader_should_share_resolved_assets_with_system_update() {
|
||||
const CHILD_ENV: &str = "CPR_TEST_UPDATE_ASSETS_CHILD";
|
||||
let Ok(case) = std::env::var(CHILD_ENV) else {
|
||||
// 使用子进程覆盖无环境变量、Docker 路径和相对路径,避免污染并行测试。
|
||||
// 使用子进程覆盖无环境变量、Docker 路径和相对路径,避免污染并行测试
|
||||
for (case, web_dist) in [
|
||||
("binary", None),
|
||||
("docker", Some("/app/web/dist")),
|
||||
@@ -230,7 +39,7 @@ fn config_loader_should_share_resolved_assets_with_system_update() {
|
||||
child
|
||||
.args([
|
||||
"--exact",
|
||||
"bootstrap::config_loader_should_share_resolved_assets_with_system_update",
|
||||
"bootstrap::config::config_loader_should_share_resolved_assets_with_system_update",
|
||||
])
|
||||
.env(CHILD_ENV, case)
|
||||
.env_remove("CPR_WEB_DIST_DIR");
|
||||
@@ -267,11 +76,6 @@ fn config_loader_should_reject_missing_runtime_data_dir() {
|
||||
assert!(parse_config(&config).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_inject_connection_passwords_into_urls() {
|
||||
parse_config(&valid_config()).expect("Store validates password injection into both URLs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_apply_only_explicit_topology_overrides() {
|
||||
let invalid = valid_config()
|
||||
@@ -292,7 +96,7 @@ fn config_loader_should_apply_only_explicit_topology_overrides() {
|
||||
let status = Command::new(std::env::current_exe().expect("current test executable"))
|
||||
.args([
|
||||
"--exact",
|
||||
"bootstrap::config_loader_should_apply_only_explicit_topology_overrides",
|
||||
"bootstrap::config::config_loader_should_apply_only_explicit_topology_overrides",
|
||||
])
|
||||
.env(TOPOLOGY_CHILD_ENV, "1")
|
||||
.env("CPR_SERVER_HOST", "127.0.0.1")
|
||||
@@ -374,7 +178,15 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
|
||||
gateway_host::load_config::<GatewayConfig>().expect("startup configuration");
|
||||
return;
|
||||
}
|
||||
for case in ["normal", "unused", "missing"] {
|
||||
for case in [
|
||||
"normal",
|
||||
"unused",
|
||||
"missing",
|
||||
"timezoneEmpty",
|
||||
"timezoneInvalid",
|
||||
"timezoneNull",
|
||||
"timezoneType",
|
||||
] {
|
||||
let mut document = valid_config_document();
|
||||
if case == "unused" {
|
||||
document["openai"]["wire_profile"]["location"] = serde_json::json!(UNUSED_SECRET);
|
||||
@@ -384,6 +196,14 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
|
||||
.unwrap()
|
||||
.remove("port");
|
||||
}
|
||||
if case.starts_with("timezone") {
|
||||
document["host"]["timezone"] = match case {
|
||||
"timezoneEmpty" => serde_json::json!(""),
|
||||
"timezoneInvalid" => serde_json::json!(UNUSED_SECRET),
|
||||
"timezoneNull" => serde_json::json!(null),
|
||||
_ => serde_json::json!(8),
|
||||
};
|
||||
}
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(directory.path().join("deploy")).unwrap();
|
||||
fs::write(
|
||||
@@ -394,7 +214,7 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
|
||||
let output = Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"bootstrap::config_loader_should_report_startup_configuration_diagnostics",
|
||||
"bootstrap::config::config_loader_should_report_startup_configuration_diagnostics",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_ENV, "1")
|
||||
@@ -404,7 +224,7 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(
|
||||
output.status.success(),
|
||||
case != "missing",
|
||||
case != "missing" && !case.starts_with("timezone"),
|
||||
"{case}: {stderr}"
|
||||
);
|
||||
match case {
|
||||
@@ -413,6 +233,9 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
|
||||
"{stderr}"
|
||||
),
|
||||
"missing" => assert!(stderr.contains("host.listen.port"), "{stderr}"),
|
||||
case if case.starts_with("timezone") => {
|
||||
assert!(stderr.contains("host.timezone"), "{stderr}")
|
||||
}
|
||||
_ => assert!(!stderr.contains("警告"), "{stderr}"),
|
||||
}
|
||||
assert!(!stderr.contains(UNUSED_SECRET), "{stderr}");
|
||||
@@ -553,7 +376,7 @@ fn valid_config() -> String {
|
||||
}
|
||||
|
||||
fn valid_config_document() -> serde_json::Value {
|
||||
// 按字段修改样例,避免注释或排版变化让测试输入悄悄失效;JSON 仍可由 YAML 文件入口加载。
|
||||
// 按字段修改样例,避免注释或排版变化让测试输入悄悄失效;JSON 仍可由 YAML 文件入口加载
|
||||
config::Config::builder()
|
||||
.add_source(config::File::from_str(
|
||||
&valid_config(),
|
||||
@@ -580,3 +403,18 @@ fn parse_config(config: &str) -> Result<(GatewayConfig, tempfile::TempDir), Stri
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok((config, directory))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deployment_timezone_defaults_and_valid_override_are_loaded() {
|
||||
let mut document = valid_config_document();
|
||||
document["host"].as_object_mut().unwrap().remove("timezone");
|
||||
let (_config, _directory) = parse_config(&document.to_string()).unwrap();
|
||||
let host: gateway_host::config::HostConfig =
|
||||
serde_json::from_value(document["host"].clone()).unwrap();
|
||||
assert_eq!(host.timezone.name(), "Asia/Shanghai");
|
||||
document["host"]["timezone"] = serde_json::json!("Asia/Kathmandu");
|
||||
let (_config, _directory) = parse_config(&document.to_string()).unwrap();
|
||||
let host: gateway_host::config::HostConfig =
|
||||
serde_json::from_value(document["host"].clone()).unwrap();
|
||||
assert_eq!(host.timezone.name(), "Asia/Kathmandu");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! 网关启动配置、命令行与服务组装的测试入口
|
||||
|
||||
mod command;
|
||||
mod config;
|
||||
mod server;
|
||||
@@ -0,0 +1,148 @@
|
||||
//! 验证服务组装为 HTTPS 代理探测传递自定义 CA
|
||||
|
||||
use std::{fs, process::Command};
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() {
|
||||
use gateway_admin::ports::proxy::ProxyProbe;
|
||||
use gateway_core::account::OutboundProxy;
|
||||
use gateway_host::proxy_probe::HttpProxyProbe;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
|
||||
use tokio_rustls::{
|
||||
TlsAcceptor,
|
||||
rustls::{
|
||||
ServerConfig,
|
||||
pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject as _},
|
||||
},
|
||||
};
|
||||
|
||||
const CHILD_ENV: &str = "CPR_TEST_PROXY_TLS_DIRECTORY";
|
||||
let Ok(directory) = std::env::var(CHILD_ENV) else {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
generate_proxy_test_certificates(directory.path());
|
||||
// 使用子进程隔离环境变量,避免并行测试读取到临时 CA 配置
|
||||
for ca_env in ["CODEX_CA_CERTIFICATE", "SSL_CERT_FILE"] {
|
||||
let mut child = Command::new(std::env::current_exe().unwrap());
|
||||
child
|
||||
.args([
|
||||
"--exact",
|
||||
"bootstrap::server::proxy_probe_should_use_provider_custom_ca_for_https_proxies",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_ENV, directory.path())
|
||||
.env_remove("CODEX_CA_CERTIFICATE")
|
||||
.env_remove("SSL_CERT_FILE")
|
||||
.env(ca_env, directory.path().join("ca.pem"));
|
||||
if ca_env == "CODEX_CA_CERTIFICATE" {
|
||||
child.env(
|
||||
"SSL_CERT_FILE",
|
||||
directory.path().join("missing-fallback.pem"),
|
||||
);
|
||||
}
|
||||
let output = child.output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{ca_env}: {}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
return;
|
||||
};
|
||||
provider_openai::ensure_rustls_provider();
|
||||
let directory = std::path::Path::new(&directory);
|
||||
let certificate = CertificateDer::from_pem_file(directory.join("server.pem")).unwrap();
|
||||
let key = PrivateKeyDer::from_pem_file(directory.join("server.key")).unwrap();
|
||||
let acceptor = TlsAcceptor::from(Arc::new(
|
||||
ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(vec![certificate], key)
|
||||
.unwrap(),
|
||||
));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let proxy =
|
||||
OutboundProxy::parse(&format!("https://{}", listener.local_addr().unwrap())).unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (socket, _) = listener.accept().await.unwrap();
|
||||
let mut stream = acceptor.accept(socket).await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
while !request.ends_with(b"\r\n\r\n") {
|
||||
assert!(request.len() < 8192);
|
||||
request.push(stream.read_u8().await.unwrap());
|
||||
}
|
||||
assert!(request.starts_with(b"GET http://unresolvable.invalid/ip "));
|
||||
let body = "{\"ip\":\"203.0.113.8\"}";
|
||||
stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
|
||||
stream.shutdown().await.unwrap();
|
||||
});
|
||||
let probe = HttpProxyProbe::new("http://unresolvable.invalid/ip")
|
||||
.with_client_builder(provider_openai::build_reqwest_client_with_custom_ca);
|
||||
let result = probe.test(&proxy, false).await;
|
||||
assert!(result.success, "{}", result.message);
|
||||
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
|
||||
tokio::time::timeout(Duration::from_secs(5), server)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn generate_proxy_test_certificates(directory: &std::path::Path) {
|
||||
let openssl = |args: &[&str]| {
|
||||
let output = Command::new("openssl")
|
||||
.args(args)
|
||||
.current_dir(directory)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
};
|
||||
openssl(&[
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
"ca.key",
|
||||
"-out",
|
||||
"ca.pem",
|
||||
"-days",
|
||||
"1",
|
||||
"-subj",
|
||||
"/CN=Proxy Test CA",
|
||||
]);
|
||||
openssl(&[
|
||||
"req",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
"server.key",
|
||||
"-out",
|
||||
"server.csr",
|
||||
"-subj",
|
||||
"/CN=localhost",
|
||||
]);
|
||||
fs::write(directory.join("extensions"), "subjectAltName=IP:127.0.0.1\nbasicConstraints=critical,CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n").unwrap();
|
||||
openssl(&[
|
||||
"x509",
|
||||
"-req",
|
||||
"-in",
|
||||
"server.csr",
|
||||
"-CA",
|
||||
"ca.pem",
|
||||
"-CAkey",
|
||||
"ca.key",
|
||||
"-CAcreateserial",
|
||||
"-out",
|
||||
"server.pem",
|
||||
"-days",
|
||||
"1",
|
||||
"-extfile",
|
||||
"extensions",
|
||||
]);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
//! 网关组合根测试入口,检查源码模块纪律与冻结的文件边界
|
||||
|
||||
mod architecture;
|
||||
mod bootstrap;
|
||||
|
||||
// 组合根是否混入业务策略由代码审查判断,行数和标识符/注释关键词不能证明职责越界。
|
||||
// 依赖 DAG、公开模块面和模块镜像继续由 architecture 检查,源码纪律由下方测试维护。
|
||||
// 组合根是否混入业务策略由代码审查判断,行数和标识符/注释关键词不能证明职责越界
|
||||
// 依赖 DAG、公开模块面和模块镜像继续由 architecture 检查,源码纪律由下方测试维护
|
||||
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
@@ -18,7 +20,12 @@ fn app_tree_matches_frozen_terminal_manifest() {
|
||||
assert_eq!(
|
||||
rust_files(&root.join("src")),
|
||||
BTreeSet::from([
|
||||
PathBuf::from("bootstrap.rs"),
|
||||
PathBuf::from("bootstrap/command.rs"),
|
||||
PathBuf::from("bootstrap/config.rs"),
|
||||
PathBuf::from("bootstrap/mod.rs"),
|
||||
PathBuf::from("bootstrap/plugins.rs"),
|
||||
PathBuf::from("bootstrap/server.rs"),
|
||||
PathBuf::from("bootstrap/startup.rs"),
|
||||
PathBuf::from("lib.rs"),
|
||||
PathBuf::from("main.rs"),
|
||||
]),
|
||||
@@ -27,7 +34,10 @@ fn app_tree_matches_frozen_terminal_manifest() {
|
||||
rust_files(&root.join("tests")),
|
||||
BTreeSet::from([
|
||||
PathBuf::from("architecture.rs"),
|
||||
PathBuf::from("bootstrap.rs"),
|
||||
PathBuf::from("bootstrap/command.rs"),
|
||||
PathBuf::from("bootstrap/config.rs"),
|
||||
PathBuf::from("bootstrap/mod.rs"),
|
||||
PathBuf::from("bootstrap/server.rs"),
|
||||
PathBuf::from("main.rs"),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
从仓库根目录安装到本地工具目录:
|
||||
|
||||
```bash
|
||||
cargo install --locked --path backend/apps/plugin-cli --root /path/to/local-tools
|
||||
cargo +1.97.0 install --locked --path backend/apps/plugin-cli --root /path/to/local-tools
|
||||
```
|
||||
|
||||
将 `/path/to/local-tools/bin` 加入 `PATH`,或使用可执行文件的完整路径
|
||||
将 `/path/to/local-tools/bin` 加入 `PATH`,或使用可执行文件的完整路径;工具链与宿主 [验证命令](../../../docs/architecture.md#验证命令)一致
|
||||
|
||||
## 打包
|
||||
|
||||
@@ -28,11 +28,8 @@ cpr-plugin package \
|
||||
|
||||
资源映射相对于作者清单所在目录解析。输出包括完整插件 ID、版本和目标 triple 命名的 `.tar.gz` 及 `.sha256`;归档根目录直接包含 `plugin.json`、可执行文件和已声明资源,不包含开发依赖
|
||||
|
||||
作者清单可省略普通贡献项的 `id`、`version` 和固定阶段;`middleware` 必须显式声明版本 `3`,
|
||||
并从 `http`、`websocket`、`service`、`request`、`attempt` 中选择挂载阶段。
|
||||
CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共用规范化入口;归档中的
|
||||
`plugin.json` 写入补全后的贡献项字段,不保留另一份作者声明。
|
||||
完整规则见 [SDK 清单](../../crates/gateway-plugin/sdk/docs/manifest.md)
|
||||
作者清单的省略规则、能力版本和挂载阶段见 [SDK 扩展项声明](../../crates/gateway-plugin/sdk/docs/manifest.md#扩展项简写)。
|
||||
CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共用规范化入口,归档只保留补全字段后的 `plugin.json`
|
||||
|
||||
支持 `x86_64-unknown-linux-gnu`、`aarch64-unknown-linux-gnu` 和 `aarch64-apple-darwin`。
|
||||
完整开发示例在独立仓库 `codex-proxy-plugins` 的 `examples/workbench`,可通过[子模块](../../../docs/development.md#源码联调)检出;本工具不负责源码构建、上传或启用插件
|
||||
@@ -48,6 +45,6 @@ CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共
|
||||
Rust 包名为 `codex-proxy-plugin-cli`,位于宿主的 `backend` workspace,共享工具链、锁文件和检查规则;内部仅依赖 `gateway-plugin-sdk`
|
||||
|
||||
```bash
|
||||
cargo run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli -- package --help
|
||||
cargo +1.97.0 run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help
|
||||
RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked
|
||||
```
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! 插件打包命令行,校验输入资源并生成带摘要的平台归档
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs::{self, File, OpenOptions},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! 插件打包命令行的参数、归档校验与输出行为测试
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs::{self, File},
|
||||
@@ -128,7 +130,7 @@ fn rejects_duplicate_resource_prefixes() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packages_only_generated_resources_with_v3_metadata() {
|
||||
fn packages_only_generated_resources_with_current_metadata() {
|
||||
assert_packaged_resources("assets/icon.png", "image/png", b"png");
|
||||
}
|
||||
|
||||
@@ -248,7 +250,7 @@ fn source_manifest() -> &'static [u8] {
|
||||
"main":"bin/plugin",
|
||||
"runtime":"trustedProcess",
|
||||
"contributes":{
|
||||
"middleware":{"version":3,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
|
||||
"middleware":{"version":4,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
|
||||
"management":{}
|
||||
},
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ futures = "0.3.32"
|
||||
gateway-core = { path = "../gateway-core" }
|
||||
heck = "0.5.0"
|
||||
hex = "0.4.3"
|
||||
hkdf = "0.13.0"
|
||||
rand_core = { version = "0.6.4", features = ["getrandom"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
|
||||
@@ -36,9 +37,9 @@ chrono-tz = "0.10"
|
||||
tokio = { version = "1.52", features = ["rt", "time", "macros", "sync"] }
|
||||
|
||||
[dev-dependencies]
|
||||
prettyplease = "0.2"
|
||||
prettyplease = "0.3"
|
||||
quote = "1"
|
||||
syn = { version = "2", features = ["full", "visit-mut"] }
|
||||
syn = { version = "3", features = ["full", "visit-mut"] }
|
||||
tempfile = "3"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "test-util"] }
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 备份纯策略与 Worker 任务。
|
||||
//! 备份纯策略与 Worker 任务
|
||||
|
||||
pub mod policy;
|
||||
pub mod task;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 备份纯策略:Cron 推进与保留候选。
|
||||
//! 备份纯策略:Cron 推进与保留候选
|
||||
//!
|
||||
//! 本模块不持有任何端口或对象,是 `BackupService` 与 `BackupTask` 共用的纯函数集。
|
||||
//! 本模块不持有任何端口或对象,是 `BackupService` 与 `BackupTask` 共用的纯函数集
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
@@ -10,10 +10,11 @@ use cron::Schedule;
|
||||
|
||||
use crate::model::backup::{BackupError, BackupRecord, BackupStatus, code};
|
||||
|
||||
/// 向历史回溯的最大范围(分钟)。覆盖 Feb 29 + 特定星期等罕见组合的最长间隔。
|
||||
/// 向历史回溯的最大范围(分钟)
|
||||
/// 覆盖 Feb 29 + 特定星期等罕见组合的最长间隔
|
||||
const MAX_BACKDATE_MINUTES: i64 = 40 * 366 * 24 * 60;
|
||||
|
||||
/// 已解析并校验的备份计划。
|
||||
/// 已解析并校验的备份计划
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BackupSchedule {
|
||||
schedule: Schedule,
|
||||
@@ -21,43 +22,44 @@ pub struct BackupSchedule {
|
||||
}
|
||||
|
||||
impl BackupSchedule {
|
||||
/// 解析并校验 5 段 Cron 与 IANA 时区。
|
||||
/// 按部署时区解析并校验 5 段 Cron
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Cron 非法或时区不是 IANA 名称时返回 [`BackupError`]。
|
||||
pub fn parse(cron_expression: &str, schedule_timezone: &str) -> Result<Self, BackupError> {
|
||||
/// Cron 非法时返回 [`BackupError`]
|
||||
pub fn parse(
|
||||
cron_expression: &str,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> Result<Self, BackupError> {
|
||||
let expression = format!("0 {cron_expression}");
|
||||
let schedule = Schedule::from_str(&expression).map_err(|_| {
|
||||
BackupError::new(code::INVALID_CRON, "Cron 表达式必须是 5 段格式".to_owned())
|
||||
})?;
|
||||
let timezone = schedule_timezone.parse::<Tz>().map_err(|_| {
|
||||
BackupError::new(code::INVALID_TIMEZONE, "时区必须是 IANA 名称".to_owned())
|
||||
})?;
|
||||
Ok(Self { schedule, timezone })
|
||||
Ok(Self {
|
||||
schedule,
|
||||
timezone: timezone.iana(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 返回最接近 `at`(含 `at` 所在分钟)的最近一次触发时间。
|
||||
/// 返回最接近 `at`(含 `at` 所在分钟)的最近一次触发时间
|
||||
///
|
||||
/// 若过去 `MAX_BACKDATE_MINUTES` 内没有任何触发(极罕见的稀疏计划或超长停机),
|
||||
/// 返回 `None`,调用方不应为历史点补任务。
|
||||
/// 返回 `None`,调用方不应为历史点补任务
|
||||
#[must_use]
|
||||
pub fn last_firing_at_or_before(&self, at: DateTime<Utc>) -> Option<DateTime<Utc>> {
|
||||
let high_min = at.timestamp().div_euclid(60);
|
||||
let low_min = high_min.saturating_sub(MAX_BACKDATE_MINUTES);
|
||||
|
||||
// 检查窗口内是否存在触发;不存在则直接返回 None。
|
||||
// 检查窗口内是否存在触发;不存在则直接返回 None
|
||||
let low_instant = instant(low_min, self.timezone)?;
|
||||
if self
|
||||
.schedule
|
||||
.after(&low_instant)
|
||||
.next()
|
||||
.next_after(low_instant.to_utc())
|
||||
.is_none_or(|firing| firing.timestamp() > high_min * 60 + 59)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// 二分查找最小的 u(分钟)使 after(u) > high;prev = after(u - 1 分钟)。
|
||||
// 二分查找最小的 u(分钟)使 after(u) > high;prev = after(u - 1 分钟)
|
||||
let mut lo = low_min;
|
||||
let mut hi = high_min;
|
||||
while lo < hi {
|
||||
@@ -76,54 +78,56 @@ impl BackupSchedule {
|
||||
.and_then(|firing| Utc.timestamp_opt(firing * 60, 0).single())
|
||||
}
|
||||
|
||||
/// 返回第一个严格晚于 `at` 的触发时间。
|
||||
/// 返回第一个严格晚于 `at` 的触发时间
|
||||
#[must_use]
|
||||
pub fn next_after(&self, at: DateTime<Utc>) -> Option<DateTime<Utc>> {
|
||||
let local = at.with_timezone(&self.timezone);
|
||||
self.schedule
|
||||
.after(&local)
|
||||
.next()
|
||||
.map(|firing| firing.with_timezone(&Utc))
|
||||
let local = at.with_timezone(&self.timezone).naive_local().and_utc();
|
||||
self.schedule.after(&local).find_map(|firing| {
|
||||
let instant = self
|
||||
.timezone
|
||||
.from_local_datetime(&firing.naive_utc())
|
||||
.earliest()?
|
||||
.to_utc();
|
||||
(instant > at).then_some(instant)
|
||||
})
|
||||
}
|
||||
|
||||
/// `after(u 分钟)` 返回的第一个触发时间(分钟索引),用于二分搜索。
|
||||
/// `after(u 分钟)` 返回的第一个触发时间(分钟索引),用于二分搜索
|
||||
fn firing_after(&self, minute_index: i64) -> Option<i64> {
|
||||
let instant = instant(minute_index, self.timezone)?;
|
||||
self.schedule
|
||||
.after(&instant)
|
||||
.next()
|
||||
let instant = instant(minute_index, self.timezone)?.to_utc();
|
||||
self.next_after(instant)
|
||||
.map(|firing| firing.timestamp().div_euclid(60))
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造指定分钟索引(UTC epoch 分钟)在目标时区下的 `DateTime`。
|
||||
/// 构造指定分钟索引(UTC epoch 分钟)在目标时区下的 `DateTime`
|
||||
fn instant(minute_index: i64, timezone: Tz) -> Option<DateTime<Tz>> {
|
||||
timezone.timestamp_opt(minute_index * 60, 0).single()
|
||||
}
|
||||
|
||||
/// 一条记录被纳入删除流程的原因。
|
||||
/// 一条记录被纳入删除流程的原因
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RetentionReason {
|
||||
/// 超过 `retention_days`。
|
||||
/// 超过 `retention_days`
|
||||
Days,
|
||||
/// 超过 `retention_count`。
|
||||
/// 超过 `retention_count`
|
||||
Count,
|
||||
}
|
||||
|
||||
/// 保留决策。
|
||||
/// 保留决策
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RetentionDecision {
|
||||
pub record_id: String,
|
||||
pub reason: RetentionReason,
|
||||
}
|
||||
|
||||
/// 从按完成时间倒序的 completed 计划备份中决定纳入删除流程的记录。
|
||||
/// 从按完成时间倒序的 completed 计划备份中决定纳入删除流程的记录
|
||||
///
|
||||
/// 自动保留规则:
|
||||
/// - `retentionDays = 0` 时不按天数清理;`retentionCount = 0` 时不按份数清理。
|
||||
/// - 自动计划备份满足任一启用阈值即可进入删除。
|
||||
/// - 无论阈值如何,至少保留最近一个成功的计划备份(倒序下标 0)。
|
||||
/// - 手动备份不进入本规则;手工/计划记录各自的 `expires_at` 由到期扫描处理。
|
||||
/// - `retentionDays = 0` 时不按天数清理;`retentionCount = 0` 时不按份数清理
|
||||
/// - 自动计划备份满足任一启用阈值即可进入删除
|
||||
/// - 无论阈值如何,至少保留最近一个成功的计划备份(倒序下标 0)
|
||||
/// - 手动备份不进入本规则;手工/计划记录各自的 `expires_at` 由到期扫描处理
|
||||
#[must_use]
|
||||
pub fn decide_retention(
|
||||
retention_days: u32,
|
||||
@@ -136,7 +140,7 @@ pub fn decide_retention(
|
||||
if record.status != BackupStatus::Completed {
|
||||
continue;
|
||||
}
|
||||
// 最近一个成功的计划备份永远保留。
|
||||
// 最近一个成功的计划备份永远保留
|
||||
if index == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Backup Worker 贡献:单个可取消 `DaemonTask` 承担调度、执行、删除收敛与保留清理。
|
||||
//! Backup Worker 贡献:单个可取消 `DaemonTask` 承担调度、执行、删除收敛与保留清理
|
||||
//!
|
||||
//! 当前部署边界是单副本,因此不需要 Redis lease、fencing token 或 heartbeat。长时间
|
||||
//! `pg_dump` 与上传由本 Daemon 自身持有;Host 只负责 panic 后重启、健康与关闭。
|
||||
//! 当前部署边界是单副本,因此不需要 Redis lease、fencing token 或 heartbeat
|
||||
//! 长时间
|
||||
//! `pg_dump` 与上传由本 Daemon 自身持有;Host 只负责 panic 后重启、健康与关闭
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -27,30 +28,31 @@ use crate::{
|
||||
|
||||
impl From<BackupError> for WorkerTaskError {
|
||||
fn from(error: BackupError) -> Self {
|
||||
WorkerTaskError::safe(error.message())
|
||||
WorkerTaskError::safe(error.message()).with_source(error)
|
||||
}
|
||||
}
|
||||
|
||||
use super::policy::{BackupSchedule, decide_retention};
|
||||
|
||||
/// 循环间隔:管理员修改 Cron 后该时长内生效;无工作时可取消等待。
|
||||
/// 循环间隔:管理员修改 Cron 后该时长内生效;无工作时可取消等待
|
||||
const BACKUP_LOOP_INTERVAL: Duration = Duration::from_secs(30);
|
||||
/// 每个循环最多执行的保留删除数量。
|
||||
/// 每个循环最多执行的保留删除数量
|
||||
const RETENTION_BATCH_SIZE: usize = 10;
|
||||
/// 保留扫描一次读取的 completed 计划备份上限。
|
||||
/// 保留扫描一次读取的 completed 计划备份上限
|
||||
const RETENTION_SCAN_LIMIT: u32 = 1000;
|
||||
/// 每个循环最多完成的待删除记录数量。
|
||||
/// 每个循环最多完成的待删除记录数量
|
||||
const PENDING_DELETION_BATCH: u32 = 20;
|
||||
|
||||
/// 备份 Daemon 任务。
|
||||
/// 备份 Daemon 任务
|
||||
pub struct BackupTask {
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
repository: Arc<dyn BackupRepository>,
|
||||
dump: Arc<dyn DatabaseDumpPort>,
|
||||
object_store: Arc<dyn BackupObjectStorePort>,
|
||||
}
|
||||
|
||||
impl BackupTask {
|
||||
/// 组合仓储、导出器与对象存储适配器。
|
||||
/// 组合仓储、导出器与对象存储适配器
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repository: Arc<dyn BackupRepository>,
|
||||
@@ -58,11 +60,18 @@ impl BackupTask {
|
||||
object_store: Arc<dyn BackupObjectStorePort>,
|
||||
) -> Self {
|
||||
Self {
|
||||
timezone: Default::default(),
|
||||
repository,
|
||||
dump,
|
||||
object_store,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_timezone(mut self, timezone: gateway_core::time::DeploymentTimeZone) -> Self {
|
||||
self.timezone = timezone;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl DaemonTask for BackupTask {
|
||||
@@ -86,13 +95,13 @@ impl BackupTask {
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行一个周期:推进计划、恢复中间态、删除收敛、领取执行一个任务、保留清理。
|
||||
/// 执行一个周期:推进计划、恢复中间态、删除收敛、领取执行一个任务、保留清理
|
||||
///
|
||||
/// 对外暴露以便集成测试逐周期驱动;daemon 循环内部复用。
|
||||
/// 对外暴露以便集成测试逐周期驱动;daemon 循环内部复用
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 仓储或基础设施不可用、任务执行失败时返回 [`WorkerTaskError`]。
|
||||
/// 仓储或基础设施不可用、任务执行失败时返回 [`WorkerTaskError`]
|
||||
pub async fn run_cycle(&self, cancellation: &CancellationToken) -> Result<(), WorkerTaskError> {
|
||||
if cancellation.is_cancelled() {
|
||||
return Ok(());
|
||||
@@ -105,29 +114,52 @@ impl BackupTask {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 推进到期计划并幂等插入 scheduled 任务。
|
||||
/// 推进到期计划并幂等插入 scheduled 任务
|
||||
async fn advance_schedule(&self, now: DateTime<Utc>) -> Result<(), WorkerTaskError> {
|
||||
let settings = self.repository.load_settings().await.map_err(repo_error)?;
|
||||
if !settings.schedule_enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let cron = settings.cron_expression.as_deref().unwrap_or_default();
|
||||
let timezone = settings.schedule_timezone.as_deref().unwrap_or_default();
|
||||
if cron.is_empty() || timezone.is_empty() {
|
||||
let timezone = self.timezone.name();
|
||||
if cron.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let schedule = BackupSchedule::parse(cron, timezone)
|
||||
.map_err(|_| WorkerTaskError::safe("backup schedule is invalid"))?;
|
||||
let schedule = BackupSchedule::parse(cron, self.timezone).map_err(|source| {
|
||||
WorkerTaskError::safe("backup schedule is invalid").with_source(source)
|
||||
})?;
|
||||
|
||||
if settings.schedule_timezone.as_deref() != Some(timezone) {
|
||||
// 时区切换只初始化未来游标,不把旧时区计划当作应补跑任务
|
||||
if let Some(next_run_at) = schedule.next_after(now) {
|
||||
self.repository
|
||||
.advance_schedule_cursor(
|
||||
next_run_at,
|
||||
cron,
|
||||
settings.schedule_timezone.as_deref(),
|
||||
settings.next_run_at,
|
||||
timezone,
|
||||
)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let due = match settings.next_run_at {
|
||||
Some(next_run_at) if next_run_at <= now => true,
|
||||
Some(_) => false,
|
||||
None => {
|
||||
// 刚启用计划但尚无游标:直接初始化。
|
||||
// 刚启用计划但尚无游标:直接初始化
|
||||
if let Some(next_run_at) = schedule.next_after(now) {
|
||||
let _ = self
|
||||
.repository
|
||||
.advance_schedule_cursor(next_run_at, cron, timezone)
|
||||
.advance_schedule_cursor(
|
||||
next_run_at,
|
||||
cron,
|
||||
Some(timezone),
|
||||
settings.next_run_at,
|
||||
timezone,
|
||||
)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
}
|
||||
@@ -141,11 +173,17 @@ impl BackupTask {
|
||||
let scheduled_at = match schedule.last_firing_at_or_before(now) {
|
||||
Some(scheduled_at) => scheduled_at,
|
||||
None => {
|
||||
// 无法从计划推导最近到期时间:推进游标避免反复触发。
|
||||
// 无法从计划推导最近到期时间:推进游标避免反复触发
|
||||
if let Some(next_run_at) = schedule.next_after(now) {
|
||||
let _ = self
|
||||
.repository
|
||||
.advance_schedule_cursor(next_run_at, cron, timezone)
|
||||
.advance_schedule_cursor(
|
||||
next_run_at,
|
||||
cron,
|
||||
Some(timezone),
|
||||
settings.next_run_at,
|
||||
timezone,
|
||||
)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
}
|
||||
@@ -168,25 +206,24 @@ impl BackupTask {
|
||||
.map_err(infra_error)?;
|
||||
let inserted = self
|
||||
.repository
|
||||
.insert_scheduled_record(seed)
|
||||
.insert_scheduled_record(
|
||||
seed,
|
||||
schedule.next_after(now),
|
||||
cron,
|
||||
timezone,
|
||||
settings.next_run_at,
|
||||
)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
if inserted {
|
||||
info!(scheduled_at = %scheduled_at, "计划备份任务已创建");
|
||||
} else {
|
||||
warn!(scheduled_at = %scheduled_at, "计划时间点冲突,跳过并推进游标");
|
||||
}
|
||||
if let Some(next_run_at) = schedule.next_after(now) {
|
||||
let _ = self
|
||||
.repository
|
||||
.advance_schedule_cursor(next_run_at, cron, timezone)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
warn!(scheduled_at = %scheduled_at, "计划已变化或存在冲突,本轮未创建任务");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 恢复中间状态(dumping/uploading/deleting)。
|
||||
/// 恢复中间状态(dumping/uploading/deleting)
|
||||
async fn recover_intermediate(
|
||||
&self,
|
||||
cancellation: &CancellationToken,
|
||||
@@ -206,7 +243,7 @@ impl BackupTask {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// dumping:有完整暂存则继续上传,否则清理暂存并标记失败。
|
||||
/// dumping:有完整暂存则继续上传,否则清理暂存并标记失败
|
||||
async fn recover_dumping(
|
||||
&self,
|
||||
record: &BackupRecord,
|
||||
@@ -244,7 +281,6 @@ impl BackupTask {
|
||||
.await?;
|
||||
}
|
||||
None => {
|
||||
let _ = self.dump.cleanup_staging(&record.id).await;
|
||||
self.fail_task(record, code::PG_DUMP_FAILED, "导出进程中断,暂存归档不完整")
|
||||
.await?;
|
||||
}
|
||||
@@ -252,7 +288,7 @@ impl BackupTask {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// uploading:远端匹配则补记完成,有完整暂存则重试上传,否则失败。
|
||||
/// uploading:远端匹配则补记完成,有完整暂存则重试上传,否则失败
|
||||
async fn recover_uploading(
|
||||
&self,
|
||||
record: &BackupRecord,
|
||||
@@ -270,7 +306,8 @@ impl BackupTask {
|
||||
&& record.sha256.as_deref() == Some(remote.sha256.as_str())
|
||||
{
|
||||
let now = Utc::now();
|
||||
self.repository
|
||||
if self
|
||||
.repository
|
||||
.transition_status(
|
||||
&record.id,
|
||||
status_transition(BackupStatus::Uploading, BackupStatus::Completed)?,
|
||||
@@ -278,7 +315,11 @@ impl BackupTask {
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
.map_err(repo_error)?
|
||||
.is_some()
|
||||
{
|
||||
self.cleanup_staging(&record.id).await;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
match self.dump.inspect_staging(&record.id).await? {
|
||||
@@ -304,7 +345,7 @@ impl BackupTask {
|
||||
}
|
||||
}
|
||||
|
||||
/// 完成待删除记录(deleting → DeleteObject → 硬删除)。
|
||||
/// 完成待删除记录(deleting → DeleteObject → 硬删除)
|
||||
async fn finish_pending_deletions(&self) -> Result<(), WorkerTaskError> {
|
||||
let settings = self.repository.load_settings().await.map_err(repo_error)?;
|
||||
let Some(config) = BackupStorageConfig::from_settings(&settings) else {
|
||||
@@ -331,7 +372,7 @@ impl BackupTask {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 领取一个 queued 任务并执行导出、上传与远端校验。
|
||||
/// 领取一个 queued 任务并执行导出、上传与远端校验
|
||||
async fn process_next_task(
|
||||
&self,
|
||||
cancellation: &CancellationToken,
|
||||
@@ -356,7 +397,7 @@ impl BackupTask {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 执行单个任务;内部处理全部状态迁移。
|
||||
/// 执行单个任务;内部处理全部状态迁移
|
||||
async fn execute_task(
|
||||
&self,
|
||||
record: &BackupRecord,
|
||||
@@ -390,7 +431,7 @@ impl BackupTask {
|
||||
}
|
||||
};
|
||||
|
||||
// dumping → uploading,持久化 size/sha256。
|
||||
// dumping → uploading,持久化 size/sha256
|
||||
let update = StatusTransitionUpdate {
|
||||
size_bytes: Some(artifact.size_bytes),
|
||||
sha256: Some(artifact.sha256.clone()),
|
||||
@@ -418,12 +459,10 @@ impl BackupTask {
|
||||
now,
|
||||
cancellation,
|
||||
)
|
||||
.await?;
|
||||
let _ = self.dump.cleanup_staging(&record.id).await;
|
||||
Ok(())
|
||||
.await
|
||||
}
|
||||
|
||||
/// 上传 + 远端校验,内部完成终态迁移。
|
||||
/// 上传 + 远端校验,内部完成终态迁移
|
||||
async fn upload_and_verify(
|
||||
&self,
|
||||
record: &BackupRecord,
|
||||
@@ -452,11 +491,13 @@ impl BackupTask {
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = store.delete_object(&config, &record.object_key).await;
|
||||
self.fail_task(
|
||||
self.fail_uploaded_task(
|
||||
record,
|
||||
error.code(),
|
||||
format!("上传到对象存储失败:{}", error.message()),
|
||||
&config,
|
||||
BackupError::new(
|
||||
error.code(),
|
||||
format!("上传到对象存储失败:{}", error.message()),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
@@ -481,6 +522,7 @@ impl BackupTask {
|
||||
size_bytes,
|
||||
"备份完成"
|
||||
);
|
||||
self.cleanup_staging(&record.id).await;
|
||||
}
|
||||
}
|
||||
Ok(Some(remote)) => {
|
||||
@@ -493,16 +535,19 @@ impl BackupTask {
|
||||
sha256_matches,
|
||||
"备份远端对象校验不一致"
|
||||
);
|
||||
let _ = store.delete_object(&config, &record.object_key).await;
|
||||
self.fail_task(
|
||||
self.fail_uploaded_task(
|
||||
record,
|
||||
code::REMOTE_VERIFICATION_FAILED,
|
||||
match (size_matches, sha256_matches) {
|
||||
(false, true) => "远端对象大小不一致",
|
||||
(true, false) => "远端对象 sha256 校验值不一致",
|
||||
(false, false) => "远端对象大小和 sha256 校验值均不一致",
|
||||
(true, true) => "远端对象校验状态异常",
|
||||
},
|
||||
&config,
|
||||
BackupError::new(
|
||||
code::REMOTE_VERIFICATION_FAILED,
|
||||
match (size_matches, sha256_matches) {
|
||||
(false, true) => "远端对象大小不一致",
|
||||
(true, false) => "远端对象 sha256 校验值不一致",
|
||||
(false, false) => "远端对象大小和 sha256 校验值均不一致",
|
||||
(true, true) => "远端对象校验状态异常",
|
||||
}
|
||||
.to_owned(),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -516,11 +561,13 @@ impl BackupTask {
|
||||
.await?;
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = store.delete_object(&config, &record.object_key).await;
|
||||
self.fail_task(
|
||||
self.fail_uploaded_task(
|
||||
record,
|
||||
error.code(),
|
||||
format!("远端对象校验失败:{}", error.message()),
|
||||
&config,
|
||||
BackupError::new(
|
||||
error.code(),
|
||||
format!("远端对象校验失败:{}", error.message()),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -528,7 +575,25 @@ impl BackupTask {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 把活跃任务写入失败终态并清理暂存。
|
||||
/// 清理失败并入原终态,后续清理沿用备份记录的到期策略
|
||||
async fn fail_uploaded_task(
|
||||
&self,
|
||||
record: &BackupRecord,
|
||||
config: &BackupStorageConfig,
|
||||
mut error: BackupError,
|
||||
) -> Result<(), WorkerTaskError> {
|
||||
if let Err(cleanup) = self
|
||||
.object_store
|
||||
.delete_object(config, &record.object_key)
|
||||
.await
|
||||
{
|
||||
error =
|
||||
error.with_cleanup_failure("DeleteObject", &cleanup, record.expires_at.is_some());
|
||||
}
|
||||
self.fail_task(record, error.code(), error.message()).await
|
||||
}
|
||||
|
||||
/// 把活跃任务写入失败终态并清理暂存
|
||||
async fn fail_task(
|
||||
&self,
|
||||
record: &BackupRecord,
|
||||
@@ -559,15 +624,21 @@ impl BackupTask {
|
||||
)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
let _ = self.dump.cleanup_staging(&record.id).await;
|
||||
self.cleanup_staging(&record.id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 执行一小批到期保留清理:先处理 `expires_at` 已到期的记录,再按天数/份数清理计划备份。
|
||||
async fn cleanup_staging(&self, backup_id: &str) {
|
||||
if let Err(error) = self.dump.cleanup_staging(backup_id).await {
|
||||
warn!(backup_id, error = %error, "清理备份暂存失败");
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行一小批到期保留清理:先处理 `expires_at` 已到期的记录,再按天数/份数清理计划备份
|
||||
async fn run_retention_batch(&self, now: DateTime<Utc>) -> Result<(), WorkerTaskError> {
|
||||
let settings = self.repository.load_settings().await.map_err(repo_error)?;
|
||||
|
||||
// 1. expires_at 已到期的记录(手动或计划)无条件进入删除。
|
||||
// 1. expires_at 已到期的记录(手动或计划)无条件进入删除
|
||||
let expired = self
|
||||
.repository
|
||||
.list_expired_records(RETENTION_BATCH_SIZE as u32)
|
||||
@@ -585,7 +656,7 @@ impl BackupTask {
|
||||
self.finalize_deletion(&record).await?;
|
||||
}
|
||||
|
||||
// 2. 计划备份按 retentionDays / retentionCount 清理。
|
||||
// 2. 计划备份按 retentionDays / retentionCount 清理
|
||||
let records = self
|
||||
.repository
|
||||
.list_scheduled_completed_desc(RETENTION_SCAN_LIMIT)
|
||||
@@ -611,7 +682,7 @@ impl BackupTask {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 对已进入 deleting 的记录执行对象删除并硬删除记录行。
|
||||
/// 对已进入 deleting 的记录执行对象删除并硬删除记录行
|
||||
async fn finalize_deletion(&self, record: &BackupRecord) -> Result<(), WorkerTaskError> {
|
||||
let settings = self.repository.load_settings().await.map_err(repo_error)?;
|
||||
let Some(config) = BackupStorageConfig::from_settings(&settings) else {
|
||||
@@ -649,9 +720,9 @@ fn status_transition(
|
||||
}
|
||||
|
||||
fn repo_error(error: crate::ports::store::AdminStoreError) -> WorkerTaskError {
|
||||
WorkerTaskError::safe(format!("backup store failed: {error}"))
|
||||
WorkerTaskError::safe("backup store failed").with_source(error)
|
||||
}
|
||||
|
||||
fn infra_error(error: BackupError) -> WorkerTaskError {
|
||||
WorkerTaskError::safe(error.message())
|
||||
WorkerTaskError::safe(error.message()).with_source(error)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! 容量熔断冻结的恢复编排:到期探测、失败顺延与自适应并发下调。
|
||||
//! 容量熔断冻结的恢复编排:到期探测、失败顺延与自适应并发下调
|
||||
//!
|
||||
//! 冻结事实保存在可丢失的 Redis 冷却中,本服务只组合 Admin 端口:
|
||||
//! 观测(`AccountRuntimeStore`)、探测(`AccountsService`)与写回
|
||||
//! (原子降低并发 / 按冻结代次结束探测)。旧观测不会覆盖管理员操作。
|
||||
//! (原子降低并发 / 按冻结代次结束探测)
|
||||
//! 旧观测不会覆盖管理员操作
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
@@ -22,21 +23,21 @@ use crate::model::{MutationActor, MutationContext};
|
||||
use crate::ports::store::{AccountRuntimeStore, AccountStore, SettingsStore};
|
||||
use crate::use_case::accounts::AccountsService;
|
||||
|
||||
/// 自适应并发下调保留系数:下调到观测在途峰值的 80%。
|
||||
/// 自适应并发下调保留系数:下调到观测在途峰值的 80%
|
||||
const ADAPTIVE_CONCURRENCY_FACTOR: f64 = 0.8;
|
||||
/// 自适应并发下限:过低的并发让账号几乎不可用,宁可保持冻结。
|
||||
/// 自适应并发下限:过低的并发让账号几乎不可用,宁可保持冻结
|
||||
const ADAPTIVE_CONCURRENCY_FLOOR: u32 = 2;
|
||||
/// worker 周期;冷却观测与探测都依赖该节奏。
|
||||
/// worker 周期;冷却观测与探测都依赖该节奏
|
||||
pub const FREEZE_RECOVERY_INTERVAL: Duration = Duration::from_secs(30);
|
||||
pub const FREEZE_RECOVERY_WORKER_OWNER: &str = "account-freeze-recovery";
|
||||
pub const WORKER_INITIAL_BACKOFF: Duration = Duration::from_secs(1);
|
||||
pub const WORKER_MAXIMUM_BACKOFF: Duration = Duration::from_secs(60);
|
||||
pub const WORKER_LEASE_TTL: Duration = Duration::from_secs(15 * 60);
|
||||
pub const WORKER_LEASE_RENEWAL: Duration = Duration::from_secs(5 * 60);
|
||||
/// 系统审计上下文的固定请求 ID;账号并发更新审计由此溯源到自动冻结编排。
|
||||
/// 系统审计上下文的固定请求 ID;账号并发更新审计由此溯源到自动冻结编排
|
||||
const SYSTEM_REQUEST_ID: &str = "account-freeze-recovery";
|
||||
|
||||
/// 冻结恢复所需的管理端口组合。
|
||||
/// 冻结恢复所需的管理端口组合
|
||||
#[derive(Clone)]
|
||||
pub struct FreezeRecoveryDeps {
|
||||
pub accounts: Arc<dyn AccountsService>,
|
||||
@@ -61,17 +62,17 @@ impl FreezeRecoveryTask {
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取冻结策略;读取或校验失败跳过本轮,不能把未知配置当作允许解冻。
|
||||
/// 读取冻结策略;读取或校验失败跳过本轮,不能把未知配置当作允许解冻
|
||||
async fn freeze_policy(&self) -> Option<gateway_core::provider_ports::ProviderFreezePolicy> {
|
||||
let settings = self.deps.settings.load_runtime_settings().await.ok()?;
|
||||
gateway_core::provider_ports::ProviderFreezePolicy::try_new(
|
||||
settings.account_auto_freeze_enabled,
|
||||
settings.account_auto_freeze_threshold,
|
||||
settings.account_auto_freeze_window_seconds,
|
||||
settings.account_auto_freeze_duration_seconds,
|
||||
settings.account_auto_freeze_probe_enabled,
|
||||
settings.account_auto_freeze_probe_model,
|
||||
settings.account_auto_freeze_adaptive_concurrency,
|
||||
settings.values.account_auto_freeze_enabled,
|
||||
settings.values.account_auto_freeze_threshold,
|
||||
settings.values.account_auto_freeze_window_seconds,
|
||||
settings.values.account_auto_freeze_duration_seconds,
|
||||
settings.values.account_auto_freeze_probe_enabled,
|
||||
settings.values.account_auto_freeze_probe_model,
|
||||
settings.values.account_auto_freeze_adaptive_concurrency,
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
@@ -95,7 +96,7 @@ impl FreezeRecoveryTask {
|
||||
}
|
||||
|
||||
/// 对冻结中的账号执行自适应并发下调:目标为观测在途峰值的 80%(下限 2),
|
||||
/// 只降不升;未观测到峰值证据的账号保持现状。
|
||||
/// 只降不升;未观测到峰值证据的账号保持现状
|
||||
async fn adapt_concurrency_limits(
|
||||
&self,
|
||||
freezes: &BTreeMap<String, AccountFreeze>,
|
||||
@@ -146,7 +147,7 @@ impl FreezeRecoveryTask {
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// 到达探测时间后才恢复;关闭自动冻结或探测时,已有冻结仍等待原冷却结束。
|
||||
/// 到达探测时间后才恢复;关闭自动冻结或探测时,已有冻结仍等待原冷却结束
|
||||
async fn recover_due_freezes(
|
||||
&self,
|
||||
freezes: &BTreeMap<String, AccountFreeze>,
|
||||
@@ -179,14 +180,14 @@ impl FreezeRecoveryTask {
|
||||
return;
|
||||
};
|
||||
if !item.account.enabled {
|
||||
// 停用账号没有自动恢复意义;解冻交给管理员手动恢复。
|
||||
// 停用账号没有自动恢复意义;解冻交给管理员手动恢复
|
||||
return;
|
||||
}
|
||||
let Ok(account) = ProviderAccountId::new(account_id.to_owned()) else {
|
||||
return;
|
||||
};
|
||||
let Some(model) = self.resolve_probe_model(&account, policy).await else {
|
||||
// 没有可用探测模型时按失败处理,顺延冻结等待下一轮。
|
||||
// 没有可用探测模型时按失败处理,顺延冻结等待下一轮
|
||||
self.postpone(&account, freeze, policy).await;
|
||||
return;
|
||||
};
|
||||
@@ -218,7 +219,7 @@ impl FreezeRecoveryTask {
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测失败只顺延本次仍存在的冻结,旧探测不能重新冻结已手动恢复的账号。
|
||||
/// 探测失败只顺延本次仍存在的冻结,旧探测不能重新冻结已手动恢复的账号
|
||||
async fn postpone(
|
||||
&self,
|
||||
account: &ProviderAccountId,
|
||||
@@ -281,7 +282,7 @@ impl ScheduledTask for FreezeRecoveryTask {
|
||||
}
|
||||
}
|
||||
|
||||
/// 自适应目标并发:观测峰值 × 0.8 向下取整,下限 2;无证据(峰值为 0)不调整。
|
||||
/// 自适应目标并发:观测峰值 × 0.8 向下取整,下限 2;无证据(峰值为 0)不调整
|
||||
fn adaptive_target(peak_in_flight: u32) -> Option<u32> {
|
||||
if peak_in_flight == 0 {
|
||||
return None;
|
||||
@@ -290,7 +291,7 @@ fn adaptive_target(peak_in_flight: u32) -> Option<u32> {
|
||||
Some(scaled.max(ADAPTIVE_CONCURRENCY_FLOOR))
|
||||
}
|
||||
|
||||
/// 消耗连接测试事件流并返回是否以 `Completed` 终止。
|
||||
/// 消耗连接测试事件流并返回是否以 `Completed` 终止
|
||||
async fn drain_probe(mut events: crate::model::accounts::AccountConnectionTestEventStream) -> bool {
|
||||
let mut completed = false;
|
||||
while let Some(event) = events.next().await {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 管理控制面的语义模型、用例与外部能力端口。
|
||||
//! 管理控制面的语义模型、用例与外部能力端口
|
||||
//!
|
||||
//! 本 crate 不包含 HTTP wire、数据库实现或具体 Provider 实现。
|
||||
//! 本 crate 不包含 HTTP wire、数据库实现或具体 Provider 实现
|
||||
|
||||
use std::{fmt, path::Path, sync::Arc, time::Duration};
|
||||
|
||||
@@ -20,7 +20,7 @@ pub mod backup;
|
||||
pub mod freeze_recovery;
|
||||
pub mod model;
|
||||
pub mod ports;
|
||||
pub mod service;
|
||||
pub mod public_service;
|
||||
mod use_case;
|
||||
pub use use_case::plugins::{PluginDistributionPorts, PluginManagementService, PluginsService};
|
||||
|
||||
@@ -35,7 +35,7 @@ pub use use_case::{
|
||||
client_keys::ClientKeyService,
|
||||
credentials::{CredentialsService, ProviderCredentials},
|
||||
import_tasks::ImportTasksService,
|
||||
observability::ObservabilityService,
|
||||
observability::{ObservabilityService, ProviderUsageBillingProjector},
|
||||
proxies::ProxiesService,
|
||||
settings::SettingsService,
|
||||
system::SystemService,
|
||||
@@ -70,7 +70,7 @@ const WEAK_ADMIN_PASSWORDS: &[&str] = &[
|
||||
const BACKUP_WORKER_OWNER: &str = "backup";
|
||||
const DEFAULT_CLIENT_SESSION_TTL_MINUTES: u64 = 24 * 60;
|
||||
|
||||
/// 只用于首次幂等创建默认管理员的启动密码。
|
||||
/// 只用于首次幂等创建默认管理员的启动密码
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct InitialAdminPassword(SecretString);
|
||||
@@ -100,15 +100,21 @@ impl fmt::Debug for InitialAdminPassword {
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理控制面的启动配置。
|
||||
/// 管理控制面的启动配置
|
||||
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct AdminConfig {
|
||||
pub session_ttl_minutes: u64,
|
||||
#[serde(default = "default_absolute_session_ttl_minutes")]
|
||||
pub session_absolute_ttl_minutes: u64,
|
||||
pub default_username: String,
|
||||
pub default_password: InitialAdminPassword,
|
||||
}
|
||||
|
||||
/// Client 登录域的通用启动配置。
|
||||
fn default_absolute_session_ttl_minutes() -> u64 {
|
||||
30 * 24 * 60
|
||||
}
|
||||
|
||||
/// Client 登录域的通用启动配置
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct ClientConfig {
|
||||
pub session_ttl_minutes: u64,
|
||||
@@ -123,11 +129,11 @@ impl Default for ClientConfig {
|
||||
}
|
||||
|
||||
impl ClientConfig {
|
||||
/// 校验 Client session TTL;当前配置不含相对路径。
|
||||
/// 校验 Client session TTL;当前配置不含相对路径
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 会话有效期为零或无法安全换算时返回错误。
|
||||
/// 会话有效期为零或无法安全换算时返回错误
|
||||
pub fn resolve_and_validate(&mut self, _source_dir: &Path) -> Result<(), AdminConfigError> {
|
||||
if self.session_ttl_minutes == 0 || i64::try_from(self.session_ttl_minutes).is_err() {
|
||||
return Err(AdminConfigError::InvalidField("client.session_ttl_minutes"));
|
||||
@@ -137,11 +143,11 @@ impl ClientConfig {
|
||||
}
|
||||
|
||||
impl AdminConfig {
|
||||
/// 校验 Admin-owned 字段;当前配置不含相对路径。
|
||||
/// 校验 Admin-owned 字段;当前配置不含相对路径
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 用户名、会话有效期或初始密码不满足安全约束时返回错误。
|
||||
/// 用户名、会话有效期或初始密码不满足安全约束时返回错误
|
||||
pub fn resolve_and_validate(&mut self, _source_dir: &Path) -> Result<(), AdminConfigError> {
|
||||
if self.default_username.trim().is_empty()
|
||||
|| self.default_username.chars().any(char::is_control)
|
||||
@@ -151,6 +157,13 @@ impl AdminConfig {
|
||||
if self.session_ttl_minutes == 0 || i64::try_from(self.session_ttl_minutes).is_err() {
|
||||
return Err(AdminConfigError::InvalidField("admin.session_ttl_minutes"));
|
||||
}
|
||||
if self.session_absolute_ttl_minutes == 0
|
||||
|| i64::try_from(self.session_absolute_ttl_minutes).is_err()
|
||||
{
|
||||
return Err(AdminConfigError::InvalidField(
|
||||
"admin.session_absolute_ttl_minutes",
|
||||
));
|
||||
}
|
||||
let password = self.default_password.expose().trim();
|
||||
if password.len() < MINIMUM_INITIAL_PASSWORD_BYTES
|
||||
|| password.contains('$')
|
||||
@@ -167,13 +180,17 @@ impl fmt::Debug for AdminConfig {
|
||||
formatter
|
||||
.debug_struct("AdminConfig")
|
||||
.field("session_ttl_minutes", &self.session_ttl_minutes)
|
||||
.field(
|
||||
"session_absolute_ttl_minutes",
|
||||
&self.session_absolute_ttl_minutes,
|
||||
)
|
||||
.field("default_username", &self.default_username)
|
||||
.field("default_password", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin-owned 启动配置错误;不回显任何配置值。
|
||||
/// Admin-owned 启动配置错误;不回显任何配置值
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum AdminConfigError {
|
||||
#[error("配置字段 `{0}` 不合法")]
|
||||
@@ -182,12 +199,13 @@ pub enum AdminConfigError {
|
||||
WeakInitialPassword,
|
||||
}
|
||||
|
||||
/// API 持有的管理资源能力集合。
|
||||
/// API 持有的管理资源能力集合
|
||||
///
|
||||
/// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块。
|
||||
/// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块
|
||||
#[derive(Clone)]
|
||||
pub struct AdminServices {
|
||||
public_services: Arc<service::Registry>,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
public_services: Arc<public_service::Registry>,
|
||||
plugins: Arc<PluginsService>,
|
||||
plugin_management: Arc<PluginManagementService>,
|
||||
proxies: Arc<dyn ProxiesService>,
|
||||
@@ -201,13 +219,19 @@ pub struct AdminServices {
|
||||
settings: Arc<dyn SettingsService>,
|
||||
system: Arc<dyn SystemService>,
|
||||
credentials: Arc<CredentialsService>,
|
||||
plugin_accounts: Arc<dyn PluginAccountAccess>,
|
||||
// Runtime 只持有 Weak,这个强引用保障账号端口与 Admin 服务同寿命
|
||||
_plugin_accounts: Arc<dyn PluginAccountAccess>,
|
||||
backups: Arc<dyn BackupService>,
|
||||
import_tasks: Arc<dyn ImportTasksService>,
|
||||
}
|
||||
|
||||
impl AdminServices {
|
||||
pub fn public_services(&self) -> Arc<service::Registry> {
|
||||
#[must_use]
|
||||
pub const fn timezone(&self) -> gateway_core::time::DeploymentTimeZone {
|
||||
self.timezone
|
||||
}
|
||||
|
||||
pub fn public_services(&self) -> Arc<public_service::Registry> {
|
||||
self.public_services.clone()
|
||||
}
|
||||
|
||||
@@ -231,12 +255,6 @@ impl AdminServices {
|
||||
self.key_usage.as_ref()
|
||||
}
|
||||
|
||||
/// 取得账号服务的共享句柄;后台编排(冻结恢复 worker)需要持有 Arc。
|
||||
#[must_use]
|
||||
pub fn accounts_handle(&self) -> Arc<dyn AccountsService> {
|
||||
Arc::clone(&self.accounts)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn proxies(&self) -> &dyn ProxiesService {
|
||||
self.proxies.as_ref()
|
||||
@@ -286,19 +304,13 @@ impl AdminServices {
|
||||
self.credentials.as_ref()
|
||||
}
|
||||
|
||||
/// Runtime 只持有该窄端口的 Weak;AdminBundle 保持实际生命周期。
|
||||
#[must_use]
|
||||
pub fn plugin_accounts_handle(&self) -> Arc<dyn PluginAccountAccess> {
|
||||
Arc::clone(&self.plugin_accounts)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn backups(&self) -> &dyn BackupService {
|
||||
self.backups.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin 初始化完成后的封闭能力包。
|
||||
/// Admin 初始化完成后的封闭能力包
|
||||
pub struct AdminBundle {
|
||||
services: AdminServices,
|
||||
worker_contributions: Vec<WorkerContribution>,
|
||||
@@ -310,15 +322,16 @@ impl AdminBundle {
|
||||
self.services.clone()
|
||||
}
|
||||
|
||||
/// 取出 Admin Worker 贡献;只能调用一次,与其它 Bundle 的贡献一并交给 Host。
|
||||
/// 取出 Admin Worker 贡献;只能调用一次,与其它 Bundle 的贡献一并交给 Host
|
||||
pub fn take_worker_contributions(&mut self) -> Vec<WorkerContribution> {
|
||||
std::mem::take(&mut self.worker_contributions)
|
||||
}
|
||||
}
|
||||
|
||||
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。
|
||||
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入
|
||||
pub struct AdminRuntimePorts {
|
||||
pub service_middleware: service::PlanSource,
|
||||
pub timezone: gateway_core::time::DeploymentTimeZone,
|
||||
pub service_middleware: public_service::PlanSource,
|
||||
pub plugin_preparation: Arc<dyn ports::plugins::PluginPreparation>,
|
||||
pub plugin_management: Arc<dyn ports::plugin_management::PluginManagement>,
|
||||
pub published_snapshot: gateway_core::runtime::RuntimeSnapshotHandle,
|
||||
@@ -334,11 +347,11 @@ pub struct AdminRuntimePorts {
|
||||
pub client_key_verifier: Arc<dyn ClientKeyVerifier>,
|
||||
}
|
||||
|
||||
/// 校验配置、接入已组装的 Provider 注册表并完成默认管理员幂等初始化。
|
||||
/// 校验配置、接入已组装的 Provider 注册表并完成默认管理员幂等初始化
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 配置非法或默认管理员初始化失败时返回错误。
|
||||
/// 配置非法或默认管理员初始化失败时返回错误
|
||||
pub async fn initialize(
|
||||
config: AdminConfig,
|
||||
client_config: ClientConfig,
|
||||
@@ -348,7 +361,7 @@ pub async fn initialize(
|
||||
initialize_inner(config, client_config, store, runtime, None).await
|
||||
}
|
||||
|
||||
/// 使用组合根已绑定给 Runtime 的同一账号窄端口,避免为完整 Admin 重建第二实例。
|
||||
/// 使用组合根已绑定给 Runtime 的同一账号窄端口,避免为完整 Admin 重建第二实例
|
||||
pub async fn initialize_with_plugin_accounts(
|
||||
config: AdminConfig,
|
||||
client_config: ClientConfig,
|
||||
@@ -367,6 +380,7 @@ async fn initialize_inner(
|
||||
plugin_accounts: Option<Arc<dyn PluginAccountAccess>>,
|
||||
) -> Result<AdminBundle, AdminError> {
|
||||
let AdminRuntimePorts {
|
||||
timezone,
|
||||
service_middleware,
|
||||
plugin_preparation,
|
||||
plugin_management,
|
||||
@@ -391,8 +405,7 @@ async fn initialize_inner(
|
||||
let registry = providers;
|
||||
|
||||
let auth = Arc::new(DefaultAuthService::new(
|
||||
config.default_username,
|
||||
config.session_ttl_minutes,
|
||||
&config,
|
||||
client_config.session_ttl_minutes,
|
||||
store.auth(),
|
||||
client_key_verifier.clone(),
|
||||
@@ -413,24 +426,29 @@ async fn initialize_inner(
|
||||
backup_ports.object_store(),
|
||||
store.auth(),
|
||||
snapshot.clone(),
|
||||
timezone,
|
||||
));
|
||||
let backup_task = backup::task::BackupTask::new(
|
||||
backup_ports.repository(),
|
||||
backup_ports.dump(),
|
||||
backup_ports.object_store(),
|
||||
);
|
||||
)
|
||||
.with_timezone(timezone);
|
||||
let system_preflight = Arc::new(use_case::plugin_update::PluginSystemUpdatePreflight::new(
|
||||
store.plugins(),
|
||||
plugin_inspector.clone(),
|
||||
snapshot.clone(),
|
||||
));
|
||||
let system = Arc::new(DefaultSystemService::new(system, system_preflight));
|
||||
let observations: Arc<dyn ports::store::ObservabilityStore> = Arc::new(
|
||||
use_case::observability::CachedObservabilityStore::new(store.observability()),
|
||||
);
|
||||
let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new(
|
||||
auth.clone(),
|
||||
client_key_verifier,
|
||||
store.client_keys(),
|
||||
store.observability(),
|
||||
observations.clone(),
|
||||
system.clone(),
|
||||
timezone,
|
||||
));
|
||||
let credentials = Arc::new(CredentialsService::new(
|
||||
registry.clone(),
|
||||
@@ -449,9 +467,10 @@ async fn initialize_inner(
|
||||
registry.clone(),
|
||||
pricing_source,
|
||||
);
|
||||
let mut public_services = service::Registry::new(service_middleware);
|
||||
let mut public_services = public_service::Registry::new(service_middleware);
|
||||
public_services.register_settings(&settings)?;
|
||||
let services = AdminServices {
|
||||
timezone,
|
||||
public_services: Arc::new(public_services),
|
||||
plugin_management: Arc::new(PluginManagementService::new(
|
||||
plugin_management,
|
||||
@@ -488,15 +507,16 @@ async fn initialize_inner(
|
||||
)),
|
||||
client_distribution: Arc::new(DefaultClientDistributionService::new(client_distribution)),
|
||||
observability: Arc::new(DefaultObservabilityService::new(
|
||||
store.observability(),
|
||||
observations.clone(),
|
||||
store.accounts(),
|
||||
store.settings(),
|
||||
registry.clone(),
|
||||
timezone,
|
||||
)),
|
||||
settings,
|
||||
system,
|
||||
credentials,
|
||||
plugin_accounts,
|
||||
_plugin_accounts: plugin_accounts,
|
||||
import_tasks,
|
||||
backups,
|
||||
};
|
||||
@@ -528,7 +548,7 @@ async fn initialize_inner(
|
||||
})
|
||||
}
|
||||
|
||||
/// CLI 只组合账号用例,不初始化管理员、管理服务或后台任务;写入仍复用同一事务与审计。
|
||||
/// CLI 只组合账号用例,不初始化管理员、管理服务或后台任务;写入仍复用同一事务与审计
|
||||
pub fn initialize_plugin_accounts(
|
||||
providers: ports::provider::ProviderAdminRegistry,
|
||||
accounts: Arc<dyn ports::store::AccountStore>,
|
||||
@@ -539,7 +559,7 @@ pub fn initialize_plugin_accounts(
|
||||
))
|
||||
}
|
||||
|
||||
/// 为 Runtime 创建非秘密 Client Key 目录与预算重置的窄端口。
|
||||
/// 为 Runtime 创建非秘密 Client Key 目录与预算重置的窄端口
|
||||
#[must_use]
|
||||
pub fn initialize_plugin_client_keys(
|
||||
providers: ports::provider::ProviderAdminRegistry,
|
||||
@@ -551,7 +571,7 @@ pub fn initialize_plugin_client_keys(
|
||||
Arc::new(use_case::plugin_client_keys::DefaultPluginClientKeyAccess::new(service))
|
||||
}
|
||||
|
||||
/// 为 Runtime 组合实例自有资源写入;权限和归属在同一存储事务复核。
|
||||
/// 为 Runtime 组合实例自有资源写入;权限和归属在同一存储事务复核
|
||||
#[must_use]
|
||||
pub fn initialize_plugin_resources(
|
||||
store: Arc<dyn ports::plugin_resources::PluginResourceStore>,
|
||||
@@ -560,7 +580,7 @@ pub fn initialize_plugin_resources(
|
||||
Arc::new(use_case::plugin_resources::DefaultPluginResourceAccess { store, snapshot })
|
||||
}
|
||||
|
||||
/// Backup Worker 注册:单个可取消 Daemon,owner 固定为 `backup`。
|
||||
/// Backup Worker 注册:单个可取消 Daemon,owner 固定为 `backup`
|
||||
fn backup_worker_contribution(
|
||||
task: backup::task::BackupTask,
|
||||
) -> Result<Vec<WorkerContribution>, AdminError> {
|
||||
@@ -579,7 +599,7 @@ fn backup_worker_contribution(
|
||||
Ok(vec![WorkerContribution::Registration(registration)])
|
||||
}
|
||||
|
||||
/// 冻结恢复 Worker 注册:按固定周期扫描活跃冻结,owner 固定。
|
||||
/// 冻结恢复 Worker 注册:按固定周期扫描活跃冻结,owner 固定
|
||||
fn freeze_recovery_worker_contribution(
|
||||
task: freeze_recovery::FreezeRecoveryTask,
|
||||
) -> Result<Vec<WorkerContribution>, AdminError> {
|
||||
@@ -610,7 +630,7 @@ fn freeze_recovery_worker_contribution(
|
||||
Ok(vec![WorkerContribution::Registration(registration)])
|
||||
}
|
||||
|
||||
/// 设置服务不依赖 Web 管理会话,CLI 与服务器通过同一用例执行事务和发布。
|
||||
/// 设置服务不依赖 Web 管理会话,CLI 与服务器通过同一用例执行事务和发布
|
||||
pub fn initialize_settings(
|
||||
store: Arc<dyn ports::store::SettingsStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
//! Provider-neutral account group commands and projections.
|
||||
//! 跨 Provider 账号分组的管理命令与查询投影
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::{account::AccountStatusFacts, routing::AccountGroupId};
|
||||
use gateway_core::{
|
||||
account::{AccountStatusFacts, FastMode},
|
||||
routing::AccountGroupId,
|
||||
};
|
||||
|
||||
use super::{PageSize, Revision, observability::DecimalAmount};
|
||||
|
||||
/// Canonical `#RRGGBBAA` color persisted with an account group.
|
||||
/// 名称与描述的共同约束,HTTP、用例和存储边界只映射错误
|
||||
pub fn validate_group_fields(
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
) -> Result<(), super::AdminModelError> {
|
||||
if name.trim() != name
|
||||
|| name.is_empty()
|
||||
|| name.chars().count() > 100
|
||||
|| name.chars().any(char::is_control)
|
||||
|| description
|
||||
.is_some_and(|value| value.len() > 4096 || value.chars().any(char::is_control))
|
||||
{
|
||||
return Err(super::AdminModelError::InvalidAccountGroupFields);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 账号分组持久化使用的标准 `#RRGGBBAA` 颜色
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupColor(String);
|
||||
|
||||
@@ -26,7 +46,7 @@ impl AccountGroupColor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Group member availability at the current observation time.
|
||||
/// 当前观测时刻的分组成员可用性
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountGroupAccountSummary {
|
||||
pub available: u64,
|
||||
@@ -34,23 +54,23 @@ pub struct AccountGroupAccountSummary {
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// Group scheduling slots derived from available accounts and runtime leases.
|
||||
/// 根据可用账号与运行时租约计算的分组调度槽位
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountGroupCapacity {
|
||||
pub used_slots: Option<u64>,
|
||||
/// `None` 表示可用账号中存在不限制并发的账号。
|
||||
/// `None` 表示可用账号中存在不限制并发的账号
|
||||
pub total_slots: Option<u64>,
|
||||
}
|
||||
|
||||
/// Successful, downstream-committed USD request costs for group accounts.
|
||||
/// 分组账号成功且已向下游交付的请求美元费用
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupUsage {
|
||||
pub today_usd: DecimalAmount,
|
||||
/// 当前 usage retention 窗口内、按请求发生时 routing group 快照归属的累计成本。
|
||||
/// 当前 usage retention 窗口内、当前组成员实际完成请求的累计成本
|
||||
pub retained_total_usd: DecimalAmount,
|
||||
}
|
||||
|
||||
/// 当前页账号组 membership 对应的持久账号事实;运行态在 Admin query service 合并。
|
||||
/// 当前页账号组 membership 对应的持久账号事实;运行态在 Admin query service 合并
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupMemberFact {
|
||||
pub group_id: AccountGroupId,
|
||||
@@ -59,7 +79,7 @@ pub struct AccountGroupMemberFact {
|
||||
pub total_slots: Option<u64>,
|
||||
}
|
||||
|
||||
/// Lightweight group reference embedded in account and client-key views.
|
||||
/// 账号与 Client Key 视图内嵌的轻量分组引用
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupRef {
|
||||
pub id: AccountGroupId,
|
||||
@@ -68,7 +88,7 @@ pub struct AccountGroupRef {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Account group list query.
|
||||
/// 账号分组列表查询
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupListQuery {
|
||||
pub page: u32,
|
||||
@@ -77,10 +97,10 @@ pub struct AccountGroupListQuery {
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// Complete account group summary.
|
||||
/// 完整账号分组摘要
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupRecord {
|
||||
pub disable_fast: bool,
|
||||
pub fast_mode: FastMode,
|
||||
pub id: AccountGroupId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
@@ -96,7 +116,7 @@ pub struct AccountGroupRecord {
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Paginated account groups.
|
||||
/// 账号分组分页结果
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupPage {
|
||||
pub config_revision: Revision,
|
||||
@@ -106,49 +126,59 @@ pub struct AccountGroupPage {
|
||||
pub page_size: u16,
|
||||
}
|
||||
|
||||
/// Create an account group.
|
||||
/// 分组选择器使用的轻量分页结果
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupOptionsPage {
|
||||
pub config_revision: Revision,
|
||||
pub items: Vec<AccountGroupRef>,
|
||||
pub total: u64,
|
||||
pub page: u32,
|
||||
pub page_size: u16,
|
||||
}
|
||||
|
||||
/// 创建账号分组
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateAccountGroup {
|
||||
pub disable_fast: bool,
|
||||
pub fast_mode: FastMode,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub color: AccountGroupColor,
|
||||
}
|
||||
|
||||
/// Store-ready create command with a generated stable ID.
|
||||
/// 已生成稳定标识、可直接提交存储的创建命令
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NewAccountGroup {
|
||||
pub disable_fast: bool,
|
||||
pub fast_mode: FastMode,
|
||||
pub id: AccountGroupId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub color: AccountGroupColor,
|
||||
}
|
||||
|
||||
/// Update an account group's descriptive fields.
|
||||
/// 更新账号分组的描述字段
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateAccountGroup {
|
||||
pub disable_fast: Option<bool>,
|
||||
pub fast_mode: Option<FastMode>,
|
||||
pub id: AccountGroupId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub color: AccountGroupColor,
|
||||
}
|
||||
|
||||
/// Enable or disable an account group.
|
||||
/// 启用或停用账号分组
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetAccountGroupEnabled {
|
||||
pub id: AccountGroupId,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Delete an account group that is not referenced by a client key.
|
||||
/// 删除未被 Client Key 引用的账号分组
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteAccountGroup {
|
||||
pub id: AccountGroupId,
|
||||
}
|
||||
|
||||
/// Result of any account-group mutation.
|
||||
/// 账号分组变更结果
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupMutation {
|
||||
pub config_revision: Revision,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 多 Provider 账号目录与连接测试的公共事实。
|
||||
//! 多 Provider 账号目录与连接测试的公共事实
|
||||
|
||||
use std::{collections::BTreeMap, pin::Pin};
|
||||
|
||||
@@ -18,7 +18,7 @@ pub use gateway_core::account::{
|
||||
QuotaState, resolve_account_status,
|
||||
};
|
||||
|
||||
/// 账号可用的管理操作。
|
||||
/// 账号可用的管理操作
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProviderAccountCapabilities {
|
||||
pub quota: bool,
|
||||
@@ -30,7 +30,7 @@ pub struct ProviderAccountCapabilities {
|
||||
pub consume_reset_credit: bool,
|
||||
}
|
||||
|
||||
/// 导入时统一应用的账号备注、调度与分组设置;缺省时保留原有导入语义。
|
||||
/// 导入时统一应用的账号备注、调度与分组设置;缺省时保留原有导入语义
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountImportSettings {
|
||||
pub notes: Option<String>,
|
||||
@@ -41,7 +41,7 @@ pub struct AccountImportSettings {
|
||||
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
|
||||
}
|
||||
|
||||
/// 账号列表排序字段。
|
||||
/// 账号列表排序字段
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AccountSortField {
|
||||
Email,
|
||||
@@ -52,21 +52,21 @@ pub enum AccountSortField {
|
||||
ExpiresAt,
|
||||
}
|
||||
|
||||
/// 账号列表排序方向。
|
||||
/// 账号列表排序方向
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SortDirection {
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
/// 一组完整的账号排序规则。
|
||||
/// 一组完整的账号排序规则
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountSort {
|
||||
pub field: AccountSortField,
|
||||
pub direction: SortDirection,
|
||||
}
|
||||
|
||||
/// 账号列表的存储查询条件。
|
||||
/// 账号列表的存储查询条件
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountListQuery {
|
||||
pub page: u32,
|
||||
@@ -78,15 +78,15 @@ pub struct AccountListQuery {
|
||||
pub sort: Option<AccountSort>,
|
||||
}
|
||||
|
||||
/// Admin query service 从运行态存储取得的当前账号冷却快照。
|
||||
/// Admin query service 从运行态存储取得的当前账号冷却快照
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AccountRuntimeSnapshot {
|
||||
pub cooldown: BTreeMap<String, gateway_core::account::AccountCooldown>,
|
||||
/// `None` 表示实时 lease 存储不可用;`Some` 中未出现的账号当前使用量为零。
|
||||
/// `None` 表示实时 lease 存储不可用;`Some` 中未出现的账号当前使用量为零
|
||||
pub in_flight: Option<BTreeMap<String, u64>>,
|
||||
}
|
||||
|
||||
/// 恢复任务读取的冻结快照;generation 将异步结果绑定到本次冻结。
|
||||
/// 恢复任务读取的冻结快照;generation 将异步结果绑定到本次冻结
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountFreeze {
|
||||
pub credential_revision: Revision,
|
||||
@@ -95,14 +95,14 @@ pub struct AccountFreeze {
|
||||
pub requires_probe: bool,
|
||||
}
|
||||
|
||||
/// Optional account membership filter.
|
||||
/// 可选的账号分组成员筛选条件
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AccountGroupFilter {
|
||||
Group(gateway_core::routing::AccountGroupId),
|
||||
Ungrouped,
|
||||
}
|
||||
|
||||
/// 账号公共存储投影;Provider 专属字段不进入此结构。
|
||||
/// 账号公共存储投影;Provider 专属字段不进入此结构
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountRecord {
|
||||
pub id: String,
|
||||
@@ -133,42 +133,12 @@ pub struct AccountRecord {
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 单一货币的账号成本聚合。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountCost {
|
||||
pub currency: String,
|
||||
pub amount: super::observability::DecimalAmount,
|
||||
}
|
||||
pub use super::observability::{
|
||||
AccountModelUsage, AccountPoolMetrics as AccountSummary, AccountRequestBucket,
|
||||
CurrencyCost as AccountCost,
|
||||
};
|
||||
|
||||
/// 账号在一个模型上的历史用量。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountModelUsage {
|
||||
pub model: String,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub image_request_count: u64,
|
||||
pub image_request_failed_count: u64,
|
||||
pub total_tokens: Option<u64>,
|
||||
pub cost_coverage: super::observability::CostCoverage,
|
||||
pub costs: Vec<AccountCost>,
|
||||
pub last_used_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 账号在一个小时窗口内的请求数。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountRequestBucket {
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
pub request_count: u64,
|
||||
}
|
||||
|
||||
/// 账号历史用量聚合。
|
||||
/// 账号历史用量聚合
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountUsage {
|
||||
pub account_id: String,
|
||||
@@ -176,9 +146,9 @@ pub struct AccountUsage {
|
||||
pub success_count: u64,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub cached_input_tokens: Option<u64>,
|
||||
pub cache_write_input_tokens: Option<u64>,
|
||||
pub reasoning_output_tokens: Option<u64>,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub image_request_count: u64,
|
||||
@@ -191,9 +161,9 @@ pub struct AccountUsage {
|
||||
pub models: Vec<AccountModelUsage>,
|
||||
}
|
||||
|
||||
/// 某个账号在调用方指定时间窗口内的本地用量查询。
|
||||
/// 某个账号在调用方指定时间窗口内的本地用量查询
|
||||
///
|
||||
/// `key` 只用于把聚合结果关联回调用方的窗口,不承载 Provider 私有语义。
|
||||
/// `key` 只用于把聚合结果关联回调用方的窗口,不承载 Provider 私有语义
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountUsageWindowQuery {
|
||||
pub account_id: String,
|
||||
@@ -201,7 +171,7 @@ pub struct AccountUsageWindowQuery {
|
||||
pub range: TimeRange,
|
||||
}
|
||||
|
||||
/// 一个账号时间窗口用量查询的结果。
|
||||
/// 一个账号时间窗口用量查询的结果
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountUsageWindowResult {
|
||||
pub account_id: String,
|
||||
@@ -209,7 +179,7 @@ pub struct AccountUsageWindowResult {
|
||||
pub usage: AccountUsage,
|
||||
}
|
||||
|
||||
/// 账号列表页所需的完整存储事实。
|
||||
/// 账号列表页所需的完整存储事实
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountPage {
|
||||
pub config_revision: Revision,
|
||||
@@ -218,7 +188,7 @@ pub struct AccountPage {
|
||||
pub summary: AccountSummary,
|
||||
}
|
||||
|
||||
/// 同一状态快照下的账号事实与唯一状态投影。
|
||||
/// 同一状态快照下的账号事实与唯一状态投影
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountPageItem {
|
||||
pub account: AccountRecord,
|
||||
@@ -226,33 +196,20 @@ pub struct AccountPageItem {
|
||||
pub capacity: AccountCapacity,
|
||||
}
|
||||
|
||||
/// 网关配置的账号并发上限与查询时的占用,不包含排队请求或上游隐藏限制。
|
||||
/// 网关配置的账号并发上限与查询时的占用,不包含排队请求或上游隐藏限制
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountCapacity {
|
||||
/// 实时租约读取失败时为 `None`,不能当作空闲。
|
||||
/// 实时租约读取失败时为 `None`,不能当作空闲
|
||||
pub used_slots: Option<u64>,
|
||||
/// 应用账号覆盖或全局默认值后的上限;`None` 表示不限。
|
||||
/// 应用账号覆盖或全局默认值后的上限;`None` 表示不限
|
||||
pub total_slots: Option<u64>,
|
||||
}
|
||||
|
||||
/// 统一账号目录的全局状态计数,不受当前筛选和分页影响。
|
||||
///
|
||||
/// 计数与 [`AccountStatus`] 一一对应,由 store 按派生状态聚合。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountSummary {
|
||||
pub total: u64,
|
||||
pub normal: u64,
|
||||
pub quota_exhausted: u64,
|
||||
pub rate_limited: u64,
|
||||
pub disabled: u64,
|
||||
pub error: u64,
|
||||
}
|
||||
|
||||
/// 账号可编辑事实的一次性替换命令。
|
||||
/// 账号可编辑事实的一次性替换命令
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateAccount {
|
||||
pub account_id: String,
|
||||
/// 缺省保留备注;空字符串清空备注。
|
||||
/// 缺省保留备注;空字符串清空备注
|
||||
pub notes: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
@@ -262,14 +219,14 @@ pub struct UpdateAccount {
|
||||
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
|
||||
}
|
||||
|
||||
/// 账号更新结果。
|
||||
/// 账号更新结果
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountUpdateResult {
|
||||
pub config_revision: Revision,
|
||||
pub account_id: gateway_core::account::ProviderAccountId,
|
||||
}
|
||||
|
||||
/// 仅修改显式字段的批量账号设置命令。
|
||||
/// 仅修改显式字段的批量账号设置命令
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BatchUpdateAccounts {
|
||||
pub account_ids: Vec<String>,
|
||||
@@ -281,20 +238,20 @@ pub struct BatchUpdateAccounts {
|
||||
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
|
||||
}
|
||||
|
||||
/// 批量账号更新结果。
|
||||
/// 批量账号更新结果
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountsUpdateResult {
|
||||
pub config_revision: Revision,
|
||||
pub account_ids: Vec<gateway_core::account::ProviderAccountId>,
|
||||
}
|
||||
|
||||
/// 账号批量删除命令。
|
||||
/// 账号批量删除命令
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteAccounts {
|
||||
pub account_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// 账号连接测试的语义事件。
|
||||
/// 账号连接测试的语义事件
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AccountConnectionTestEvent {
|
||||
Started {
|
||||
@@ -323,6 +280,6 @@ pub enum AccountConnectionTestEvent {
|
||||
},
|
||||
}
|
||||
|
||||
/// 每次连接测试独占的有限事件流。
|
||||
/// 每次连接测试独占的有限事件流
|
||||
pub type AccountConnectionTestEventStream =
|
||||
Pin<Box<dyn Stream<Item = AccountConnectionTestEvent> + Send + 'static>>;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! 统计投影的新鲜度、覆盖范围与预计算费用合同
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::observability::{DecimalAmount, RequestMetricPoint, UsageOverview};
|
||||
|
||||
/// 数据可见性状态,不以缺失投影冒充零统计
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum AnalyticsState {
|
||||
Ready,
|
||||
#[default]
|
||||
Building,
|
||||
Lagging,
|
||||
Stale,
|
||||
}
|
||||
|
||||
impl AnalyticsState {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ready => "ready",
|
||||
Self::Building => "building",
|
||||
Self::Lagging => "lagging",
|
||||
Self::Stale => "stale",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 与统计结果在同一读取快照内取得的覆盖和版本事实
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AnalyticsMetadata {
|
||||
pub coverage_start: Option<DateTime<Utc>>,
|
||||
pub coverage_end: Option<DateTime<Utc>>,
|
||||
pub generated_at: DateTime<Utc>,
|
||||
pub lag_seconds: u64,
|
||||
pub state: AnalyticsState,
|
||||
pub percentiles_approximate: bool,
|
||||
pub schema_version: u32,
|
||||
pub histogram_version: u32,
|
||||
/// 派生数据版本供缓存判断失效,不作为源事务提交顺序
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
/// 单次请求经 Provider 费用合同确认后可累加的 USD 情景金额
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct UsageBillingProjection {
|
||||
pub standard_amount: Option<DecimalAmount>,
|
||||
pub no_cache_amount: Option<DecimalAmount>,
|
||||
}
|
||||
|
||||
/// 时间桶内的情景费用,未知金额不改写为零
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageProjectedCostPoint {
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
pub standard_amount: Option<DecimalAmount>,
|
||||
pub no_cache_amount: Option<DecimalAmount>,
|
||||
}
|
||||
|
||||
/// 同一数据库快照内的完整洞察输入
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageInsightsObservation {
|
||||
pub overview: UsageOverview,
|
||||
pub trend: Vec<RequestMetricPoint>,
|
||||
pub costs: Vec<UsageProjectedCostPoint>,
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 管理写入的审计分类;适配器只能补充事务实际写入的字段与实体标识。
|
||||
//! 管理写入的审计分类;适配器只能补充事务实际写入的字段与实体标识
|
||||
|
||||
use super::{
|
||||
MutationActor, MutationContext,
|
||||
@@ -112,7 +112,7 @@ impl MutationAuditOperation {
|
||||
}
|
||||
}
|
||||
|
||||
/// 分类与稳定实体标识共同组成审计意图,字段差异由同一提交事务补齐。
|
||||
/// 分类与稳定实体标识共同组成审计意图,字段差异由同一提交事务补齐
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MutationAuditIntent<'a> {
|
||||
pub operation: MutationAuditOperation,
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
//! 控制面认证、统一会话和管理员安全审计事实。
|
||||
//! 控制面认证、统一会话和管理员安全审计事实
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::{engine::execution::ClientAuthenticationError, policy::ClientApiKeyId};
|
||||
|
||||
use super::{MutationActor, MutationContext, Revision};
|
||||
|
||||
/// 会话审计使用的稳定管理员标识。
|
||||
/// 会话审计使用的稳定管理员标识
|
||||
#[must_use]
|
||||
pub fn admin_session_actor_ref(admin_user_id: &str) -> String {
|
||||
format!("admin:{admin_user_id}")
|
||||
}
|
||||
|
||||
/// 已认证的管理主体。
|
||||
/// 已认证的管理主体
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AdminPrincipal {
|
||||
Session {
|
||||
admin_user_id: String,
|
||||
},
|
||||
ApiKey,
|
||||
/// 安装者完整信任的插件;身份仅由内部 HTTP 分派端口签发。
|
||||
/// 安装者完整信任的插件;身份仅由内部 HTTP 分派端口签发
|
||||
Plugin {
|
||||
instance_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 传给管理用例的安全请求上下文。
|
||||
/// 传给管理用例的安全请求上下文
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AdminRequestContext {
|
||||
pub principal: AdminPrincipal,
|
||||
@@ -48,7 +48,7 @@ impl AdminRequestContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录方式只决定凭据验证流程,不能直接授予会话权限。
|
||||
/// 登录方式只决定凭据验证流程,不能直接授予会话权限
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum LoginCommand {
|
||||
Admin {
|
||||
@@ -76,24 +76,29 @@ impl std::fmt::Debug for LoginCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录成功后返回的会话事实。
|
||||
/// 登录或刷新后仅向当前浏览器交付的会话凭据
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct LoginResult {
|
||||
pub struct SessionGrant {
|
||||
pub session_id: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub session: AuthSession,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LoginResult {
|
||||
impl std::fmt::Debug for SessionGrant {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("LoginResult")
|
||||
.debug_struct("SessionGrant")
|
||||
.field("session_id", &"[REDACTED]")
|
||||
.field(
|
||||
"refresh_token",
|
||||
&self.refresh_token.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.field("session", &self.session)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录状态机可被 API 精确映射的失败类型。
|
||||
/// 登录状态机可被 API 精确映射的失败类型
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum LoginError {
|
||||
#[error("invalid login credentials")]
|
||||
@@ -114,7 +119,7 @@ impl From<ClientAuthenticationError> for LoginError {
|
||||
}
|
||||
}
|
||||
|
||||
/// 服务端已验证的身份绑定,不保存密码或原始 API Key。
|
||||
/// 服务端已验证的身份绑定,不保存密码或原始 API Key
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SessionSubject {
|
||||
Admin {
|
||||
@@ -126,7 +131,7 @@ pub enum SessionSubject {
|
||||
},
|
||||
}
|
||||
|
||||
/// 仅已登录管理员可以修改自己的密码。
|
||||
/// 仅已登录管理员可以修改自己的密码
|
||||
pub struct ChangePassword {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
@@ -138,14 +143,44 @@ impl std::fmt::Debug for ChangePassword {
|
||||
}
|
||||
}
|
||||
|
||||
/// 两种登录方式共用的固定有效期会话。
|
||||
/// 访问有效期与逻辑会话;没有续期凭据的会话保持固定期限
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthSession {
|
||||
pub subject: SessionSubject,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub absolute_expires_at: Option<DateTime<Utc>>,
|
||||
pub renewal: Option<SessionRenewal>,
|
||||
}
|
||||
|
||||
/// 安全审计事件类型。
|
||||
/// 长效登录的凭据摘要与续期窗口;访问到期不删除整段登录
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SessionRenewal {
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub access_digest: [u8; 32],
|
||||
pub refresh_digest: [u8; 32],
|
||||
pub previous: Option<SessionRefreshReplay>,
|
||||
}
|
||||
|
||||
/// 仅保存上一代摘要及派生盐;响应丢失时须持有上一代凭据才能重建同一响应
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SessionRefreshReplay {
|
||||
pub digest: [u8; 32],
|
||||
pub salt: [u8; 32],
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl AuthSession {
|
||||
#[must_use]
|
||||
pub fn retained_until(&self) -> DateTime<Utc> {
|
||||
self.renewal
|
||||
.as_ref()
|
||||
.map_or(self.expires_at, |renewal| renewal.expires_at)
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全审计事件类型
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuditActorKind {
|
||||
AdminSession,
|
||||
@@ -154,7 +189,7 @@ pub enum AuditActorKind {
|
||||
Anonymous,
|
||||
}
|
||||
|
||||
/// 管理写操作留下的最小审计事实。
|
||||
/// 管理写操作留下的最小审计事实
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AdminAuditEvent {
|
||||
pub id: String,
|
||||
|
||||
@@ -1,34 +1,33 @@
|
||||
//! 备份领域的稳定错误码。
|
||||
//! 备份领域的稳定错误码
|
||||
|
||||
/// 配置或参数非法。
|
||||
/// 配置或参数非法
|
||||
pub const INVALID_CONFIG: &str = "backup.invalid_config";
|
||||
/// Cron 表达式非法。
|
||||
/// Cron 表达式非法
|
||||
pub const INVALID_CRON: &str = "backup.invalid_cron";
|
||||
/// IANA 时区非法。
|
||||
pub const INVALID_TIMEZONE: &str = "backup.invalid_timezone";
|
||||
/// 记录不存在。
|
||||
/// IANA 时区非法
|
||||
/// 记录不存在
|
||||
pub const RECORD_NOT_FOUND: &str = "backup.record_not_found";
|
||||
/// 已存在活跃任务,拒绝创建。
|
||||
/// 已存在活跃任务,拒绝创建
|
||||
pub const ACTIVE_TASK_CONFLICT: &str = "backup.active_task_conflict";
|
||||
/// 记录状态与期望不符,无法迁移。
|
||||
/// 记录状态与期望不符,无法迁移
|
||||
pub const STATE_CONFLICT: &str = "backup.state_conflict";
|
||||
/// 已有记录时禁止切换存储目标。
|
||||
/// 已有记录时禁止切换存储目标
|
||||
pub const STORAGE_IDENTITY_LOCKED: &str = "backup.storage_identity_locked";
|
||||
/// `pg_dump` 非零退出或失败。
|
||||
/// `pg_dump` 非零退出或失败
|
||||
pub const PG_DUMP_FAILED: &str = "backup.pg_dump_failed";
|
||||
/// 暂存磁盘空间不足。
|
||||
/// 暂存磁盘空间不足
|
||||
pub const STAGING_SPACE_EXHAUSTED: &str = "backup.staging_space_exhausted";
|
||||
/// S3 认证失败。
|
||||
/// S3 认证失败
|
||||
pub const S3_AUTH_FAILED: &str = "backup.s3_auth_failed";
|
||||
/// S3 权限拒绝。
|
||||
/// S3 权限拒绝
|
||||
pub const S3_PERMISSION_DENIED: &str = "backup.s3_permission_denied";
|
||||
/// S3 上传失败。
|
||||
/// S3 上传失败
|
||||
pub const S3_UPLOAD_FAILED: &str = "backup.s3_upload_failed";
|
||||
/// 远端对象校验失败。
|
||||
/// 远端对象校验失败
|
||||
pub const REMOTE_VERIFICATION_FAILED: &str = "backup.remote_verification_failed";
|
||||
/// 任务被取消。
|
||||
/// 任务被取消
|
||||
pub const CANCELLED: &str = "backup.cancelled";
|
||||
/// 仓储或对象存储暂不可用。
|
||||
/// 仓储或对象存储暂不可用
|
||||
pub const STORE_UNAVAILABLE: &str = "backup.store_unavailable";
|
||||
/// 上游 S3 服务返回无效或失败响应。
|
||||
/// 上游 S3 服务返回无效或失败响应
|
||||
pub const UPSTREAM_FAILURE: &str = "backup.upstream_failure";
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! S3 备份领域模型、状态机与稳定错误。
|
||||
//! S3 备份领域模型、状态机与稳定错误
|
||||
//!
|
||||
//! 领域类型只携带事实,不承载基础设施细节。Secret 使用 [`secrecy::SecretString`],
|
||||
//! 全流程不实现 `Serialize`,避免被错误地写入 wire 或日志。管理员身份等审计事实
|
||||
//! 只存在于 `admin_audit_events`,本模型不重复保存。
|
||||
//! 领域类型只携带事实,不承载基础设施细节
|
||||
//! Secret 使用 [`secrecy::SecretString`],
|
||||
//! 全流程不实现 `Serialize`,避免被错误地写入 wire 或日志
|
||||
//! 管理员身份等审计事实
|
||||
//! 只存在于 `admin_audit_events`,本模型不重复保存
|
||||
|
||||
use std::fmt;
|
||||
|
||||
@@ -12,7 +14,7 @@ use uuid::Uuid;
|
||||
|
||||
use super::PageSize;
|
||||
|
||||
/// 备份触发来源。
|
||||
/// 备份触发来源
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BackupTriggerKind {
|
||||
Manual,
|
||||
@@ -20,7 +22,7 @@ pub enum BackupTriggerKind {
|
||||
}
|
||||
|
||||
impl BackupTriggerKind {
|
||||
/// 稳定持久化值。
|
||||
/// 稳定持久化值
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
@@ -29,7 +31,7 @@ impl BackupTriggerKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析持久化值。
|
||||
/// 解析持久化值
|
||||
#[must_use]
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
@@ -46,11 +48,11 @@ impl fmt::Display for BackupTriggerKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// 备份任务状态机(六状态)。
|
||||
/// 备份任务状态机(六状态)
|
||||
///
|
||||
/// 迁移 `backup_records_lifecycle_ck` 与字段空值语义共同定义合法持久化行;
|
||||
/// [`Self::allows_transition_to`] 只约束领域内的显式状态迁移,两层都必须满足。
|
||||
/// 删除成功后记录行被硬删除,因此不存在 `deleted` 终态。
|
||||
/// [`Self::allows_transition_to`] 只约束领域内的显式状态迁移,两层都必须满足
|
||||
/// 删除成功后记录行被硬删除,因此不存在 `deleted` 终态
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum BackupStatus {
|
||||
Queued,
|
||||
@@ -62,7 +64,7 @@ pub enum BackupStatus {
|
||||
}
|
||||
|
||||
impl BackupStatus {
|
||||
/// 稳定持久化值。
|
||||
/// 稳定持久化值
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
@@ -75,7 +77,7 @@ impl BackupStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析持久化值。
|
||||
/// 解析持久化值
|
||||
#[must_use]
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
@@ -89,19 +91,19 @@ impl BackupStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// 该状态是否消耗全局活跃名额。
|
||||
/// 该状态是否消耗全局活跃名额
|
||||
#[must_use]
|
||||
pub const fn is_active(self) -> bool {
|
||||
matches!(self, Self::Queued | Self::Dumping | Self::Uploading)
|
||||
}
|
||||
|
||||
/// 该状态是否允许进入删除流程。
|
||||
/// 该状态是否允许进入删除流程
|
||||
#[must_use]
|
||||
pub const fn can_be_deleted(self) -> bool {
|
||||
matches!(self, Self::Completed | Self::Failed)
|
||||
}
|
||||
|
||||
/// 领域内允许的显式迁移。
|
||||
/// 领域内允许的显式迁移
|
||||
#[must_use]
|
||||
pub fn allows_transition_to(self, target: BackupStatus) -> bool {
|
||||
matches!(
|
||||
@@ -124,7 +126,7 @@ impl fmt::Display for BackupStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// 已通过领域状态机校验的显式迁移。
|
||||
/// 已通过领域状态机校验的显式迁移
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct BackupStatusTransition {
|
||||
from: BackupStatus,
|
||||
@@ -132,26 +134,26 @@ pub struct BackupStatusTransition {
|
||||
}
|
||||
|
||||
impl BackupStatusTransition {
|
||||
/// 校验并构造显式迁移;非法状态边返回 `None`。
|
||||
/// 校验并构造显式迁移;非法状态边返回 `None`
|
||||
#[must_use]
|
||||
pub fn try_new(from: BackupStatus, to: BackupStatus) -> Option<Self> {
|
||||
from.allows_transition_to(to).then_some(Self { from, to })
|
||||
}
|
||||
|
||||
/// 迁移要求的当前状态。
|
||||
/// 迁移要求的当前状态
|
||||
#[must_use]
|
||||
pub const fn from(self) -> BackupStatus {
|
||||
self.from
|
||||
}
|
||||
|
||||
/// 迁移完成后的目标状态。
|
||||
/// 迁移完成后的目标状态
|
||||
#[must_use]
|
||||
pub const fn to(self) -> BackupStatus {
|
||||
self.to
|
||||
}
|
||||
}
|
||||
|
||||
/// S3 存储配置更新命令;`secret_access_key` 为 `None` 表示保留旧值。
|
||||
/// S3 存储配置更新命令;`secret_access_key` 为 `None` 表示保留旧值
|
||||
#[derive(Clone)]
|
||||
pub struct UpdateBackupStorageCommand {
|
||||
pub endpoint: String,
|
||||
@@ -178,19 +180,18 @@ impl fmt::Debug for UpdateBackupStorageCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// 调度配置更新命令。
|
||||
/// 调度配置更新命令
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateBackupScheduleCommand {
|
||||
pub schedule_enabled: bool,
|
||||
pub cron_expression: String,
|
||||
pub schedule_timezone: String,
|
||||
pub retention_days: u32,
|
||||
pub retention_count: u32,
|
||||
}
|
||||
|
||||
/// 完整保存的备份设置(含 Secret)。
|
||||
/// 完整保存的备份设置(含 Secret)
|
||||
///
|
||||
/// 只从仓储读取;不实现 `Serialize`,防止 Secret 进入 wire 或日志。
|
||||
/// 只从仓储读取;不实现 `Serialize`,防止 Secret 进入 wire 或日志
|
||||
#[derive(Clone)]
|
||||
pub struct BackupSettings {
|
||||
pub storage_revision: u64,
|
||||
@@ -203,6 +204,7 @@ pub struct BackupSettings {
|
||||
pub force_path_style: bool,
|
||||
pub schedule_enabled: bool,
|
||||
pub cron_expression: Option<String>,
|
||||
/// 记录持久化游标采用的时区,由服务端维护,不是独立部署设置
|
||||
pub schedule_timezone: Option<String>,
|
||||
pub retention_days: u32,
|
||||
pub retention_count: u32,
|
||||
@@ -236,7 +238,7 @@ impl fmt::Debug for BackupSettings {
|
||||
}
|
||||
|
||||
impl BackupSettings {
|
||||
/// S3 存储配置是否完整(bucket/region/密钥/前缀齐全)。
|
||||
/// S3 存储配置是否完整(bucket/region/密钥/前缀齐全)
|
||||
#[must_use]
|
||||
pub fn storage_configured(&self) -> bool {
|
||||
self.endpoint.is_some()
|
||||
@@ -247,14 +249,14 @@ impl BackupSettings {
|
||||
&& self.prefix.is_some()
|
||||
}
|
||||
|
||||
/// 探测是否已对当前 revision 成功;`last_verified_at` 非空即表示当前配置已通过探针。
|
||||
/// 探测是否已对当前 revision 成功;`last_verified_at` 非空即表示当前配置已通过探针
|
||||
#[must_use]
|
||||
pub fn storage_verified(&self) -> bool {
|
||||
self.last_verified_at.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// 已经校验并可用于对象存储操作的存储配置快照。
|
||||
/// 已经校验并可用于对象存储操作的存储配置快照
|
||||
#[derive(Clone)]
|
||||
pub struct BackupStorageConfig {
|
||||
pub storage_revision: u64,
|
||||
@@ -284,7 +286,7 @@ impl fmt::Debug for BackupStorageConfig {
|
||||
}
|
||||
|
||||
impl BackupStorageConfig {
|
||||
/// 从完整设置推导对象存储配置;设置不完整时返回 `None`。
|
||||
/// 从完整设置推导对象存储配置;设置不完整时返回 `None`
|
||||
#[must_use]
|
||||
pub fn from_settings(settings: &BackupSettings) -> Option<Self> {
|
||||
Some(Self {
|
||||
@@ -300,7 +302,7 @@ impl BackupStorageConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条备份记录。
|
||||
/// 一条备份记录
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackupRecord {
|
||||
pub id: String,
|
||||
@@ -315,13 +317,13 @@ pub struct BackupRecord {
|
||||
pub error_message: Option<String>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
/// 创建时确定的过期时间;手工和计划备份均可设置,到期自动清理。
|
||||
/// 创建时确定的过期时间;手工和计划备份均可设置,到期自动清理
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 备份记录分页查询。
|
||||
/// 备份记录分页查询
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackupRecordListQuery {
|
||||
pub page: u32,
|
||||
@@ -330,7 +332,7 @@ pub struct BackupRecordListQuery {
|
||||
pub trigger: Option<BackupTriggerKind>,
|
||||
}
|
||||
|
||||
/// 备份记录分页结果。
|
||||
/// 备份记录分页结果
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackupRecordPage {
|
||||
pub items: Vec<BackupRecord>,
|
||||
@@ -340,7 +342,7 @@ pub struct BackupRecordPage {
|
||||
}
|
||||
|
||||
impl BackupRecordPage {
|
||||
/// 计算总页数;空集合视为一页。
|
||||
/// 计算总页数;空集合视为一页
|
||||
#[must_use]
|
||||
pub fn total_pages(&self) -> u32 {
|
||||
let size = u64::from(self.page_size.get());
|
||||
@@ -352,7 +354,7 @@ impl BackupRecordPage {
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建备份任务的持久化数据;id 与对象 key 由领域生成。
|
||||
/// 创建备份任务的持久化数据;id 与对象 key 由领域生成
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackupRecordSeed {
|
||||
pub id: String,
|
||||
@@ -362,7 +364,7 @@ pub struct BackupRecordSeed {
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 连接测试结果;只携带稳定阶段与脱敏消息。
|
||||
/// 连接测试结果;只携带稳定阶段与脱敏消息
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConnectionTestResult {
|
||||
pub ok: bool,
|
||||
@@ -371,7 +373,7 @@ pub struct ConnectionTestResult {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 对象存储探测阶段。
|
||||
/// 对象存储探测阶段
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConnectionTestStage {
|
||||
PutObject,
|
||||
@@ -381,7 +383,7 @@ pub enum ConnectionTestStage {
|
||||
}
|
||||
|
||||
impl ConnectionTestStage {
|
||||
/// 稳定 wire 值。
|
||||
/// 稳定 wire 值
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
@@ -393,10 +395,10 @@ impl ConnectionTestStage {
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传到对象存储并期望远端校验的归档 metadata。
|
||||
/// 上传到对象存储并期望远端校验的归档 metadata
|
||||
///
|
||||
/// `size_bytes` 在上传时不写入 metadata,但 `HeadObject` 会返回远端大小,
|
||||
/// 用于与本地归档校验一致。
|
||||
/// 用于与本地归档校验一致
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackupObjectMetadata {
|
||||
pub backup_id: String,
|
||||
@@ -406,7 +408,7 @@ pub struct BackupObjectMetadata {
|
||||
}
|
||||
|
||||
impl BackupObjectMetadata {
|
||||
/// 构造上传 metadata。
|
||||
/// 构造上传 metadata
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
backup_id: String,
|
||||
@@ -423,7 +425,7 @@ impl BackupObjectMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
/// 短时下载地址结果。
|
||||
/// 短时下载地址结果
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct DownloadUrlResult {
|
||||
pub url: String,
|
||||
@@ -442,10 +444,11 @@ impl fmt::Debug for DownloadUrlResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// 备份基础设施错误:只携带稳定错误码与脱敏消息。
|
||||
/// 备份基础设施错误:只携带稳定错误码与脱敏消息
|
||||
///
|
||||
/// 该类型不构成第二套 kind 枚举;`gateway-admin` 边界统一映射为既有
|
||||
/// `AdminErrorKind`。稳定 `error_code` 同时用于任务记录、日志与 UI。
|
||||
/// `AdminErrorKind`
|
||||
/// 稳定 `error_code` 同时用于任务记录、日志与 UI
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("{message}")]
|
||||
pub struct BackupError {
|
||||
@@ -454,32 +457,52 @@ pub struct BackupError {
|
||||
}
|
||||
|
||||
impl BackupError {
|
||||
/// 构造基础设施错误。
|
||||
/// 构造基础设施错误
|
||||
#[must_use]
|
||||
pub const fn new(code: &'static str, message: String) -> Self {
|
||||
Self { code, message }
|
||||
}
|
||||
|
||||
/// 稳定错误码,例如 `backup.s3_auth_failed`。
|
||||
/// 稳定错误码,例如 `backup.s3_auth_failed`
|
||||
#[must_use]
|
||||
pub const fn code(&self) -> &'static str {
|
||||
self.code
|
||||
}
|
||||
|
||||
/// 脱敏后的可操作消息。
|
||||
/// 脱敏后的可操作消息
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
/// 清理失败附加到原始终态,不覆盖上传失败或主动取消的分类
|
||||
#[must_use]
|
||||
pub fn with_cleanup_failure(
|
||||
mut self,
|
||||
stage: &'static str,
|
||||
error: &Self,
|
||||
retry_on_expiration: bool,
|
||||
) -> Self {
|
||||
let recovery = if retry_on_expiration {
|
||||
"等待备份到期清理"
|
||||
} else {
|
||||
"未安排自动重试"
|
||||
};
|
||||
self.message.push_str(&format!(
|
||||
";清理阶段 {stage} 失败({}),{recovery}",
|
||||
error.code()
|
||||
));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造稳定错误码常量。
|
||||
/// 构造稳定错误码常量
|
||||
pub mod code;
|
||||
|
||||
/// 构建对象 key:`{prefix}/YYYY/MM/DD/codex-proxy-rs_{UTC seconds}_{backup id}.dump`。
|
||||
/// 构建对象 key:`{prefix}/YYYY/MM/DD/codex-proxy-rs_{UTC seconds}_{backup id}.dump`
|
||||
///
|
||||
/// 规范化规则:prefix 不得为空、不得以前导 `/` 开头、不得包含 `..`、反斜杠或控制字符;
|
||||
/// 输出统一使用 `/` 分隔且不含空段。
|
||||
/// 输出统一使用 `/` 分隔且不含空段
|
||||
pub fn build_object_key(
|
||||
prefix: &str,
|
||||
backup_id: &str,
|
||||
@@ -496,22 +519,22 @@ pub fn build_object_key(
|
||||
))
|
||||
}
|
||||
|
||||
/// 下载文件名:`codex-proxy-rs_{短 id}.dump`,与对象 key 的文件名一致。
|
||||
/// 下载文件名:`codex-proxy-rs_{短 id}.dump`,与对象 key 的文件名一致
|
||||
#[must_use]
|
||||
pub fn build_download_file_name(backup_id: &str) -> String {
|
||||
format!("codex-proxy-rs_{}.dump", short_backup_id(backup_id))
|
||||
}
|
||||
|
||||
/// 截取备份 id 的可读短标识:去掉 `backup_` 前缀,最多保留 8 位 hex。
|
||||
/// 截取备份 id 的可读短标识:去掉 `backup_` 前缀,最多保留 8 位 hex
|
||||
#[must_use]
|
||||
fn short_backup_id(backup_id: &str) -> String {
|
||||
let hex = backup_id.strip_prefix("backup_").unwrap_or(backup_id);
|
||||
hex.get(..8).unwrap_or(hex).to_owned()
|
||||
}
|
||||
|
||||
/// 规范化并校验对象 prefix。
|
||||
/// 规范化并校验对象 prefix
|
||||
///
|
||||
/// 返回去掉尾部 `/` 的规范 prefix;空串、前导 `/`、`..` 段、反斜杠和控制字符均拒绝。
|
||||
/// 返回去掉尾部 `/` 的规范 prefix;空串、前导 `/`、`..` 段、反斜杠和控制字符均拒绝
|
||||
pub fn normalize_prefix(prefix: &str) -> Result<String, BackupError> {
|
||||
if prefix.is_empty() || prefix.starts_with('/') {
|
||||
return Err(BackupError::new(
|
||||
@@ -534,13 +557,13 @@ pub fn normalize_prefix(prefix: &str) -> Result<String, BackupError> {
|
||||
Ok(prefix.trim_end_matches('/').to_owned())
|
||||
}
|
||||
|
||||
/// 生成新的备份记录 id。
|
||||
/// 生成新的备份记录 id
|
||||
#[must_use]
|
||||
pub fn new_backup_id() -> String {
|
||||
format!("backup_{}", Uuid::now_v7().simple())
|
||||
}
|
||||
|
||||
/// 从触发类型与计划时间构造可持久化 seed;id 与对象 key 一次生成保持一致。
|
||||
/// 从触发类型与计划时间构造可持久化 seed;id 与对象 key 一次生成保持一致
|
||||
pub fn build_backup_seed(
|
||||
trigger_kind: BackupTriggerKind,
|
||||
scheduled_at: Option<DateTime<Utc>>,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! 管理设置页使用的 Codex 客户端下载信息。
|
||||
//! 管理设置页使用的 Codex 客户端下载信息
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Windows 安装包架构。
|
||||
/// Windows 安装包架构
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum ClientArchitecture {
|
||||
X64,
|
||||
@@ -19,7 +19,7 @@ impl ClientArchitecture {
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载地址的可信来源。
|
||||
/// 下载地址的可信来源
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClientDownloadSource {
|
||||
MicrosoftStore,
|
||||
@@ -36,7 +36,7 @@ impl ClientDownloadSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个已经过 Host 校验、可以直接交给浏览器下载的安装包。
|
||||
/// 一个已经过 Host 校验、可以直接交给浏览器下载的安装包
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientDownloadPackage {
|
||||
pub architecture: ClientArchitecture,
|
||||
@@ -48,7 +48,7 @@ pub struct ClientDownloadPackage {
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Codex Desktop Windows 下载解析结果。
|
||||
/// Codex Desktop Windows 下载解析结果
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodexDesktopWindowsDownloads {
|
||||
pub resolved_at: DateTime<Utc>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Client API Key 的 Command、Result 与安全秘密类型。
|
||||
//! Client API Key 的 Command、Result 与安全秘密类型
|
||||
|
||||
use std::{collections::BTreeMap, fmt, num::NonZeroU16};
|
||||
|
||||
@@ -20,16 +20,30 @@ pub type ProviderRequestProfileOverrides =
|
||||
pub type ProviderRequestProfileOverrideUpdates =
|
||||
BTreeMap<ProviderKind, Option<gateway_core::account::OpaqueProviderData>>;
|
||||
|
||||
/// Client Key 列表保持旧 HTTP 合同允许的完整非零 `u16` 页大小。
|
||||
/// Client Key 关联分组的数量与唯一性约束
|
||||
pub fn validate_group_ids(group_ids: &[AccountGroupId]) -> Result<(), AdminModelError> {
|
||||
if group_ids.len() > 1000
|
||||
|| group_ids
|
||||
.iter()
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.len()
|
||||
!= group_ids.len()
|
||||
{
|
||||
return Err(AdminModelError::InvalidClientKeyGroups);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Client Key 列表保持旧 HTTP 合同允许的完整非零 `u16` 页大小
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ClientKeyPageSize(NonZeroU16);
|
||||
|
||||
impl ClientKeyPageSize {
|
||||
/// 创建 1 至 65535 的 Client Key 页大小。
|
||||
/// 创建 1 至 65535 的 Client Key 页大小
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `value` 为零时返回 [`AdminModelError::InvalidClientKeyPageSize`]。
|
||||
/// `value` 为零时返回 [`AdminModelError::InvalidClientKeyPageSize`]
|
||||
pub fn new(value: u16) -> Result<Self, AdminModelError> {
|
||||
NonZeroU16::new(value)
|
||||
.map(Self)
|
||||
@@ -42,7 +56,7 @@ impl ClientKeyPageSize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Client Key 列表排序字段。
|
||||
/// Client Key 列表排序字段
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClientKeySortField {
|
||||
Name,
|
||||
@@ -51,21 +65,21 @@ pub enum ClientKeySortField {
|
||||
LastUsedAt,
|
||||
}
|
||||
|
||||
/// Client Key 列表排序方向。
|
||||
/// Client Key 列表排序方向
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SortDirection {
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
/// Client Key 列表排序规则。
|
||||
/// Client Key 列表排序规则
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ClientKeySort {
|
||||
pub field: ClientKeySortField,
|
||||
pub direction: SortDirection,
|
||||
}
|
||||
|
||||
/// 与排序字段绑定的游标值。
|
||||
/// 与排序字段绑定的游标值
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ClientKeyCursorValue {
|
||||
Name(String),
|
||||
@@ -74,7 +88,7 @@ pub enum ClientKeyCursorValue {
|
||||
LastUsedAt(Option<DateTime<Utc>>),
|
||||
}
|
||||
|
||||
/// Client Key 的稳定键集游标。
|
||||
/// Client Key 的稳定键集游标
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientKeyCursor {
|
||||
pub sort: ClientKeySort,
|
||||
@@ -82,7 +96,7 @@ pub struct ClientKeyCursor {
|
||||
pub id: ClientApiKeyId,
|
||||
}
|
||||
|
||||
/// Client Key 列表查询。
|
||||
/// Client Key 列表查询
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientKeyListQuery {
|
||||
pub cursor: Option<ClientKeyCursor>,
|
||||
@@ -91,7 +105,7 @@ pub struct ClientKeyListQuery {
|
||||
pub sort: ClientKeySort,
|
||||
}
|
||||
|
||||
/// 不含完整明文 Key 的管理投影。
|
||||
/// 不含完整明文 Key 的管理投影
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientKeyRecord {
|
||||
pub request_profile_overrides: ProviderRequestProfileOverrides,
|
||||
@@ -109,7 +123,7 @@ pub struct ClientKeyRecord {
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Client Key 列表页。
|
||||
/// Client Key 列表页
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientKeyPage {
|
||||
pub config_revision: Revision,
|
||||
@@ -118,7 +132,7 @@ pub struct ClientKeyPage {
|
||||
pub next_cursor: Option<ClientKeyCursor>,
|
||||
}
|
||||
|
||||
/// 仅在创建或显式读取密钥配置时返回的明文 Key;Debug 隐去明文。
|
||||
/// 仅在创建或显式读取密钥配置时返回的明文 Key;Debug 隐去明文
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct ClientKeySecret {
|
||||
pub record: ClientKeyRecord,
|
||||
@@ -150,7 +164,7 @@ impl fmt::Debug for ClientKeySecret {
|
||||
}
|
||||
}
|
||||
|
||||
/// API 提交的 Client Key 创建命令。
|
||||
/// API 提交的 Client Key 创建命令
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateClientKey {
|
||||
pub request_profile_overrides: ProviderRequestProfileOverrides,
|
||||
@@ -162,7 +176,7 @@ pub struct CreateClientKey {
|
||||
pub budget: ClientBudgetLimits,
|
||||
}
|
||||
|
||||
/// 管理用例生成 ID 与明文后的持久化命令。
|
||||
/// 管理用例生成 ID 与明文后的持久化命令
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct NewClientKey {
|
||||
pub request_profile_overrides: ProviderRequestProfileOverrides,
|
||||
@@ -187,7 +201,7 @@ impl fmt::Debug for NewClientKey {
|
||||
}
|
||||
}
|
||||
|
||||
/// 修改 Client Key 的公开策略字段。
|
||||
/// 修改 Client Key 的公开策略字段
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateClientKey {
|
||||
pub request_profile_override_updates: ProviderRequestProfileOverrideUpdates,
|
||||
@@ -200,14 +214,14 @@ pub struct UpdateClientKey {
|
||||
pub weekly_limit_usd: Option<gateway_core::metering::Decimal>,
|
||||
}
|
||||
|
||||
/// 修改 Client Key 启用状态。
|
||||
/// 修改 Client Key 启用状态
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetClientKeyEnabled {
|
||||
pub id: ClientApiKeyId,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// 管理员选择的金额重置范围。
|
||||
/// 管理员选择的金额重置范围
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClientKeyBudgetPeriod {
|
||||
Daily,
|
||||
@@ -215,14 +229,14 @@ pub enum ClientKeyBudgetPeriod {
|
||||
All,
|
||||
}
|
||||
|
||||
/// 清零所选窗口已用金额,保留限额、到期时间与历史费用。
|
||||
/// 清零所选窗口已用金额,保留限额、到期时间与历史费用
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResetClientKeyBudget {
|
||||
pub id: ClientApiKeyId,
|
||||
pub period: ClientKeyBudgetPeriod,
|
||||
}
|
||||
|
||||
/// 仅更新指定金额上限;省略的周期保持不变,不修改已用金额或窗口。
|
||||
/// 仅更新指定金额上限;省略的周期保持不变,不修改已用金额或窗口
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateClientKeyBudgetLimits {
|
||||
pub id: ClientApiKeyId,
|
||||
@@ -230,20 +244,20 @@ pub struct UpdateClientKeyBudgetLimits {
|
||||
pub weekly_limit_usd: Option<gateway_core::metering::Decimal>,
|
||||
}
|
||||
|
||||
/// 预算变更的调用来源;插件身份由 Runtime 给出,不能从插件请求反序列化。
|
||||
/// 预算变更的调用来源;插件身份由 Runtime 给出,不能从插件请求反序列化
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ClientKeyBudgetMutationOrigin {
|
||||
Admin,
|
||||
Plugin(PluginResourceOwner),
|
||||
}
|
||||
|
||||
/// 删除 Client Key。
|
||||
/// 删除 Client Key
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteClientKey {
|
||||
pub id: ClientApiKeyId,
|
||||
}
|
||||
|
||||
/// Client Key 创建结果;完整明文仅存在于该一次性结果中。
|
||||
/// Client Key 创建结果;完整明文仅存在于该一次性结果中
|
||||
pub struct CreatedClientKey {
|
||||
pub config_revision: Revision,
|
||||
pub secret: ClientKeySecret,
|
||||
@@ -259,7 +273,7 @@ impl fmt::Debug for CreatedClientKey {
|
||||
}
|
||||
}
|
||||
|
||||
/// Client Key 普通写操作结果。
|
||||
/// Client Key 普通写操作结果
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientKeyMutation {
|
||||
pub config_revision: Revision,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 进程内账号导入任务;只保存执行所需输入与可公开的结果。
|
||||
//! 进程内账号导入任务;只保存执行所需输入与可公开的结果
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::{account::ProviderAccountId, routing::ProviderKind};
|
||||
@@ -17,7 +17,7 @@ pub struct ImportTaskInput {
|
||||
#[derive(Debug)]
|
||||
pub struct SubmitImportTask {
|
||||
pub submission_id: Uuid,
|
||||
/// API 对完整输入计算摘要;用于区分丢失响应后的重试与修改后的新提交。
|
||||
/// API 对完整输入计算摘要;用于区分丢失响应后的重试与修改后的新提交
|
||||
pub fingerprint: [u8; 32],
|
||||
pub context: MutationContext,
|
||||
pub items: Vec<ImportTaskInput>,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! 单页 Key 用量的查询合同;身份范围不由调用方提供。
|
||||
//! 单页 Key 用量的查询合同;身份范围不由调用方提供
|
||||
|
||||
use super::{
|
||||
PageSize,
|
||||
client_keys::ClientKeyRecord,
|
||||
observability::{
|
||||
HealthTimeline, OpsErrorPage, RequestMetricPoint, TimeRange, UsageOverview, UsagePage,
|
||||
HealthTimeline, ObservabilityPageSize, OpsErrorPage, RequestMetricPoint, TimeRange,
|
||||
UsageOverview, UsagePage,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ pub struct KeyUsageRecordsQuery {
|
||||
pub usage: KeyUsageQuery,
|
||||
pub kind: KeyUsageRecordKind,
|
||||
pub current_page: u32,
|
||||
pub page_size: PageSize,
|
||||
pub page_size: ObservabilityPageSize,
|
||||
}
|
||||
|
||||
pub struct KeyUsageOverview {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! 管理控制面使用的 Command、Result 与稳定值对象。
|
||||
//! 管理控制面使用的 Command、Result 与稳定值对象
|
||||
|
||||
use std::num::{NonZeroU16, NonZeroU64};
|
||||
|
||||
pub mod account_groups;
|
||||
pub mod accounts;
|
||||
pub mod analytics;
|
||||
pub mod audit;
|
||||
pub mod auth;
|
||||
pub mod backup;
|
||||
@@ -24,7 +25,7 @@ pub mod retention;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
|
||||
/// 管理用例对外返回的稳定错误分类。
|
||||
/// 管理用例对外返回的稳定错误分类
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AdminErrorKind {
|
||||
Invalid,
|
||||
@@ -39,12 +40,31 @@ pub enum AdminErrorKind {
|
||||
Internal,
|
||||
}
|
||||
|
||||
/// 不携带基础设施细节、可安全返回给管理员的管理用例错误。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
impl AdminErrorKind {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Invalid => "invalid",
|
||||
Self::Forbidden => "forbidden",
|
||||
Self::Unauthorized => "unauthorized",
|
||||
Self::NotFound => "not_found",
|
||||
Self::Conflict => "conflict",
|
||||
Self::RateLimited => "rate_limited",
|
||||
Self::BadGateway => "bad_gateway",
|
||||
Self::UpstreamResultUnknown => "upstream_result_unknown",
|
||||
Self::Unavailable => "unavailable",
|
||||
Self::Internal => "internal",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理用例的安全消息与内部原因分离,原始来源不进入响应
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
#[error("{message}")]
|
||||
pub struct AdminError {
|
||||
kind: AdminErrorKind,
|
||||
message: String,
|
||||
source: Option<gateway_core::error::ErrorSource>,
|
||||
}
|
||||
|
||||
impl AdminError {
|
||||
@@ -53,15 +73,27 @@ impl AdminError {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_source(mut self, source: impl Into<gateway_core::error::ErrorSource>) -> Self {
|
||||
self.source = Some(source.into());
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn error_details(&self) -> Option<gateway_core::error::ErrorDetails> {
|
||||
gateway_core::error::ErrorDetails::capture(self.source.as_ref(), None, false)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> AdminErrorKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// 返回已脱敏、可公开给管理员的文案。
|
||||
/// 返回已脱敏、可公开给管理员的文案
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
@@ -108,7 +140,7 @@ impl AdminError {
|
||||
}
|
||||
}
|
||||
|
||||
/// PostgreSQL 中所有正整数 revision 的管理层表示。
|
||||
/// PostgreSQL 中所有正整数 revision 的管理层表示
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
|
||||
)]
|
||||
@@ -116,11 +148,11 @@ impl AdminError {
|
||||
pub struct Revision(NonZeroU64);
|
||||
|
||||
impl Revision {
|
||||
/// 创建正整数 revision。
|
||||
/// 创建正整数 revision
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `value` 为零时返回 [`AdminModelError::ZeroRevision`]。
|
||||
/// `value` 为零时返回 [`AdminModelError::ZeroRevision`]
|
||||
pub fn new(value: u64) -> Result<Self, AdminModelError> {
|
||||
NonZeroU64::new(value)
|
||||
.map(Self)
|
||||
@@ -133,18 +165,18 @@ impl Revision {
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理列表统一使用的受限页大小。
|
||||
/// 管理列表统一使用的受限页大小
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct PageSize(NonZeroU16);
|
||||
|
||||
impl PageSize {
|
||||
pub const MAX: u16 = 200;
|
||||
|
||||
/// 创建 1 至 200 的页大小。
|
||||
/// 创建 1 至 200 的页大小
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `value` 不在有效范围时返回 [`AdminModelError::InvalidPageSize`]。
|
||||
/// `value` 不在有效范围时返回 [`AdminModelError::InvalidPageSize`]
|
||||
pub fn new(value: u16) -> Result<Self, AdminModelError> {
|
||||
if value > Self::MAX {
|
||||
return Err(AdminModelError::InvalidPageSize(value));
|
||||
@@ -160,7 +192,7 @@ impl PageSize {
|
||||
}
|
||||
}
|
||||
|
||||
/// 可审计管理写操作的发起者。
|
||||
/// 可审计管理写操作的发起者
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum MutationActor {
|
||||
@@ -169,7 +201,7 @@ pub enum MutationActor {
|
||||
System,
|
||||
}
|
||||
|
||||
/// 管理写操作必须携带的审计上下文。
|
||||
/// 管理写操作必须携带的审计上下文
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub struct MutationContext {
|
||||
@@ -177,13 +209,19 @@ pub struct MutationContext {
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
/// 领域值对象构造失败。
|
||||
/// 领域值对象构造失败
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum AdminModelError {
|
||||
#[error("revision must be greater than zero")]
|
||||
ZeroRevision,
|
||||
#[error("page size {0} is outside 1..=200")]
|
||||
InvalidPageSize(u16),
|
||||
#[error("observability page size {0} is outside 1..=100")]
|
||||
InvalidObservabilityPageSize(u16),
|
||||
#[error("account group name or description is invalid")]
|
||||
InvalidAccountGroupFields,
|
||||
#[error("client key group IDs are duplicated or exceed 1000 entries")]
|
||||
InvalidClientKeyGroups,
|
||||
#[error("client key page size must be inside 1..=65535")]
|
||||
InvalidClientKeyPageSize,
|
||||
#[error("request outcome must be 1..=256 bytes without control characters")]
|
||||
|
||||
@@ -1,34 +1,88 @@
|
||||
//! 用量、成本、健康与错误诊断的 UTC 语义事实。
|
||||
//! 用量、成本、健康与错误诊断的 UTC 语义事实
|
||||
|
||||
use std::str::FromStr;
|
||||
use std::{num::NonZeroU16, str::FromStr};
|
||||
|
||||
use chrono::{DateTime, TimeDelta, Timelike as _, Utc};
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
|
||||
use super::{AdminModelError, PageSize};
|
||||
use super::AdminModelError;
|
||||
pub use super::analytics::{
|
||||
AnalyticsMetadata, AnalyticsState, UsageBillingProjection, UsageInsightsObservation,
|
||||
UsageProjectedCostPoint,
|
||||
};
|
||||
|
||||
/// 观测日界所用的东八区(UTC+8)固定偏移秒数。
|
||||
const CHINA_OFFSET_SECONDS: i64 = 8 * 60 * 60;
|
||||
/// 观测记录查询的页大小,由 HTTP、管理用例和存储端口共同使用
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ObservabilityPageSize(NonZeroU16);
|
||||
|
||||
/// 将 UTC 时刻截断到东八区(UTC+8)当日零点,返回值仍为 UTC。
|
||||
#[must_use]
|
||||
pub fn china_day_start(value: DateTime<Utc>) -> DateTime<Utc> {
|
||||
let elapsed = (value.timestamp() + CHINA_OFFSET_SECONDS).rem_euclid(24 * 60 * 60);
|
||||
value - TimeDelta::seconds(elapsed) - TimeDelta::nanoseconds(i64::from(value.nanosecond()))
|
||||
impl ObservabilityPageSize {
|
||||
pub const MAX: u16 = 100;
|
||||
|
||||
pub fn new(value: u16) -> Result<Self, AdminModelError> {
|
||||
if value > Self::MAX {
|
||||
return Err(AdminModelError::InvalidObservabilityPageSize(value));
|
||||
}
|
||||
NonZeroU16::new(value)
|
||||
.map(Self)
|
||||
.ok_or(AdminModelError::InvalidObservabilityPageSize(value))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn get(self) -> u16 {
|
||||
self.0.get()
|
||||
}
|
||||
}
|
||||
|
||||
/// 外部观测查询的 UTC 时间范围。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// 页面筛选表达自然日范围,具体 UTC 边界由部署时区解析
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum CalendarPeriod {
|
||||
Today,
|
||||
SevenDays,
|
||||
ThirtyDays,
|
||||
}
|
||||
|
||||
impl CalendarPeriod {
|
||||
pub fn parse(value: &str) -> Result<Self, AdminModelError> {
|
||||
match value {
|
||||
"today" => Ok(Self::Today),
|
||||
"7d" => Ok(Self::SevenDays),
|
||||
"30d" => Ok(Self::ThirtyDays),
|
||||
_ => Err(AdminModelError::InvalidTimeRange),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 外部观测查询的 UTC 时间范围
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct TimeRange {
|
||||
pub start: DateTime<Utc>,
|
||||
pub end: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TimeRange {
|
||||
/// 创建最长 366 天的正时间范围。
|
||||
pub fn calendar_at(
|
||||
period: CalendarPeriod,
|
||||
end: DateTime<Utc>,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> Result<Self, AdminModelError> {
|
||||
let days = match period {
|
||||
CalendarPeriod::Today => 0,
|
||||
CalendarPeriod::SevenDays => 6,
|
||||
CalendarPeriod::ThirtyDays => 29,
|
||||
};
|
||||
let start = timezone
|
||||
.days_before(end, days)
|
||||
.ok_or(AdminModelError::InvalidTimeRange)?;
|
||||
// 自然日刚开始时允许空快照,不借用前一天或伪造未来终点
|
||||
if start == end {
|
||||
return Ok(Self { start, end });
|
||||
}
|
||||
Self::new(start, end)
|
||||
}
|
||||
/// 创建最长 366 天的正时间范围
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 范围为空、反向或超过 366 天时返回错误。
|
||||
/// 范围为空、反向或超过 366 天时返回错误
|
||||
pub fn new(start: DateTime<Utc>, end: DateTime<Utc>) -> Result<Self, AdminModelError> {
|
||||
let duration = end.signed_duration_since(start);
|
||||
if duration <= TimeDelta::zero() || duration > TimeDelta::days(366) {
|
||||
@@ -38,8 +92,8 @@ impl TimeRange {
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求结果状态。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// 请求结果状态
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum RequestOutcome {
|
||||
Running,
|
||||
Succeeded,
|
||||
@@ -49,19 +103,19 @@ pub enum RequestOutcome {
|
||||
Other(OtherRequestOutcome),
|
||||
}
|
||||
|
||||
/// 未知但有界的请求结果值;私有字段禁止绕过 [`RequestOutcome::new`] 构造。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// 未知但有界的请求结果值;私有字段禁止绕过 [`RequestOutcome::new`] 构造
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct OtherRequestOutcome(String);
|
||||
|
||||
impl RequestOutcome {
|
||||
pub const MAX_BYTES: usize = 256;
|
||||
|
||||
/// 从持久化或 wire 字符串创建可扩展结果语义。
|
||||
/// 从持久化或 wire 字符串创建可扩展结果语义
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 空值、超过 256 字节或含控制字符时返回
|
||||
/// [`AdminModelError::InvalidRequestOutcome`]。
|
||||
/// [`AdminModelError::InvalidRequestOutcome`]
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, AdminModelError> {
|
||||
let value = value.into();
|
||||
match value.as_str() {
|
||||
@@ -100,8 +154,8 @@ impl OtherRequestOutcome {
|
||||
}
|
||||
}
|
||||
|
||||
/// 用量记录过滤条件。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
/// 用量记录过滤条件
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
|
||||
pub struct UsageFilter {
|
||||
pub client_api_key_ref: Option<String>,
|
||||
pub request_id: Option<String>,
|
||||
@@ -118,16 +172,42 @@ pub struct UsageFilter {
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
/// 用量记录分页查询。
|
||||
/// 用量记录分页查询
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageQuery {
|
||||
pub range: TimeRange,
|
||||
pub filter: UsageFilter,
|
||||
pub current_page: u32,
|
||||
pub page_size: PageSize,
|
||||
pub page_size: ObservabilityPageSize,
|
||||
}
|
||||
|
||||
/// 运维错误过滤条件。
|
||||
/// Admin 确定概览的数量、趋势粒度及最近请求筛选,Store 只执行该选择
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct DashboardQuery {
|
||||
pub range: TimeRange,
|
||||
pub granularity: Granularity,
|
||||
pub account_limit: u16,
|
||||
pub recent_request_limit: u16,
|
||||
pub recent_request_filter: UsageFilter,
|
||||
}
|
||||
|
||||
impl DashboardQuery {
|
||||
#[must_use]
|
||||
pub fn new(range: TimeRange) -> Self {
|
||||
Self {
|
||||
range,
|
||||
granularity: Granularity::for_range(range),
|
||||
account_limit: 4,
|
||||
recent_request_limit: 10,
|
||||
recent_request_filter: UsageFilter {
|
||||
outcome: Some(RequestOutcome::Succeeded),
|
||||
..UsageFilter::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 运维错误过滤条件
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct OpsErrorFilter {
|
||||
pub client_api_key_ref: Option<String>,
|
||||
@@ -144,17 +224,17 @@ pub struct OpsErrorFilter {
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
/// 运维错误分页查询。
|
||||
/// 运维错误分页查询
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OpsErrorQuery {
|
||||
pub range: TimeRange,
|
||||
pub filter: OpsErrorFilter,
|
||||
pub current_page: u32,
|
||||
pub page_size: PageSize,
|
||||
pub page_size: ObservabilityPageSize,
|
||||
}
|
||||
|
||||
/// 用量诊断维度。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// 用量诊断维度
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum DiagnosticDimension {
|
||||
Provider,
|
||||
Model,
|
||||
@@ -165,7 +245,7 @@ pub enum DiagnosticDimension {
|
||||
Status,
|
||||
}
|
||||
|
||||
/// `numeric(20,10)` 的非负规范金额。
|
||||
/// `numeric(20,10)` 的非负规范金额
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct DecimalAmount(String);
|
||||
|
||||
@@ -175,13 +255,13 @@ impl DecimalAmount {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// 精确相加两个 `numeric(20,10)` 金额。
|
||||
/// 精确相加两个 `numeric(20,10)` 金额
|
||||
#[must_use]
|
||||
pub fn checked_add(&self, other: &Self) -> Option<Self> {
|
||||
Self::from_decimal(self.to_decimal()?.checked_add(other.to_decimal()?)?)
|
||||
}
|
||||
|
||||
/// 将金额按非零请求数均分,保留最多十位小数。
|
||||
/// 将金额按非零请求数均分,保留最多十位小数
|
||||
#[must_use]
|
||||
pub fn checked_div_u64(&self, divisor: u64) -> Option<Self> {
|
||||
Self::from_decimal(self.to_decimal()?.checked_div_u64(divisor)?)
|
||||
@@ -192,7 +272,7 @@ impl DecimalAmount {
|
||||
}
|
||||
|
||||
fn from_decimal(value: gateway_core::metering::Decimal) -> Option<Self> {
|
||||
Some(Self(value.canonical()))
|
||||
Self::from_str(&value.canonical()).ok()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,25 +314,25 @@ impl std::fmt::Display for DecimalAmount {
|
||||
}
|
||||
}
|
||||
|
||||
/// 单一币种的成本合计。
|
||||
/// 单一币种的成本合计
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CurrencyCost {
|
||||
pub currency: String,
|
||||
pub amount: DecimalAmount,
|
||||
}
|
||||
|
||||
/// PostgreSQL 连续百分位返回的非负有限毫秒值。
|
||||
/// PostgreSQL 连续百分位返回的非负有限毫秒值
|
||||
///
|
||||
/// 以 IEEE-754 bits 保存,既不丢失插值小数,也可安全实现 `Eq`。
|
||||
/// 以 IEEE-754 bits 保存,既不丢失插值小数,也可安全实现 `Eq`
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct PercentileMilliseconds(u64);
|
||||
|
||||
impl PercentileMilliseconds {
|
||||
/// 校验并保存一个毫秒百分位。
|
||||
/// 校验并保存一个毫秒百分位
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 非有限值或负值返回 [`AdminModelError::InvalidLatencyPercentile`]。
|
||||
/// 非有限值或负值返回 [`AdminModelError::InvalidLatencyPercentile`]
|
||||
pub fn new(value: f64) -> Result<Self, AdminModelError> {
|
||||
if !value.is_finite() || value < 0.0 {
|
||||
return Err(AdminModelError::InvalidLatencyPercentile);
|
||||
@@ -272,7 +352,7 @@ impl std::fmt::Debug for PercentileMilliseconds {
|
||||
}
|
||||
}
|
||||
|
||||
/// 延迟分布的三个稳定百分位。
|
||||
/// 延迟分布的三个稳定百分位
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct LatencyPercentiles {
|
||||
pub p50_ms: Option<PercentileMilliseconds>,
|
||||
@@ -280,21 +360,21 @@ pub struct LatencyPercentiles {
|
||||
pub p99_ms: Option<PercentileMilliseconds>,
|
||||
}
|
||||
|
||||
/// Provider 价格规则计算所需的持久请求事实。
|
||||
/// Provider 价格规则计算所需的持久请求事实
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderBillingInput {
|
||||
pub upstream_model_id: String,
|
||||
pub service_tier: Option<String>,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub cached_input_tokens: Option<u64>,
|
||||
pub cache_write_input_tokens: Option<u64>,
|
||||
pub total: CurrencyCost,
|
||||
}
|
||||
|
||||
/// 已完整交付且由 Provider 计算费用的持久请求事实。
|
||||
/// 已完整交付且由 Provider 计算费用的持久请求事实
|
||||
///
|
||||
/// 控制面仅保留通用事实,具体 Provider 负责校验已持久化总额并恢复标准费用。
|
||||
/// 控制面仅保留通用事实,具体 Provider 负责校验已持久化总额并恢复标准费用
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageCalculatedBillingFact {
|
||||
pub breakdown: Option<CalculatedBillingBreakdown>,
|
||||
@@ -304,12 +384,12 @@ pub struct UsageCalculatedBillingFact {
|
||||
pub service_tier: Option<String>,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub cached_input_tokens: Option<u64>,
|
||||
pub cache_write_input_tokens: Option<u64>,
|
||||
pub total: CurrencyCost,
|
||||
}
|
||||
|
||||
/// Provider 已确认的逐项费用与单价。
|
||||
/// Provider 已确认的逐项费用与单价
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CalculatedBillingBreakdown {
|
||||
pub long_context_billing_applied: bool,
|
||||
@@ -332,23 +412,23 @@ pub struct CalculatedBillingBreakdown {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImageBillingBreakdown {
|
||||
pub input_tokens: u64,
|
||||
pub cached_tokens: u64,
|
||||
pub cached_input_tokens: u64,
|
||||
pub input_amount: CurrencyCost,
|
||||
pub cache_read_amount: CurrencyCost,
|
||||
pub input_price_per_million: CurrencyCost,
|
||||
pub cache_read_price_per_million: CurrencyCost,
|
||||
}
|
||||
|
||||
/// 单次请求的费用语义。
|
||||
/// 单次请求的费用语义
|
||||
///
|
||||
/// Provider 上报费用或无法恢复逐项价格时保留总额;Provider 验证成功后升级为完整分解。
|
||||
/// Provider 上报费用或无法恢复逐项价格时保留总额;Provider 验证成功后升级为完整分解
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UsageBilling {
|
||||
Total { source: String, total: CurrencyCost },
|
||||
Calculated(Box<CalculatedBillingBreakdown>),
|
||||
}
|
||||
|
||||
/// 计费数据覆盖情况。
|
||||
/// 计费数据覆盖情况
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct CostCoverage {
|
||||
pub provider_reported_count: u64,
|
||||
@@ -359,7 +439,7 @@ pub struct CostCoverage {
|
||||
}
|
||||
|
||||
impl CostCoverage {
|
||||
/// Provider 直接上报或 Provider 规则完整计算的已知成本数。
|
||||
/// Provider 直接上报或 Provider 规则完整计算的已知成本数
|
||||
#[must_use]
|
||||
pub fn known_count(&self) -> u64 {
|
||||
self.provider_reported_count
|
||||
@@ -367,7 +447,7 @@ impl CostCoverage {
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求级聚合指标。
|
||||
/// 请求级聚合指标
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct RequestMetrics {
|
||||
pub request_count: u64,
|
||||
@@ -378,9 +458,9 @@ pub struct RequestMetrics {
|
||||
pub caller_error_count: u64,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cached_tokens: u64,
|
||||
pub cache_write_tokens: u64,
|
||||
pub reasoning_tokens: u64,
|
||||
pub cached_input_tokens: u64,
|
||||
pub cache_write_input_tokens: u64,
|
||||
pub reasoning_output_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
pub first_token_latency_sum_ms: u64,
|
||||
pub first_token_latency_count: u64,
|
||||
@@ -404,7 +484,7 @@ pub struct RequestMetrics {
|
||||
pub cache_hit_request_count: u64,
|
||||
}
|
||||
|
||||
/// 上游 attempt 聚合指标。
|
||||
/// 上游 attempt 聚合指标
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AttemptMetrics {
|
||||
pub attempt_count: u64,
|
||||
@@ -419,15 +499,39 @@ pub struct AttemptMetrics {
|
||||
pub costs: Vec<CurrencyCost>,
|
||||
}
|
||||
|
||||
/// 时间序列粒度。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// 时间序列粒度
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Granularity {
|
||||
FifteenMinutes,
|
||||
Hour,
|
||||
Day,
|
||||
}
|
||||
|
||||
/// 一段时间桶内的请求指标。
|
||||
impl Granularity {
|
||||
/// 管理趋势按范围选择粒度,费用校验与请求指标必须使用同一策略
|
||||
#[must_use]
|
||||
pub fn for_range(range: TimeRange) -> Self {
|
||||
let seconds = range.end.signed_duration_since(range.start).num_seconds();
|
||||
if seconds <= 2 * 24 * 60 * 60 {
|
||||
Self::FifteenMinutes
|
||||
} else if seconds <= 31 * 24 * 60 * 60 {
|
||||
Self::Hour
|
||||
} else {
|
||||
Self::Day
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn seconds(self) -> i64 {
|
||||
match self {
|
||||
Self::FifteenMinutes => 15 * 60,
|
||||
Self::Hour => 60 * 60,
|
||||
Self::Day => 24 * 60 * 60,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一段时间桶内的请求指标
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RequestMetricPoint {
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
@@ -437,8 +541,8 @@ pub struct RequestMetricPoint {
|
||||
pub costs: Vec<CurrencyCost>,
|
||||
}
|
||||
|
||||
/// 账号池的统一五态统计。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
/// 账号池的统一五态统计
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct AccountPoolMetrics {
|
||||
pub total: u64,
|
||||
pub normal: u64,
|
||||
@@ -448,17 +552,17 @@ pub struct AccountPoolMetrics {
|
||||
pub error: u64,
|
||||
}
|
||||
|
||||
/// Dashboard 中一个账号的模型级用量事实。
|
||||
/// 账号在查询时间范围内的模型级用量事实
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DashboardAccountModelUsage {
|
||||
pub struct AccountModelUsage {
|
||||
pub model: String,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub cached_input_tokens: Option<u64>,
|
||||
pub cache_write_input_tokens: Option<u64>,
|
||||
pub reasoning_output_tokens: Option<u64>,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub image_request_count: u64,
|
||||
@@ -469,14 +573,14 @@ pub struct DashboardAccountModelUsage {
|
||||
pub last_used_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Dashboard 中账号的单小时请求数。
|
||||
/// 账号在单小时窗口内的请求数
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DashboardAccountRequestBucket {
|
||||
pub struct AccountRequestBucket {
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
pub request_count: u64,
|
||||
}
|
||||
|
||||
/// Dashboard 中一个账号的完整用量事实。
|
||||
/// Dashboard 中一个账号的完整用量事实
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DashboardAccountUsage {
|
||||
pub account_id: String,
|
||||
@@ -485,15 +589,15 @@ pub struct DashboardAccountUsage {
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
pub plan_type: Option<String>,
|
||||
/// Admin 按 Provider 补齐的套餐展示名称。
|
||||
/// Admin 按 Provider 补齐的套餐展示名称
|
||||
pub plan_type_display: Option<String>,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub cached_input_tokens: Option<u64>,
|
||||
pub cache_write_input_tokens: Option<u64>,
|
||||
pub reasoning_output_tokens: Option<u64>,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub image_request_count: u64,
|
||||
@@ -502,50 +606,52 @@ pub struct DashboardAccountUsage {
|
||||
pub cost_coverage: CostCoverage,
|
||||
pub costs: Vec<CurrencyCost>,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
pub request_buckets: Vec<DashboardAccountRequestBucket>,
|
||||
/// Provider 已持久化额度窗口投影出的代表性已用比例。
|
||||
pub request_buckets: Vec<AccountRequestBucket>,
|
||||
/// Provider 已持久化额度窗口投影出的代表性已用比例
|
||||
///
|
||||
/// `None` 表示上游未提供可比较的百分比,不应伪造为零。
|
||||
/// `None` 表示上游未提供可比较的百分比,不应伪造为零
|
||||
pub quota_used_percent: Option<f64>,
|
||||
/// Provider 返回的代表窗口;缺失时保留未知语义。
|
||||
/// Provider 返回的代表窗口;缺失时保留未知语义
|
||||
pub quota_window: Option<super::provider_credentials::ProviderQuotaWindow>,
|
||||
pub models: Vec<DashboardAccountModelUsage>,
|
||||
pub models: Vec<AccountModelUsage>,
|
||||
}
|
||||
|
||||
/// Dashboard 当前账号池的可重建运行时槽位事实。
|
||||
/// Dashboard 当前账号池的可重建运行时槽位事实
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DashboardRuntimeSlots {
|
||||
/// 继承运行参数默认并发上限的可调度账号数。
|
||||
/// 继承运行参数默认并发上限的可调度账号数
|
||||
pub inherited_accounts: u64,
|
||||
/// 已配置账号级并发上限的可调度账号槽位总数。
|
||||
/// 已配置账号级并发上限的可调度账号槽位总数
|
||||
pub overridden_slots: u64,
|
||||
/// Redis 可用时的实时 in-flight 槽位总数;不可用时为 `None`。
|
||||
/// Redis 可用时的实时 in-flight 槽位总数;不可用时为 `None`
|
||||
pub used_slots: Option<u64>,
|
||||
}
|
||||
|
||||
/// 仪表盘卡片脚注展示的全历史累计。
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
/// 仪表盘卡片脚注展示的全历史累计
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct DashboardTotals {
|
||||
pub request_count: u64,
|
||||
pub input_tokens: u64,
|
||||
pub cached_tokens: u64,
|
||||
pub cached_input_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
pub billing_usd: Option<DecimalAmount>,
|
||||
}
|
||||
|
||||
/// 仪表盘所需的当前区间事实及全历史累计。
|
||||
/// 仪表盘所需的当前区间事实及全历史累计
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DashboardObservation {
|
||||
pub analytics: AnalyticsMetadata,
|
||||
pub range: TimeRange,
|
||||
pub totals: DashboardTotals,
|
||||
pub provider_accounts: AccountPoolMetrics,
|
||||
pub runtime_slots: Option<DashboardRuntimeSlots>,
|
||||
pub trend: Vec<RequestMetricPoint>,
|
||||
pub account_usage: Vec<DashboardAccountUsage>,
|
||||
pub recent_requests: Vec<UsageListRecord>,
|
||||
}
|
||||
|
||||
/// 使用记录表格的窄读模型。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// 使用记录表格的窄读模型
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageListRecord {
|
||||
pub client_api_key_name: Option<String>,
|
||||
pub id: String,
|
||||
@@ -556,9 +662,9 @@ pub struct UsageListRecord {
|
||||
pub provider_account_ref: Option<String>,
|
||||
pub provider_account_name: Option<String>,
|
||||
pub provider_account_email: Option<String>,
|
||||
/// 账号当前备注,按内部账号 ID 关联,不属于请求历史快照。
|
||||
/// 请求选号时冻结的账号备注,未采集时保持未知
|
||||
pub provider_account_notes: Option<String>,
|
||||
/// 账号当前套餐,不属于请求历史快照。
|
||||
/// 请求选号时冻结的账号套餐
|
||||
pub provider_account_plan_type: Option<String>,
|
||||
pub provider_account_plan_type_display: Option<String>,
|
||||
pub provider_account_authentication_kind: Option<String>,
|
||||
@@ -568,9 +674,9 @@ pub struct UsageListRecord {
|
||||
pub service_tier: Option<String>,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub cached_input_tokens: Option<u64>,
|
||||
pub cache_write_input_tokens: Option<u64>,
|
||||
pub reasoning_output_tokens: Option<u64>,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub total_tokens: Option<u64>,
|
||||
@@ -586,6 +692,13 @@ pub struct UsageListRecord {
|
||||
pub first_text_ms: Option<u64>,
|
||||
pub first_token_ms: Option<u64>,
|
||||
pub provider_processing_ms: Option<u64>,
|
||||
pub upstream_response_ms: Option<u64>,
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub admission_decision_ms: Option<u64>,
|
||||
pub account_selection_wait_ms: Option<u64>,
|
||||
@@ -600,8 +713,8 @@ pub struct UsageListRecord {
|
||||
pub started_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 一次完整模型请求的公共观测记录。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// 一次完整模型请求的公共观测记录
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageRecord {
|
||||
pub id: String,
|
||||
pub client_api_key_ref: String,
|
||||
@@ -625,7 +738,7 @@ pub struct UsageRecord {
|
||||
pub websocket_pool: Option<String>,
|
||||
pub upstream_response_model: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
/// Provider 已筛选的专有观测 JSON;管理领域保持不透明。
|
||||
/// Provider 已筛选的专有观测 JSON;管理领域保持不透明
|
||||
pub provider_metadata_json: Option<String>,
|
||||
pub attempt_count: u32,
|
||||
pub upstream_send_state: String,
|
||||
@@ -642,9 +755,9 @@ pub struct UsageRecord {
|
||||
pub retry_after_ms: Option<u64>,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub cached_input_tokens: Option<u64>,
|
||||
pub cache_write_input_tokens: Option<u64>,
|
||||
pub reasoning_output_tokens: Option<u64>,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub total_tokens: Option<u64>,
|
||||
@@ -660,6 +773,13 @@ pub struct UsageRecord {
|
||||
pub first_text_ms: Option<u64>,
|
||||
pub first_token_ms: Option<u64>,
|
||||
pub provider_processing_ms: Option<u64>,
|
||||
pub upstream_response_ms: Option<u64>,
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub admission_decision_ms: Option<u64>,
|
||||
pub account_selection_wait_ms: Option<u64>,
|
||||
@@ -679,8 +799,8 @@ pub struct UsageRecord {
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 用量分页结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// 用量分页结果
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsagePage {
|
||||
pub items: Vec<UsageListRecord>,
|
||||
pub current_page: u32,
|
||||
@@ -688,7 +808,7 @@ pub struct UsagePage {
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// 请求中的一次上游尝试或运维事件。
|
||||
/// 请求中的一次上游尝试或运维事件
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageAttempt {
|
||||
pub source: String,
|
||||
@@ -715,9 +835,9 @@ pub struct UsageAttempt {
|
||||
pub message: Option<String>,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub cached_input_tokens: Option<u64>,
|
||||
pub cache_write_input_tokens: Option<u64>,
|
||||
pub reasoning_output_tokens: Option<u64>,
|
||||
pub total_tokens: Option<u64>,
|
||||
pub cost_source: Option<String>,
|
||||
pub cost_amount: Option<DecimalAmount>,
|
||||
@@ -725,8 +845,8 @@ pub struct UsageAttempt {
|
||||
pub occurred_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 一条请求及其全部尝试。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// 一条请求及其全部尝试
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageDetail {
|
||||
pub trace: Option<serde_json::Value>,
|
||||
pub related_requests: Vec<serde_json::Value>,
|
||||
@@ -734,7 +854,7 @@ pub struct UsageDetail {
|
||||
pub attempts: Vec<UsageAttempt>,
|
||||
}
|
||||
|
||||
/// 一个 Provider 的聚合用量。
|
||||
/// 一个 Provider 的聚合用量
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderObservation {
|
||||
pub provider_kind: String,
|
||||
@@ -744,30 +864,32 @@ pub struct ProviderObservation {
|
||||
pub total_tokens: u64,
|
||||
}
|
||||
|
||||
/// 用量总览。
|
||||
/// 用量总览
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageOverview {
|
||||
pub analytics: AnalyticsMetadata,
|
||||
pub range: TimeRange,
|
||||
pub requests: RequestMetrics,
|
||||
pub attempts: AttemptMetrics,
|
||||
pub providers: Vec<ProviderObservation>,
|
||||
}
|
||||
|
||||
/// 用量摘要的用例结果。
|
||||
/// 用量摘要的用例结果
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageSummary {
|
||||
pub overview: UsageOverview,
|
||||
pub average_latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// 诊断聚合结果,分母包含截取展示项之前的全部匹配请求。
|
||||
/// 诊断聚合结果,分母包含截取展示项之前的全部匹配请求
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct DiagnosticsObservation {
|
||||
pub analytics: AnalyticsMetadata,
|
||||
pub total_request_count: u64,
|
||||
pub items: Vec<DiagnosticObservation>,
|
||||
}
|
||||
|
||||
/// 单个诊断维度值的聚合结果。
|
||||
/// 单个诊断维度值的聚合结果
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DiagnosticObservation {
|
||||
pub key: String,
|
||||
@@ -789,7 +911,7 @@ pub struct DiagnosticObservation {
|
||||
pub costs: Vec<CurrencyCost>,
|
||||
}
|
||||
|
||||
/// 统一运维错误记录。
|
||||
/// 统一运维错误记录
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OpsError {
|
||||
pub client_api_key_name: Option<String>,
|
||||
@@ -823,7 +945,7 @@ pub struct OpsError {
|
||||
pub upstream_request_id: Option<String>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub message: String,
|
||||
pub raw_upstream_error: Option<String>,
|
||||
pub error_details: Option<String>,
|
||||
pub client_ip: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
@@ -847,7 +969,7 @@ pub struct OpsError {
|
||||
pub stable_sort_id: String,
|
||||
}
|
||||
|
||||
/// 运维错误分页结果。
|
||||
/// 运维错误分页结果
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OpsErrorPage {
|
||||
pub items: Vec<OpsError>,
|
||||
@@ -856,7 +978,7 @@ pub struct OpsErrorPage {
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// 仪表盘趋势指标。
|
||||
/// 仪表盘趋势指标
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TrendKind {
|
||||
Usage,
|
||||
@@ -864,7 +986,7 @@ pub enum TrendKind {
|
||||
Errors,
|
||||
}
|
||||
|
||||
/// 一个趋势桶的全部已计算语义,API 只负责时区标签与展示格式。
|
||||
/// 一个趋势桶的全部已计算语义,API 只负责时区标签与展示格式
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TrendPoint {
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
@@ -880,13 +1002,13 @@ pub struct TrendPoint {
|
||||
pub costs: Vec<CurrencyCost>,
|
||||
}
|
||||
|
||||
/// 趋势的无格式化汇总。
|
||||
/// 趋势的无格式化汇总
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TrendSummary {
|
||||
pub request_count: u64,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cached_tokens: u64,
|
||||
pub cached_input_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
pub service_failure_count: u64,
|
||||
pub average_latency_ms: Option<u64>,
|
||||
@@ -901,15 +1023,16 @@ pub struct TrendSummary {
|
||||
pub cost_coverage: CostCoverage,
|
||||
}
|
||||
|
||||
/// 趋势结果;API 再决定图表标签与数字格式。
|
||||
/// 趋势结果;API 再决定图表标签与数字格式
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Trend {
|
||||
pub analytics: AnalyticsMetadata,
|
||||
pub kind: TrendKind,
|
||||
pub points: Vec<TrendPoint>,
|
||||
pub summary: TrendSummary,
|
||||
}
|
||||
|
||||
/// 健康时间桶状态。
|
||||
/// 健康时间桶状态
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HealthStatus {
|
||||
Future,
|
||||
@@ -920,7 +1043,7 @@ pub enum HealthStatus {
|
||||
Stable,
|
||||
}
|
||||
|
||||
/// 一个 15 分钟健康桶的语义结果。
|
||||
/// 一个 15 分钟健康桶的语义结果
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct HealthTimelinePoint {
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
@@ -933,7 +1056,7 @@ pub struct HealthTimelinePoint {
|
||||
pub caller_error_requests: u64,
|
||||
}
|
||||
|
||||
/// 中国自然日的固定 96 槽健康时间线。
|
||||
/// 中国自然日的固定 96 槽健康时间线
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct HealthTimeline {
|
||||
pub reliability_percent: Option<f64>,
|
||||
@@ -946,7 +1069,7 @@ pub struct HealthTimeline {
|
||||
pub points: Vec<HealthTimelinePoint>,
|
||||
}
|
||||
|
||||
/// Dashboard 请求画像的目标平台。
|
||||
/// Dashboard 请求画像的目标平台
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DashboardWireTarget {
|
||||
pub os_type: String,
|
||||
@@ -955,7 +1078,7 @@ pub struct DashboardWireTarget {
|
||||
pub terminal: String,
|
||||
}
|
||||
|
||||
/// Desktop 发布检查状态。
|
||||
/// Desktop 发布检查状态
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DesktopReleaseStatus {
|
||||
Unchecked,
|
||||
@@ -964,7 +1087,7 @@ pub enum DesktopReleaseStatus {
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// 与请求画像分离的 Desktop 发布检查事实。
|
||||
/// 与请求画像分离的 Desktop 发布检查事实
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DashboardDesktopRelease {
|
||||
pub status: DesktopReleaseStatus,
|
||||
@@ -980,9 +1103,9 @@ pub struct DashboardDesktopRelease {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Provider 拥有并实际用于上游请求的身份画像快照。
|
||||
/// Provider 拥有并实际用于上游请求的身份画像快照
|
||||
///
|
||||
/// `attributes` 承载 Provider 特有的可观测字段;公共控制面不解释其语义。
|
||||
/// `attributes` 承载 Provider 特有的可观测字段;公共控制面不解释其语义
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DashboardWireProfile {
|
||||
pub provider: String,
|
||||
@@ -996,36 +1119,36 @@ pub struct DashboardWireProfile {
|
||||
pub release: Option<DashboardDesktopRelease>,
|
||||
}
|
||||
|
||||
/// Provider 画像的差异字段;展示层只显示标签和值,不解释 Provider 语义。
|
||||
/// Provider 画像的差异字段;展示层只显示标签和值,不解释 Provider 语义
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DashboardWireAttribute {
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// 当前账号池的并发调度容量。
|
||||
/// 当前账号池的并发调度容量
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DashboardCapacity {
|
||||
pub max_concurrent_per_account: u64,
|
||||
/// `None` 表示可用账号池不限制并发。
|
||||
/// `None` 表示可用账号池不限制并发
|
||||
pub total_slots: Option<u64>,
|
||||
pub used_slots: Option<u64>,
|
||||
pub available_slots: Option<u64>,
|
||||
}
|
||||
|
||||
/// Dashboard 某个自然日区间内的卡片计数。
|
||||
/// Dashboard 某个自然日区间内的卡片计数
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct DashboardPeriodMetrics {
|
||||
pub request_count: u64,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cached_tokens: u64,
|
||||
pub cached_input_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
pub cached_token_rate: f64,
|
||||
pub observed_cached_token_rate: Option<f64>,
|
||||
}
|
||||
|
||||
/// 仪表盘聚合结果。
|
||||
/// 仪表盘聚合结果
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DashboardResult {
|
||||
pub observation: DashboardObservation,
|
||||
@@ -1041,7 +1164,7 @@ pub struct DashboardResult {
|
||||
pub rotation_strategy: super::settings::RotationStrategy,
|
||||
}
|
||||
|
||||
/// 洞察健康时间点的语义结果。
|
||||
/// 洞察健康时间点的语义结果
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageInsightsHealthPoint {
|
||||
pub total_requests: u64,
|
||||
@@ -1054,7 +1177,7 @@ pub struct UsageInsightsHealthPoint {
|
||||
pub error_rate: f64,
|
||||
}
|
||||
|
||||
/// 洞察健康汇总;失败数已排除调用方错误。
|
||||
/// 洞察健康汇总;失败数已排除调用方错误
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageInsightsHealth {
|
||||
pub total_requests: u64,
|
||||
@@ -1068,7 +1191,7 @@ pub struct UsageInsightsHealth {
|
||||
pub points: Vec<UsageInsightsHealthPoint>,
|
||||
}
|
||||
|
||||
/// 洞察性能时间点。
|
||||
/// 洞察性能时间点
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageInsightsPerformancePoint {
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
@@ -1083,7 +1206,7 @@ pub struct UsageInsightsPerformancePoint {
|
||||
pub capacity_utilization_p95_basis_points: Option<u64>,
|
||||
}
|
||||
|
||||
/// 洞察性能汇总与可观测覆盖率。
|
||||
/// 洞察性能汇总与可观测覆盖率
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageInsightsPerformance {
|
||||
pub latency_percentiles: LatencyPercentiles,
|
||||
@@ -1103,13 +1226,13 @@ pub struct UsageInsightsPerformance {
|
||||
pub points: Vec<UsageInsightsPerformancePoint>,
|
||||
}
|
||||
|
||||
/// 洞察成本时间点。
|
||||
/// 洞察成本时间点
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageInsightsCostPoint {
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cached_tokens: u64,
|
||||
pub cached_input_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
pub estimated_cost: Option<DecimalAmount>,
|
||||
pub standard_cost: Option<DecimalAmount>,
|
||||
@@ -1119,7 +1242,7 @@ pub struct UsageInsightsCostPoint {
|
||||
pub cache_hit_request_rate: Option<f64>,
|
||||
}
|
||||
|
||||
/// 洞察成本汇总。
|
||||
/// 洞察成本汇总
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageInsightsCost {
|
||||
pub estimated_cost: Option<DecimalAmount>,
|
||||
@@ -1134,16 +1257,17 @@ pub struct UsageInsightsCost {
|
||||
pub cache_hit_request_rate: Option<f64>,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cached_tokens: u64,
|
||||
pub cached_input_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
pub points: Vec<UsageInsightsCostPoint>,
|
||||
pub costs: Vec<CurrencyCost>,
|
||||
pub coverage: CostCoverage,
|
||||
}
|
||||
|
||||
/// 用量洞察的完整用例结果;API 只负责标签、时区与字符串格式化。
|
||||
/// 用量洞察的完整用例结果;API 只负责标签、时区与字符串格式化
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageInsights {
|
||||
pub analytics: AnalyticsMetadata,
|
||||
pub granularity: Granularity,
|
||||
pub health: UsageInsightsHealth,
|
||||
pub performance: UsageInsightsPerformance,
|
||||
@@ -1152,7 +1276,7 @@ pub struct UsageInsights {
|
||||
pub providers: Vec<ProviderObservation>,
|
||||
}
|
||||
|
||||
/// 已完成分母计算的诊断项。
|
||||
/// 已完成分母计算的诊断项
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DiagnosticsItem {
|
||||
pub key: String,
|
||||
@@ -1176,9 +1300,10 @@ pub struct DiagnosticsItem {
|
||||
pub total_tokens: u64,
|
||||
}
|
||||
|
||||
/// 诊断结果。
|
||||
/// 诊断结果
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DiagnosticsResult {
|
||||
pub analytics: AnalyticsMetadata,
|
||||
pub dimension: DiagnosticDimension,
|
||||
pub items: Vec<DiagnosticsItem>,
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! 插件可见的 Client Key 非秘密资源投影。
|
||||
//! 插件可见的 Client Key 非秘密资源投影
|
||||
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use gateway_core::routing::AccountGroupId;
|
||||
|
||||
use super::PageSize;
|
||||
|
||||
/// 插件可读取的当前 Key 范围,不含密钥或配置秘密。
|
||||
/// 插件可读取的当前 Key 范围,不含密钥或配置秘密
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginClientKeyFacts {
|
||||
pub id: ClientApiKeyId,
|
||||
@@ -25,7 +25,7 @@ pub struct PluginClientKeyListQuery {
|
||||
pub limit: PageSize,
|
||||
}
|
||||
|
||||
/// 插件只能发现调用所需的公开身份,不取得 Key 前缀、策略细节或明文。
|
||||
/// 插件只能发现调用所需的公开身份,不取得 Key 前缀、策略细节或明文
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginClientKey {
|
||||
pub id: ClientApiKeyId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 插件资源归属由宿主调用上下文给出,不能由插件参数选择实例或代次。
|
||||
//! 插件资源归属由宿主调用上下文给出,不能由插件参数选择实例或代次
|
||||
|
||||
use super::Revision;
|
||||
|
||||
@@ -17,7 +17,7 @@ pub struct ManagedResource {
|
||||
}
|
||||
|
||||
pub struct ResourceMutation<T> {
|
||||
/// 没有事实变化时不递增配置 revision,也不触发发布。
|
||||
/// 没有事实变化时不递增配置 revision,也不触发发布
|
||||
pub revision: Option<Revision>,
|
||||
pub value: T,
|
||||
}
|
||||
@@ -34,7 +34,7 @@ pub struct GroupMembersChanged {
|
||||
pub removed: u64,
|
||||
}
|
||||
|
||||
/// 插件 Key 的首次创建设置;分组始终从本实例资源键解析。
|
||||
/// 插件 Key 的首次创建设置;分组始终从本实例资源键解析
|
||||
pub struct ManagedKeyConfig {
|
||||
pub name: String,
|
||||
pub limits: gateway_core::policy::RateLimits,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! 插件分发来源、下载凭据、发行查询与远程安装的数据合同
|
||||
|
||||
use std::{fmt, sync::Arc};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -17,7 +19,7 @@ pub enum PluginUpdateSource {
|
||||
Github { repository: String },
|
||||
}
|
||||
|
||||
/// 检查策略只决定查询目标,任何策略都不会自动安装或切换运行实例。
|
||||
/// 检查策略只决定查询目标,任何策略都不会自动安装或切换运行实例
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PluginUpdatePolicy {
|
||||
@@ -85,7 +87,7 @@ impl fmt::Debug for PluginDistributionEgress {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginUpdateCheck {
|
||||
pub binding: PluginSourceBinding,
|
||||
/// Release 元数据不是已校验的插件包,不据此声明平台或 SDK 兼容。
|
||||
/// Release 元数据不是已校验的插件包,不据此声明平台或 SDK 兼容
|
||||
pub release: PluginRelease,
|
||||
}
|
||||
|
||||
@@ -109,7 +111,7 @@ pub enum DownloadPurpose {
|
||||
Artifact,
|
||||
}
|
||||
|
||||
/// 授权目标以完整 origin 和路径段边界匹配;摘要、名称和列表均不包含 secret。
|
||||
/// 授权目标以完整 origin 和路径段边界匹配;摘要、名称和列表均不包含 secret
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct SourceCredentialInfo {
|
||||
@@ -174,7 +176,7 @@ pub struct PluginRelease {
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 下载意图不承载凭据;引用在 Admin 中解析后只交给 Host 的本次调用。
|
||||
/// 下载意图不承载凭据;引用在 Admin 中解析后只交给 Host 的本次调用
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum RemotePluginLocation {
|
||||
@@ -200,7 +202,7 @@ pub struct RemotePluginVerify {
|
||||
pub credential_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
/// 更新已有插件时锁定身份;首次安装从包内清单读取。
|
||||
/// 更新已有插件时锁定身份;首次安装从包内清单读取
|
||||
pub expected_plugin_id: Option<String>,
|
||||
}
|
||||
|
||||
@@ -222,7 +224,7 @@ pub struct DownloadedPlugin {
|
||||
pub source: PluginSource,
|
||||
}
|
||||
|
||||
/// 只读包校验结果,不代表实例配置、授权或私有状态已完成运行时准备。
|
||||
/// 只读包校验结果,不代表实例配置、授权或私有状态已完成运行时准备
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VerifiedPluginArtifact {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! 插件实例配置、能力绑定、运行状态与版本切换的数据合同
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use secrecy::SecretString;
|
||||
@@ -20,7 +22,7 @@ pub struct PluginCapabilityBinding {
|
||||
pub stage: String,
|
||||
pub order: i32,
|
||||
pub failure_policy: PluginFailurePolicy,
|
||||
/// 观察绑定选择事件类型;其他能力不设置事件。
|
||||
/// 观察绑定选择事件类型;其他能力不设置事件
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub event: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -35,7 +37,7 @@ pub struct PluginCapabilityBinding {
|
||||
pub identity_bindings: Vec<PluginFrontendIdentityBinding>,
|
||||
}
|
||||
|
||||
/// 外部 principal 到既有 Client Key 的显式宿主映射。
|
||||
/// 外部 principal 到既有 Client Key 的显式宿主映射
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PluginFrontendIdentityBinding {
|
||||
@@ -53,7 +55,7 @@ impl std::fmt::Debug for PluginFrontendIdentityBinding {
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置和 secret 分离;Debug 与 Serialize 不得无意展开实例配置。
|
||||
/// 配置和 secret 分离;Debug 与 Serialize 不得无意展开实例配置
|
||||
#[derive(Clone)]
|
||||
pub struct PluginInstance {
|
||||
pub id: String,
|
||||
@@ -78,7 +80,7 @@ pub struct PluginInstanceMutation {
|
||||
pub instance: PluginInstance,
|
||||
}
|
||||
|
||||
/// 控制面展示的单实例运行状态;它描述当前进程观察到的事实,不替代持久启用配置。
|
||||
/// 控制面展示的单实例运行状态;它描述当前进程观察到的事实,不替代持久启用配置
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PluginInstanceRuntimeStatus {
|
||||
Disabled,
|
||||
@@ -91,14 +93,14 @@ pub enum PluginInstanceRuntimeStatus {
|
||||
Draining,
|
||||
}
|
||||
|
||||
/// 已脱敏且可安全返回给管理员的运行失败摘要。
|
||||
/// 已脱敏且可安全返回给管理员的运行失败摘要
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstanceRuntimeFailure {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 当前发布视图与代次索引的只读诊断。
|
||||
/// 当前发布视图与代次索引的只读诊断
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstanceRuntime {
|
||||
pub status: PluginInstanceRuntimeStatus,
|
||||
@@ -108,10 +110,24 @@ pub struct PluginInstanceRuntime {
|
||||
pub draining_revisions: Vec<u64>,
|
||||
}
|
||||
|
||||
/// 可继续运行的旧接口提示,与无法启用的兼容性错误分开
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginApiDeprecation {
|
||||
pub capability: String,
|
||||
pub version: u32,
|
||||
pub replacement_version: u32,
|
||||
pub introduced_in: Option<String>,
|
||||
pub remaining_releases: u32,
|
||||
pub migration: String,
|
||||
}
|
||||
|
||||
pub struct PluginInstanceView {
|
||||
pub instance: PluginInstance,
|
||||
pub configuration_required: bool,
|
||||
pub compatibility_warning: Option<String>,
|
||||
pub load_error: Option<String>,
|
||||
pub api_deprecations: Vec<PluginApiDeprecation>,
|
||||
pub running: bool,
|
||||
pub published_revision: Option<u64>,
|
||||
pub runtime: PluginInstanceRuntime,
|
||||
@@ -120,18 +136,18 @@ pub struct PluginInstanceView {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ConfigurePluginInstance {
|
||||
/// 创建草稿固定一个 UUID,重试复用同一配置,不能作为编辑已有配置的入口。
|
||||
/// 创建草稿固定一个 UUID,重试复用同一配置,不能作为编辑已有配置的入口
|
||||
pub creation_id: Option<String>,
|
||||
/// 编辑时检查用户看到的配置版本,避免覆盖另一窗口已经保存的内容。
|
||||
/// 编辑时检查用户看到的配置版本,避免覆盖另一窗口已经保存的内容
|
||||
pub expected_revision: Option<u64>,
|
||||
/// 管理员确认停用的同插件配置,与当前配置在同一事务中切换。
|
||||
/// 管理员确认停用的同插件配置,与当前配置在同一事务中切换
|
||||
#[serde(default)]
|
||||
pub replace_instances: Vec<PluginInstanceReplacement>,
|
||||
pub name: String,
|
||||
pub artifact_sha256: String,
|
||||
pub enabled: bool,
|
||||
pub configuration: serde_json::Value,
|
||||
/// 缺省保留旧值;显式空对象清除全部 secret。
|
||||
/// 缺省保留旧值;显式空对象清除全部 secret
|
||||
pub secrets: Option<BTreeMap<String, SecretString>>,
|
||||
pub bindings: Vec<PluginCapabilityBinding>,
|
||||
}
|
||||
@@ -143,7 +159,7 @@ pub struct PluginInstanceReplacement {
|
||||
pub expected_revision: u64,
|
||||
}
|
||||
|
||||
/// 恢复已安装旧版本的配置快照,并重新通过目标版本校验。
|
||||
/// 恢复已安装旧版本的配置快照,并重新通过目标版本校验
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct RollbackPluginInstance {
|
||||
@@ -159,7 +175,7 @@ pub struct PluginRollbackTarget {
|
||||
pub platforms: Vec<String>,
|
||||
}
|
||||
|
||||
/// 静态候选不启动插件;实际回滚仍须复验配置、平台和私有状态兼容性。
|
||||
/// 静态候选不启动插件;实际回滚仍须复验配置、平台和私有状态兼容性
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginRollbackPlan {
|
||||
@@ -168,7 +184,7 @@ pub struct PluginRollbackPlan {
|
||||
pub targets: Vec<PluginRollbackTarget>,
|
||||
}
|
||||
|
||||
/// 版本配置快照只用于服务端恢复,敏感值不实现 Debug 或 Serialize。
|
||||
/// 版本配置快照只用于服务端恢复,敏感值不实现 Debug 或 Serialize
|
||||
#[derive(Clone)]
|
||||
pub struct PluginVersionConfiguration {
|
||||
pub configuration: serde_json::Value,
|
||||
@@ -176,7 +192,7 @@ pub struct PluginVersionConfiguration {
|
||||
pub bindings: Vec<PluginCapabilityBinding>,
|
||||
}
|
||||
|
||||
/// 切换版本的只读草稿,不包含密钥值,也不会启动插件或写入配置。
|
||||
/// 切换版本的只读草稿,不包含密钥值,也不会启动插件或写入配置
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginVersionPlan {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! 插件管理页面、静态资源、请求与授权回调的数据合同
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 页面和业务调用绑定同一实例版本;制品摘要单独作为不可变资源身份。
|
||||
/// 页面和业务调用绑定同一实例版本;制品摘要单独作为不可变资源身份
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PluginManagementTarget {
|
||||
@@ -37,7 +39,7 @@ pub struct StartPluginManagementCallback {
|
||||
pub ttl_seconds: u32,
|
||||
}
|
||||
|
||||
/// state 是短期一次性秘密,只交给发起登录的管理员,不进入 Debug 或日志。
|
||||
/// state 是短期一次性秘密,只交给发起登录的管理员,不进入 Debug 或日志
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginManagementCallbackTicket {
|
||||
@@ -72,7 +74,7 @@ pub struct PluginManagementResource {
|
||||
pub public: bool,
|
||||
}
|
||||
|
||||
/// 原始正文可能含业务数据,不能派生内容型 Debug 或写入诊断日志。
|
||||
/// 原始正文可能含业务数据,不能派生内容型 Debug 或写入诊断日志
|
||||
pub struct PluginManagementRequest {
|
||||
pub method: String,
|
||||
pub path: String,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! 插件制品元数据、宿主兼容性与管理领域模型的公共入口
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
sync::Arc,
|
||||
@@ -13,7 +15,7 @@ pub mod management;
|
||||
pub mod official;
|
||||
pub mod state;
|
||||
|
||||
/// 一个宿主发行版明确承诺支持的插件能力版本。
|
||||
/// 一个宿主发行版明确承诺支持的插件能力版本
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginHostCapability {
|
||||
@@ -21,7 +23,7 @@ pub struct PluginHostCapability {
|
||||
pub versions: Vec<u32>,
|
||||
}
|
||||
|
||||
/// 随宿主发行物封口的插件合同;Runtime 与发布清单共用这一份声明。
|
||||
/// 随宿主发行物封口的插件合同;Runtime 与发布清单共用这一份声明
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginHostCompatibility {
|
||||
@@ -32,7 +34,7 @@ pub struct PluginHostCompatibility {
|
||||
}
|
||||
|
||||
impl PluginHostCompatibility {
|
||||
/// 拒绝空集合、重复项和不受控标识,避免发行清单把“未知”解释为通配。
|
||||
/// 拒绝空集合、重复项和不受控标识,避免发行清单把“未知”解释为通配
|
||||
#[must_use]
|
||||
pub fn is_valid(&self) -> bool {
|
||||
if self.schema_version != 2
|
||||
@@ -101,7 +103,7 @@ fn valid_plugin_segment(value: &str) -> bool {
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
}
|
||||
|
||||
/// 已安装包对目标宿主的静态要求;不包含包体、配置或 secret。
|
||||
/// 已安装包对目标宿主的静态要求;不包含包体、配置或 secret
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginCompatibilityRequirements {
|
||||
pub host_version: String,
|
||||
@@ -110,7 +112,7 @@ pub struct PluginCompatibilityRequirements {
|
||||
pub capabilities: Vec<(String, u32)>,
|
||||
}
|
||||
|
||||
/// 一次下载使用的非敏感出站代理身份;地址与认证由受管代理记录持有。
|
||||
/// 一次下载使用的非敏感出站代理身份;地址与认证由受管代理记录持有
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PluginSourceEgress {
|
||||
@@ -118,7 +120,7 @@ pub struct PluginSourceEgress {
|
||||
pub revision: u64,
|
||||
}
|
||||
|
||||
/// 安装来源只保存公开定位信息,下载凭据和出站代理通过独立记录引用。
|
||||
/// 安装来源只保存公开定位信息,下载凭据和出站代理通过独立记录引用
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PluginSource {
|
||||
@@ -164,7 +166,7 @@ impl PluginSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理端保留的单项贡献声明;map key 是能力标识,`id` 是实例绑定引用的扩展项身份。
|
||||
/// 管理端保留的单项贡献声明;map key 是能力标识,`id` 是实例绑定引用的扩展项身份
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginContribution {
|
||||
@@ -178,7 +180,7 @@ pub struct PluginContribution {
|
||||
pub output_formats: Vec<String>,
|
||||
}
|
||||
|
||||
/// 制品清单中的展示图标;路径只用于选择已校验的包资源。
|
||||
/// 制品清单中的展示图标;路径只用于选择已校验的包资源
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum PluginArtifactIcon {
|
||||
@@ -198,15 +200,15 @@ pub enum PluginIconTheme {
|
||||
Dark,
|
||||
}
|
||||
|
||||
/// 从不可变制品中读取的已校验图标字节。
|
||||
/// 从不可变制品中读取的已校验图标字节
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginArtifactIconResource {
|
||||
pub content_type: String,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// 已校验制品的持久化投影;读取时忽略未知字段,可选字段缺省沿用清单语义。
|
||||
/// 身份与执行所需事实仍为必填,包清单及协议版本由 Runtime 独立校验。
|
||||
/// 已校验制品的持久化投影;读取时忽略未知字段,可选字段缺省沿用清单语义
|
||||
/// 身份与执行所需事实仍为必填,包清单及协议版本由 Runtime 独立校验
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginArtifactMetadata {
|
||||
@@ -235,7 +237,7 @@ fn empty_configuration_schema() -> serde_json::Value {
|
||||
serde_json::json!({})
|
||||
}
|
||||
|
||||
/// 由包检查端口产生的不可变制品;不派生 Debug,包内可能含敏感用户数据。
|
||||
/// 由包检查端口产生的不可变制品;不派生 Debug,包内可能含敏感用户数据
|
||||
#[derive(Clone)]
|
||||
pub struct InspectedPluginArtifact {
|
||||
pub metadata: PluginArtifactMetadata,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
//! 官方插件发行身份与导入命令
|
||||
|
||||
use super::super::Revision;
|
||||
|
||||
/// 编译进宿主二进制的发行身份;不能由运行时配置或插件清单覆盖。
|
||||
/// 编译进宿主二进制的发行身份;不能由运行时配置或插件清单覆盖
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OfficialPluginReleaseIdentity {
|
||||
pub gateway_version: String,
|
||||
pub gateway_git_sha: String,
|
||||
}
|
||||
|
||||
/// 启动时导入官方制品的结果;导入只持久化制品,不创建或启用实例。
|
||||
/// 启动时导入官方制品的结果;导入只持久化制品,不创建或启用实例
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OfficialPluginImport {
|
||||
pub artifacts: usize,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
//! 插件私有状态的归属、代次隔离与迁移数据合同
|
||||
|
||||
use std::{collections::BTreeMap, fmt};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::model::Revision;
|
||||
|
||||
/// 已由 Runtime 校验的命名空间 schema 与配额事实。
|
||||
/// 已由 Runtime 校验的命名空间 schema 与配额事实
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginStateSchema {
|
||||
@@ -42,7 +44,7 @@ pub struct PluginStateConfiguration {
|
||||
pub namespaces: Vec<PluginStateSchema>,
|
||||
}
|
||||
|
||||
/// 与实例保存同事务提交的状态配置;transition 只引用已完成的 staging 数据。
|
||||
/// 与实例保存同事务提交的状态配置;transition 只引用已完成的 staging 数据
|
||||
#[derive(Clone, Default)]
|
||||
pub struct PluginStateCommit {
|
||||
pub configuration: PluginStateConfiguration,
|
||||
@@ -57,7 +59,8 @@ pub struct PluginStateOwnerRequest {
|
||||
pub configuration: PluginStateConfiguration,
|
||||
}
|
||||
|
||||
/// Store 签发的实例私有状态 fence。插件协议中从不传输这些字段。
|
||||
/// Store 签发的实例私有状态 fence
|
||||
/// 插件协议中从不传输这些字段
|
||||
#[derive(Clone)]
|
||||
pub struct PluginStateOwner {
|
||||
instance_id: String,
|
||||
@@ -136,7 +139,7 @@ impl PluginStateNamespaceOwner {
|
||||
}
|
||||
}
|
||||
|
||||
/// 私有值可能含插件自己的敏感状态,故意不实现 `Debug`。
|
||||
/// 私有值可能含插件自己的敏感状态,故意不实现 `Debug`
|
||||
#[derive(Clone)]
|
||||
pub struct PluginStateRecord {
|
||||
pub key: String,
|
||||
@@ -149,7 +152,7 @@ pub struct PutPluginState {
|
||||
pub namespace: String,
|
||||
pub key: String,
|
||||
pub value: serde_json::Value,
|
||||
/// `None` 是 create-only;已有记录必须提交精确版本。
|
||||
/// `None` 是 create-only;已有记录必须提交精确版本
|
||||
pub expected_version: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -188,7 +191,7 @@ pub struct ApplyPluginStateMigration {
|
||||
pub transition_id: String,
|
||||
pub namespace: String,
|
||||
pub cursor: Option<String>,
|
||||
/// Runtime 刚读取的有序源键;Store 会在写入前重新读取并逐项比对。
|
||||
/// Runtime 刚读取的有序源键;Store 会在写入前重新读取并逐项比对
|
||||
pub expected_keys: Vec<String>,
|
||||
pub changes: Vec<PluginStateMigrationChange>,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 全局模型定价的管理合同。
|
||||
//! 全局模型定价的管理合同
|
||||
|
||||
use gateway_core::metering::{ModelPriceOverride, PricingOverrides};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Provider 管理能力交换的中立 Command 与 Result。
|
||||
//! Provider 管理能力交换的中立 Command 与 Result
|
||||
|
||||
use std::{fmt, pin::Pin};
|
||||
|
||||
@@ -22,7 +22,7 @@ use super::{
|
||||
},
|
||||
};
|
||||
|
||||
/// Provider-owned JSON;公共层只搬运且 Debug 不输出值。
|
||||
/// Provider-owned JSON;公共层只搬运且 Debug 不输出值
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct ProviderDocument(OpaqueProviderData);
|
||||
|
||||
@@ -32,7 +32,7 @@ impl ProviderDocument {
|
||||
Self(data)
|
||||
}
|
||||
|
||||
/// 仅具体 Provider 可以解释内部字段。
|
||||
/// 仅具体 Provider 可以解释内部字段
|
||||
#[must_use]
|
||||
pub const fn expose_to_provider(&self) -> &OpaqueProviderData {
|
||||
&self.0
|
||||
@@ -50,14 +50,14 @@ impl fmt::Debug for ProviderDocument {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider credential 详情。
|
||||
/// Provider credential 详情
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialDetails {
|
||||
pub config_revision: Revision,
|
||||
pub credential: AccountRecord,
|
||||
}
|
||||
|
||||
/// Provider 正式文档批量导入命令。
|
||||
/// Provider 正式文档批量导入命令
|
||||
pub struct ImportCredentials {
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
pub settings: Option<AccountImportSettings>,
|
||||
@@ -75,14 +75,14 @@ impl fmt::Debug for ImportCredentials {
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量导入提交结果。
|
||||
/// 批量导入提交结果
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialImportResult {
|
||||
pub config_revision: Revision,
|
||||
pub credential_ids: Vec<ProviderAccountId>,
|
||||
}
|
||||
|
||||
/// Provider 解析导入文档时只接收不透明文档,不接触 revision 或审计上下文。
|
||||
/// Provider 解析导入文档时只接收不透明文档,不接触 revision 或审计上下文
|
||||
pub struct PrepareCredentialImport {
|
||||
pub default_outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
pub document: ProviderDocument,
|
||||
@@ -97,7 +97,7 @@ impl fmt::Debug for PrepareCredentialImport {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 已验证、可由 Store 原子创建的一份 credential。
|
||||
/// Provider 已验证、可由 Store 原子创建的一份 credential
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PreparedCredentialCreate {
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
@@ -119,14 +119,14 @@ pub struct PreparedCredentialCreate {
|
||||
pub credential_observed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Provider 对一份导入文档的完整验证结果。
|
||||
/// Provider 对一份导入文档的完整验证结果
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PreparedCredentialImport {
|
||||
pub provider_kind: ProviderKind,
|
||||
pub credentials: Vec<PreparedCredentialCreate>,
|
||||
}
|
||||
|
||||
/// Admin 交给 Store 的导入事务命令。
|
||||
/// Admin 交给 Store 的导入事务命令
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CredentialImportCommit {
|
||||
pub outbound_proxy: Option<super::proxies::ImportProxyBinding>,
|
||||
@@ -134,7 +134,7 @@ pub struct CredentialImportCommit {
|
||||
pub prepared: PreparedCredentialImport,
|
||||
}
|
||||
|
||||
/// OAuth pending owner 的中立身份;不编码具体 Provider 的 Redis key 或 JSON。
|
||||
/// OAuth pending owner 的中立身份;不编码具体 Provider 的 Redis key 或 JSON
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum AuthorizationOwner {
|
||||
AdminSession { admin_user_id: String },
|
||||
@@ -148,7 +148,7 @@ impl fmt::Debug for AuthorizationOwner {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 写入 pending payload 与 Redis owner binding 所需的全部中立字段。
|
||||
/// Provider 写入 pending payload 与 Redis owner binding 所需的全部中立字段
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct AuthorizationOwnerBinding {
|
||||
owner: AuthorizationOwner,
|
||||
@@ -193,17 +193,17 @@ impl fmt::Debug for AuthorizationOwnerBinding {
|
||||
}
|
||||
}
|
||||
|
||||
/// OAuth 完成时应创建新账号还是 CAS 更新既有 credential。
|
||||
/// OAuth 完成时应创建新账号还是 CAS 更新既有 credential
|
||||
///
|
||||
/// 重新授权只绑定稳定的账号身份:credential revision 由服务端在临近写入时读取,
|
||||
/// 长流程期间的后台刷新不得让恢复操作失效。
|
||||
/// 长流程期间的后台刷新不得让恢复操作失效
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthorizationMutationTarget {
|
||||
Create { name: String },
|
||||
Reauthorize { account_id: ProviderAccountId },
|
||||
}
|
||||
|
||||
/// 必须完整进入 Provider opaque pending payload、并在 complete 后原样恢复的事务信封。
|
||||
/// 必须完整进入 Provider opaque pending payload、并在 complete 后原样恢复的事务信封
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PendingAuthorizationMutation {
|
||||
provider_kind: ProviderKind,
|
||||
@@ -269,7 +269,7 @@ impl PendingAuthorizationMutation {
|
||||
}
|
||||
|
||||
impl PendingAuthorizationMutation {
|
||||
/// 编码 v1 中立事务字段;版本号的位置和 Provider 私有外层文档由 adapter 保持。
|
||||
/// 编码 v1 中立事务字段;版本号的位置和 Provider 私有外层文档由 adapter 保持
|
||||
#[must_use]
|
||||
pub fn to_storage_v1(&self) -> Map<String, Value> {
|
||||
let target = match &self.target {
|
||||
@@ -311,11 +311,11 @@ impl PendingAuthorizationMutation {
|
||||
document
|
||||
}
|
||||
|
||||
/// 恢复 adapter 已确认版本为 v1 的中立事务字段。
|
||||
/// 恢复 adapter 已确认版本为 v1 的中立事务字段
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 文档字段、Provider kind 或账号身份非法时返回错误,不携带原始 pending 内容。
|
||||
/// 文档字段、Provider kind 或账号身份非法时返回错误,不携带原始 pending 内容
|
||||
pub fn from_storage_v1(value: Value) -> Result<Self, AdminError> {
|
||||
let invalid = || AdminError::invalid("invalid pending authorization mutation");
|
||||
let document: StoredAuthorizationMutationV1 =
|
||||
@@ -385,7 +385,7 @@ enum StoredAuthorizationOwnerV1 {
|
||||
System,
|
||||
}
|
||||
|
||||
/// Provider 授权请求。
|
||||
/// Provider 授权请求
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct StartAuthorization {
|
||||
pub context: MutationContext,
|
||||
@@ -394,7 +394,7 @@ pub struct StartAuthorization {
|
||||
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
|
||||
}
|
||||
|
||||
/// OAuth 流程启动结果。
|
||||
/// OAuth 流程启动结果
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthorizationStarted {
|
||||
pub flow_id: String,
|
||||
@@ -402,35 +402,37 @@ pub struct AuthorizationStarted {
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// refresh/rotate/reauthorize 的 lease 或 completion 生命周期。
|
||||
/// refresh/rotate/reauthorize 的 lease 或 completion 生命周期
|
||||
///
|
||||
/// 该 guard 不可 Clone。Admin 只能在 Store CAS 与审计事务成功后调用 `finish`;失败路径直接
|
||||
/// drop,使 Provider 可以释放 lease 或执行补偿。
|
||||
/// 该 guard 不可 Clone
|
||||
/// Admin 只能在 Store CAS 与审计事务成功后调用 `finish`;失败路径直接
|
||||
/// drop,使 Provider 可以释放 lease 或执行补偿
|
||||
pub trait CredentialCommitGuard: Send + 'static {
|
||||
fn finish(self: Box<Self>);
|
||||
}
|
||||
|
||||
/// OAuth pending claim 在 Store 事务结果确定后执行的显式结算动作。
|
||||
/// OAuth pending claim 在 Store 事务结果确定后执行的显式结算动作
|
||||
///
|
||||
/// OAuth provider 必须在 credential 准备阶段持有 claim:Store 提交成功后消费 flow,提交或
|
||||
/// 校验失败后释放 flow。异步结算不能依赖 `Drop`,否则请求提前返回会让同一授权流无谓地
|
||||
/// 进入短暂的不可重试状态。
|
||||
/// 校验失败后释放 flow
|
||||
/// 异步结算不能依赖 `Drop`,否则请求提前返回会让同一授权流无谓地
|
||||
/// 进入短暂的不可重试状态
|
||||
#[async_trait]
|
||||
pub trait AuthorizationCommitGuard: Send + 'static {
|
||||
/// Store 已提交 OAuth credential 后消费对应的一次性 flow。
|
||||
/// Store 已提交 OAuth credential 后消费对应的一次性 flow
|
||||
async fn commit(self: Box<Self>) -> Result<(), AdminError>;
|
||||
|
||||
/// Store 未提交 OAuth credential 时释放 claim,允许同一 flow 重试。
|
||||
/// Store 未提交 OAuth credential 时释放 claim,允许同一 flow 重试
|
||||
async fn abort(self: Box<Self>) -> Result<(), AdminError>;
|
||||
}
|
||||
|
||||
/// OAuth complete 后由 Provider 返回的准备结果;Store 仍是唯一提交者。
|
||||
/// OAuth complete 后由 Provider 返回的准备结果;Store 仍是唯一提交者
|
||||
pub enum PreparedAuthorizationCredential {
|
||||
Create(Box<PreparedCredentialCreate>),
|
||||
Reauthorize(Box<PreparedCredentialRotation>),
|
||||
}
|
||||
|
||||
/// Provider 从 opaque pending payload 恢复的信封与已验证 credential 必须一起返回。
|
||||
/// Provider 从 opaque pending payload 恢复的信封与已验证 credential 必须一起返回
|
||||
pub struct PreparedAuthorizationCommit {
|
||||
pub pending: PendingAuthorizationMutation,
|
||||
pub credential: PreparedAuthorizationCredential,
|
||||
@@ -447,14 +449,14 @@ impl fmt::Debug for PreparedAuthorizationCommit {
|
||||
}
|
||||
}
|
||||
|
||||
/// Store 可持久化的 OAuth credential facts,不携带 Provider guard。
|
||||
/// Store 可持久化的 OAuth credential facts,不携带 Provider guard
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AuthorizationCredentialCommit {
|
||||
Create(Box<PreparedCredentialCreate>),
|
||||
Reauthorize(Box<PreparedCredentialRotationFacts>),
|
||||
}
|
||||
|
||||
/// Admin 交给 Store 的 OAuth 原子事务命令。
|
||||
/// Admin 交给 Store 的 OAuth 原子事务命令
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AuthorizationCommit {
|
||||
pub key: AuthorizationReceiptKey,
|
||||
@@ -463,7 +465,7 @@ pub struct AuthorizationCommit {
|
||||
pub credential: AuthorizationCredentialCommit,
|
||||
}
|
||||
|
||||
/// 回执以 Provider、授权流和管理员身份绑定;不持久化原始 flow 或回调材料。
|
||||
/// 回执以 Provider、授权流和管理员身份绑定;不持久化原始 flow 或回调材料
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct AuthorizationReceiptKey {
|
||||
provider_kind: ProviderKind,
|
||||
@@ -531,10 +533,10 @@ pub struct AuthorizationCommitResult {
|
||||
pub newly_committed: bool,
|
||||
}
|
||||
|
||||
/// OAuth 准备结果拆解后的 Store 命令与两个结算 guard。
|
||||
/// OAuth 准备结果拆解后的 Store 命令与两个结算 guard
|
||||
///
|
||||
/// Store 成功时先结束 credential guard,再消费 OAuth claim;失败时丢弃 credential guard 并
|
||||
/// 释放 OAuth claim,使同一授权流可以重试。
|
||||
/// 释放 OAuth claim,使同一授权流可以重试
|
||||
pub(crate) struct AuthorizationCommitSettlement {
|
||||
pub(crate) command: AuthorizationCommit,
|
||||
pub(crate) credential_guard: Option<Box<dyn CredentialCommitGuard>>,
|
||||
@@ -554,7 +556,7 @@ impl PreparedAuthorizationCommit {
|
||||
}
|
||||
}
|
||||
|
||||
/// 让 OAuth claim 覆盖准备完成到 Store 事务结算之间的全部窗口。
|
||||
/// 让 OAuth claim 覆盖准备完成到 Store 事务结算之间的全部窗口
|
||||
#[must_use]
|
||||
pub fn with_authorization_guard(mut self, guard: Box<dyn AuthorizationCommitGuard>) -> Self {
|
||||
self.authorization_guard = Some(guard);
|
||||
@@ -599,7 +601,7 @@ impl PreparedAuthorizationCommit {
|
||||
}
|
||||
}
|
||||
|
||||
/// 完成 Provider OAuth 流程。
|
||||
/// 完成 Provider OAuth 流程
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct CompleteAuthorization {
|
||||
pub settings: Option<AccountImportSettings>,
|
||||
@@ -619,14 +621,14 @@ impl fmt::Debug for CompleteAuthorization {
|
||||
}
|
||||
}
|
||||
|
||||
/// Credential 生命周期写操作。
|
||||
/// Credential 生命周期写操作
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialMutation {
|
||||
pub context: MutationContext,
|
||||
pub account_id: ProviderAccountId,
|
||||
}
|
||||
|
||||
/// Credential 写入提交结果。
|
||||
/// Credential 写入提交结果
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialMutationResult {
|
||||
pub config_revision: Revision,
|
||||
@@ -634,28 +636,28 @@ pub struct CredentialMutationResult {
|
||||
pub credential_revision: Option<Revision>,
|
||||
}
|
||||
|
||||
/// 同一 Provider 管理范围内的 credential 批量删除。
|
||||
/// 同一 Provider 管理范围内的 credential 批量删除
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialDeletion {
|
||||
pub context: MutationContext,
|
||||
pub account_ids: Vec<ProviderAccountId>,
|
||||
}
|
||||
|
||||
/// Credential 批量删除提交结果。
|
||||
/// Credential 批量删除提交结果
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialDeletionResult {
|
||||
pub config_revision: Revision,
|
||||
pub account_ids: Vec<ProviderAccountId>,
|
||||
}
|
||||
|
||||
/// Provider-owned token 轮换命令。
|
||||
/// Provider-owned token 轮换命令
|
||||
pub struct RotateCredential {
|
||||
pub mutation: CredentialMutation,
|
||||
pub provider_material: ProviderDocument,
|
||||
pub settings: Option<super::accounts::UpdateAccount>,
|
||||
}
|
||||
|
||||
/// Provider 校验手工轮换材料时所需的非事务输入。
|
||||
/// Provider 校验手工轮换材料时所需的非事务输入
|
||||
pub struct PrepareCredentialRotation {
|
||||
pub account: AccountRecord,
|
||||
pub provider_material: ProviderDocument,
|
||||
@@ -671,7 +673,7 @@ impl fmt::Debug for PrepareCredentialRotation {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 已验证、可由 Store 以 credential revision CAS 原子提交的轮换 facts。
|
||||
/// Provider 已验证、可由 Store 以 credential revision CAS 原子提交的轮换 facts
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PreparedCredentialRotationFacts {
|
||||
pub account_id: ProviderAccountId,
|
||||
@@ -681,9 +683,9 @@ pub struct PreparedCredentialRotationFacts {
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
pub plan_type: Option<String>,
|
||||
/// Token 刷新保留提交时的资料,不以准备阶段的副本覆盖新套餐。
|
||||
/// Token 刷新保留提交时的资料,不以准备阶段的副本覆盖新套餐
|
||||
pub preserve_profile: bool,
|
||||
/// 连接配置变更保留当前凭据健康状态与错误事实。
|
||||
/// 连接配置变更保留当前凭据健康状态与错误事实
|
||||
pub preserve_credential_state: bool,
|
||||
pub provider_material: ProviderDocument,
|
||||
pub has_refresh_token: bool,
|
||||
@@ -691,7 +693,7 @@ pub struct PreparedCredentialRotationFacts {
|
||||
pub next_refresh_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Provider 返回的轮换准备结果;guard 必须覆盖后续 Store CAS 与审计事务。
|
||||
/// Provider 返回的轮换准备结果;guard 必须覆盖后续 Store CAS 与审计事务
|
||||
pub struct PreparedCredentialRotation {
|
||||
facts: PreparedCredentialRotationFacts,
|
||||
guard: Box<dyn CredentialCommitGuard>,
|
||||
@@ -706,7 +708,7 @@ impl PreparedCredentialRotation {
|
||||
Self { facts, guard }
|
||||
}
|
||||
|
||||
/// 仅修改连接配置时,不把配置更新视为凭据恢复成功。
|
||||
/// 仅修改连接配置时,不把配置更新视为凭据恢复成功
|
||||
#[must_use]
|
||||
pub fn preserving_credential_state(mut self) -> Self {
|
||||
self.facts.preserve_credential_state = true;
|
||||
@@ -739,7 +741,7 @@ impl fmt::Debug for PreparedCredentialRotation {
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin 交给 Store 的轮换或 refresh 事务命令。
|
||||
/// Admin 交给 Store 的轮换或 refresh 事务命令
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CredentialRotationCommit {
|
||||
pub prepared: PreparedCredentialRotationFacts,
|
||||
@@ -757,16 +759,16 @@ impl fmt::Debug for RotateCredential {
|
||||
}
|
||||
}
|
||||
|
||||
/// 通用账号用量能否可靠归属到一个 Provider quota 窗口。
|
||||
/// 通用账号用量能否可靠归属到一个 Provider quota 窗口
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum QuotaLocalUsageAttribution {
|
||||
/// 窗口覆盖账号的全部请求,可按账号与时间范围聚合。
|
||||
/// 窗口覆盖账号的全部请求,可按账号与时间范围聚合
|
||||
AccountWide,
|
||||
/// 窗口需要 Provider / 模型级归属,通用账号聚合不可用。
|
||||
/// 窗口需要 Provider / 模型级归属,通用账号聚合不可用
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Provider quota bucket 中窗口的官方位置语义。
|
||||
/// Provider quota bucket 中窗口的官方位置语义
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderQuotaWindowRole {
|
||||
Primary,
|
||||
@@ -785,7 +787,7 @@ impl ProviderQuotaWindowRole {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个 Provider quota 窗口的公共投影。
|
||||
/// 一个 Provider quota 窗口的公共投影
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderQuotaWindow {
|
||||
pub key: String,
|
||||
@@ -803,14 +805,14 @@ pub struct ProviderQuotaWindow {
|
||||
pub provider_data: Option<ProviderDocument>,
|
||||
}
|
||||
|
||||
/// 账号用量统计周期,按周优先、月次之选择;短期限流窗口不参与统计面板。
|
||||
/// 账号用量统计周期,按周优先、月次之选择;短期限流窗口不参与统计面板
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum AccountUsagePeriod {
|
||||
Weekly,
|
||||
Monthly,
|
||||
}
|
||||
|
||||
/// Provider 解释 quota 所需的公共请求事实。
|
||||
/// Provider 解释 quota 所需的公共请求事实
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderQuotaRequest {
|
||||
pub account_id: ProviderAccountId,
|
||||
@@ -818,20 +820,29 @@ pub struct ProviderQuotaRequest {
|
||||
pub rolling_usage: Option<AccountUsage>,
|
||||
}
|
||||
|
||||
/// Provider 已解析的 quota 结果及其不透明差异字段。
|
||||
/// Provider 已解析的 quota 结果及其不透明差异字段
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderQuota {
|
||||
/// 上游额度响应明确提供的套餐,可用于补全账号展示。
|
||||
/// 上游额度响应明确提供的套餐,可用于补全账号展示
|
||||
pub plan_type: Option<String>,
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
pub refresh_token_expires_at: Option<DateTime<Utc>>,
|
||||
pub windows: Vec<ProviderQuotaWindow>,
|
||||
/// 展示用快照级触顶事实(顶层或任一窗口触顶);不参与账号五态派生。
|
||||
pub credits: Option<ProviderQuotaCredits>,
|
||||
/// 展示用快照级触顶事实(顶层或任一窗口触顶);不参与账号五态派生
|
||||
pub limit_reached: bool,
|
||||
pub provider_data: Option<ProviderDocument>,
|
||||
}
|
||||
|
||||
/// 空值和 `unknown` 代表未提供套餐;新的套餐标识仍按明确值保留。
|
||||
/// 上游点数余额的展示事实,保留原始十进制文本以避免精度损失
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderQuotaCredits {
|
||||
pub has_credits: bool,
|
||||
pub unlimited: bool,
|
||||
pub balance: Option<String>,
|
||||
}
|
||||
|
||||
/// 空值和 `unknown` 代表未提供套餐;新的套餐标识仍按明确值保留
|
||||
pub(crate) fn explicit_plan_type(value: Option<&str>) -> Option<&str> {
|
||||
value.filter(|value| {
|
||||
let value = value.trim();
|
||||
@@ -839,7 +850,7 @@ pub(crate) fn explicit_plan_type(value: Option<&str>) -> Option<&str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 按需查询的订阅周期,不参与额度或账号可用性判断。
|
||||
/// 按需查询的订阅周期,不参与额度或账号可用性判断
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderSubscription {
|
||||
pub starts_at: Option<DateTime<Utc>>,
|
||||
@@ -850,14 +861,14 @@ pub struct ProviderSubscription {
|
||||
pub observed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 按需汇聚的个人信息;资料查询失败不丢弃可用的订阅结果。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// 按需汇聚的个人信息;资料查询失败不丢弃可用的订阅结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccountPersonalInfo {
|
||||
pub profile: Result<ProviderProfileStatistics, AdminError>,
|
||||
pub subscription: Option<ProviderSubscription>,
|
||||
}
|
||||
|
||||
/// Provider 官方个人资料中的累计摘要。
|
||||
/// Provider 官方个人资料中的累计摘要
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderProfileStatisticsSummary {
|
||||
pub total_text_tokens: Option<u64>,
|
||||
@@ -867,14 +878,14 @@ pub struct ProviderProfileStatisticsSummary {
|
||||
pub longest_streak_days: Option<u64>,
|
||||
}
|
||||
|
||||
/// Provider 官方个人资料中的单日 Token bucket。
|
||||
/// Provider 官方个人资料中的单日 Token bucket
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderProfileDailyUsage {
|
||||
pub date: NaiveDate,
|
||||
pub tokens: u64,
|
||||
}
|
||||
|
||||
/// Provider 官方个人资料中的插件或 Skill 调用排行。
|
||||
/// Provider 官方个人资料中的插件或 Skill 调用排行
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderProfileInvocation {
|
||||
pub invocation_type: String,
|
||||
@@ -885,7 +896,7 @@ pub struct ProviderProfileInvocation {
|
||||
pub usage_count: Option<u64>,
|
||||
}
|
||||
|
||||
/// Provider 官方个人资料中的活动洞察。
|
||||
/// Provider 官方个人资料中的活动洞察
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderProfileActivityInsights {
|
||||
pub fast_mode_percent: Option<f64>,
|
||||
@@ -897,7 +908,7 @@ pub struct ProviderProfileActivityInsights {
|
||||
pub invocations: Option<Vec<ProviderProfileInvocation>>,
|
||||
}
|
||||
|
||||
/// Provider 已解释的官方个人资料统计。
|
||||
/// Provider 已解释的官方个人资料统计
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderProfileStatistics {
|
||||
pub display_name: Option<String>,
|
||||
@@ -909,16 +920,16 @@ pub struct ProviderProfileStatistics {
|
||||
pub activity_insights: ProviderProfileActivityInsights,
|
||||
}
|
||||
|
||||
/// Provider 头像正文的中立字节流;公共层不解释 MIME,也不限制总字节数。
|
||||
/// Provider 头像正文的中立字节流;公共层不解释 MIME,也不限制总字节数
|
||||
pub type ProviderProfileAvatarStream =
|
||||
Pin<Box<dyn Stream<Item = Result<Bytes, ProviderProfileAvatarStreamError>> + Send + 'static>>;
|
||||
|
||||
/// Provider 头像流在响应开始后的通用失败,不携带上游 URL 或正文。
|
||||
/// Provider 头像流在响应开始后的通用失败,不携带上游 URL 或正文
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("provider profile avatar stream failed")]
|
||||
pub struct ProviderProfileAvatarStreamError;
|
||||
|
||||
/// Provider 已校验并打开的官方头像响应。
|
||||
/// Provider 已校验并打开的官方头像响应
|
||||
pub struct ProviderProfileAvatar {
|
||||
pub content_type: Option<String>,
|
||||
pub content_length: Option<u64>,
|
||||
@@ -938,7 +949,7 @@ impl fmt::Debug for ProviderProfileAvatar {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 返回的一张安全主动额度重置卡。
|
||||
/// Provider 返回的一张安全主动额度重置卡
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderResetCredit {
|
||||
pub id: String,
|
||||
@@ -948,14 +959,14 @@ pub struct ProviderResetCredit {
|
||||
pub reset_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Provider 主动额度重置卡列表。
|
||||
/// Provider 主动额度重置卡列表
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderResetCredits {
|
||||
pub available_count: u64,
|
||||
pub credits: Vec<ProviderResetCredit>,
|
||||
}
|
||||
|
||||
/// 一次主动额度重置卡消费命令。
|
||||
/// 一次主动额度重置卡消费命令
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConsumeProviderResetCredit {
|
||||
pub account_id: ProviderAccountId,
|
||||
@@ -963,7 +974,7 @@ pub struct ConsumeProviderResetCredit {
|
||||
pub redeem_request_id: Uuid,
|
||||
}
|
||||
|
||||
/// Provider 返回的主动额度重置卡消费结果。
|
||||
/// Provider 返回的主动额度重置卡消费结果
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderResetCreditResult {
|
||||
pub code: String,
|
||||
@@ -971,17 +982,18 @@ pub struct ProviderResetCreditResult {
|
||||
}
|
||||
|
||||
impl ProviderQuota {
|
||||
/// 保留账号已有的套餐子类型,仅在缺失时使用上游额度快照补全。
|
||||
/// 保留账号已有的套餐子类型,仅在缺失时使用上游额度快照补全
|
||||
pub(crate) fn fill_missing_plan_type(&self, account_plan_type: &mut Option<String>) {
|
||||
if explicit_plan_type(account_plan_type.as_deref()).is_none() {
|
||||
*account_plan_type = explicit_plan_type(self.plan_type.as_deref()).map(str::to_owned);
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择账号用量统计的周/月窗口,同时返回展示所需的周期事实。
|
||||
/// 选择账号用量统计的周/月窗口,同时返回展示所需的周期事实
|
||||
///
|
||||
/// 只使用边界完整、可归属到整个账号的窗口;周优先于月,同周期保持
|
||||
/// Provider 投影顺序。没有符合条件的窗口时不回退到短期或历史累计。
|
||||
/// Provider 投影顺序
|
||||
/// 没有符合条件的窗口时不回退到短期或历史累计
|
||||
#[must_use]
|
||||
pub fn usage_window(&self) -> Option<(&ProviderQuotaWindow, AccountUsagePeriod)> {
|
||||
self.usage_windows()
|
||||
@@ -989,7 +1001,7 @@ impl ProviderQuota {
|
||||
.min_by_key(|(_, period)| *period)
|
||||
}
|
||||
|
||||
/// 统计与预测共用窗口归属规则,避免把模型专属桶或短期限流当作账号容量。
|
||||
/// 统计与预测共用窗口归属规则,避免把模型专属桶或短期限流当作账号容量
|
||||
pub(crate) fn usage_windows(
|
||||
&self,
|
||||
) -> impl Iterator<Item = (&ProviderQuotaWindow, AccountUsagePeriod)> {
|
||||
@@ -1011,11 +1023,12 @@ impl ProviderQuota {
|
||||
})
|
||||
}
|
||||
|
||||
/// 返回 Dashboard 使用的代表性额度比例。
|
||||
/// 返回 Dashboard 使用的代表性额度比例
|
||||
///
|
||||
/// 优先使用覆盖账号全部请求的窗口;同一归属范围内再依次使用短周期、周、月
|
||||
/// 和其它窗口,同一优先级取较高的已用比例。这里只解释跨 Provider 共享的窗口
|
||||
/// 语义,绝不读取 Provider 私有 JSON。
|
||||
/// 和其它窗口,同一优先级取较高的已用比例
|
||||
/// 这里只解释跨 Provider 共享的窗口
|
||||
/// 语义,绝不读取 Provider 私有 JSON
|
||||
#[must_use]
|
||||
pub fn representative_used_percent(&self) -> Option<f64> {
|
||||
if self.limit_reached {
|
||||
@@ -1025,7 +1038,7 @@ impl ProviderQuota {
|
||||
.map(|(_, used_percent)| used_percent)
|
||||
}
|
||||
|
||||
/// 选择 Dashboard 展示的真实窗口;没有百分比时仍保留 Provider 的滚动窗口语义。
|
||||
/// 选择 Dashboard 展示的真实窗口;没有百分比时仍保留 Provider 的滚动窗口语义
|
||||
#[must_use]
|
||||
pub fn representative_window(&self) -> Option<&ProviderQuotaWindow> {
|
||||
self.representative_used_window()
|
||||
@@ -1039,10 +1052,10 @@ impl ProviderQuota {
|
||||
})
|
||||
}
|
||||
|
||||
/// 将已确认的账号级额度耗尽事实投影到展示窗口。
|
||||
/// 将已确认的账号级额度耗尽事实投影到展示窗口
|
||||
///
|
||||
/// Provider 已指出具体触顶窗口时只归一化这些窗口;否则归一化 Dashboard
|
||||
/// 同样会选择的代表窗口,避免把多个独立额度窗口全部伪造成已用尽。
|
||||
/// 同样会选择的代表窗口,避免把多个独立额度窗口全部伪造成已用尽
|
||||
pub fn apply_limit_reached_display(&mut self) {
|
||||
if !self.limit_reached {
|
||||
return;
|
||||
@@ -1122,21 +1135,21 @@ fn is_week_window(seconds: u64) -> bool {
|
||||
seconds > 0 && seconds.abs_diff(WEEK_SECONDS) <= WEEK_SECONDS / 20
|
||||
}
|
||||
|
||||
/// Provider 实时模型目录的一项。
|
||||
/// Provider 实时模型目录的一项
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderModel {
|
||||
pub id: UpstreamModelId,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Provider 实时模型目录。
|
||||
/// Provider 实时模型目录
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderModels {
|
||||
pub models: Vec<ProviderModel>,
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Provider 原生模型目录正文;wire 语义由对应 Provider 拥有,公共层只搬运不解释字段。
|
||||
/// Provider 原生模型目录正文;wire 语义由对应 Provider 拥有,公共层只搬运不解释字段
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderModelCatalogDocument {
|
||||
pub document: RawJsonPayload,
|
||||
@@ -1144,7 +1157,7 @@ pub struct ProviderModelCatalogDocument {
|
||||
pub observed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 插件账号回调的服务端过滤条件;Provider 缺省时跨全部已注册类型分页。
|
||||
/// 插件账号回调的服务端过滤条件;Provider 缺省时跨全部已注册类型分页
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginAccountListQuery {
|
||||
pub provider_kind: Option<ProviderKind>,
|
||||
@@ -1158,7 +1171,7 @@ pub struct PluginAccountPage {
|
||||
pub next_cursor: Option<ProviderAccountId>,
|
||||
}
|
||||
|
||||
/// 原始凭据与当前账号 revision 的同一读取结果。
|
||||
/// 原始凭据与当前账号 revision 的同一读取结果
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct PluginAccountCredential {
|
||||
pub account: AccountRecord,
|
||||
@@ -1175,7 +1188,7 @@ impl fmt::Debug for PluginAccountCredential {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime 已完成 wire 校验、Admin 仍需绑定当前账号和 revision 的 prepared facts。
|
||||
/// Runtime 已完成 wire 校验、Admin 仍需绑定当前账号和 revision 的 prepared facts
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PreparedPluginAccountSave {
|
||||
Create(PreparedCredentialCreate),
|
||||
@@ -1192,20 +1205,20 @@ pub struct PluginAccountSaveResult {
|
||||
pub credential_revision: Revision,
|
||||
}
|
||||
|
||||
/// Provider 执行 refresh 时所需的当前公共账号事实。
|
||||
/// Provider 执行 refresh 时所需的当前公共账号事实
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PrepareCredentialRefresh {
|
||||
pub account: AccountRecord,
|
||||
}
|
||||
|
||||
/// Provider 敏感导出结果。
|
||||
/// Provider 敏感导出结果
|
||||
pub struct ProviderExport {
|
||||
pub provider_kind: ProviderKind,
|
||||
pub account_ids: Vec<ProviderAccountId>,
|
||||
pub document: ProviderDocument,
|
||||
}
|
||||
|
||||
/// Store 为 Provider 导出序列化准备的最小输入;material 对公共层保持不透明。
|
||||
/// Store 为 Provider 导出序列化准备的最小输入;material 对公共层保持不透明
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderExportCredentialInput {
|
||||
pub account: AccountRecord,
|
||||
@@ -1223,20 +1236,20 @@ impl fmt::Debug for ProviderExport {
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一账号目录的一行完整结果。
|
||||
/// 统一账号目录的一行完整结果
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountDirectoryItem {
|
||||
pub account: AccountRecord,
|
||||
pub capacity: super::accounts::AccountCapacity,
|
||||
pub capabilities: super::accounts::ProviderAccountCapabilities,
|
||||
/// Provider 提供的套餐展示名称;未识别到套餐时为空。
|
||||
/// Provider 提供的套餐展示名称;未识别到套餐时为空
|
||||
pub plan_type_display: Option<String>,
|
||||
pub projection: gateway_core::account::AccountStatusProjection,
|
||||
pub usage: Option<super::accounts::AccountUsage>,
|
||||
pub quota: ProviderQuota,
|
||||
}
|
||||
|
||||
/// 统一账号目录页。
|
||||
/// 统一账号目录页
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountDirectoryPage {
|
||||
pub config_revision: Revision,
|
||||
@@ -1245,14 +1258,14 @@ pub struct AccountDirectoryPage {
|
||||
pub summary: AccountSummary,
|
||||
}
|
||||
|
||||
/// 凭据刷新提交后的完整账号结果。
|
||||
/// 凭据刷新提交后的完整账号结果
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountRefreshResult {
|
||||
pub config_revision: Revision,
|
||||
pub account: AccountDirectoryItem,
|
||||
}
|
||||
|
||||
/// 多 Provider 导出文档集合。
|
||||
/// 多 Provider 导出文档集合
|
||||
pub struct AccountExportBundle {
|
||||
pub exported_at: DateTime<Utc>,
|
||||
pub documents: Vec<ProviderExport>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 可复用的账号出口配置,以及脱敏后的连通性测试结果。
|
||||
//! 可复用的账号出口配置,以及脱敏后的连通性测试结果
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::account::{OutboundProxy, RequestLocation};
|
||||
@@ -39,7 +39,7 @@ pub struct ProxyAccountRef {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// 按代理查询关联账号,分页与搜索均在存储层执行。
|
||||
/// 按代理查询关联账号,分页与搜索均在存储层执行
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyAccountListQuery {
|
||||
pub proxy_id: String,
|
||||
@@ -94,7 +94,7 @@ pub struct ProxyPage {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewProxy {
|
||||
pub auto_location: bool,
|
||||
/// 由管理用例探测,随配置原子保存,不接受 HTTP 调用方提供结果。
|
||||
/// 由管理用例探测,随配置原子保存,不接受 HTTP 调用方提供结果
|
||||
pub test: Option<ProxyTestResult>,
|
||||
pub location: Option<gateway_core::account::RequestLocation>,
|
||||
pub name: String,
|
||||
@@ -105,7 +105,7 @@ pub struct NewProxy {
|
||||
pub struct UpdateProxy {
|
||||
pub auto_location: Option<bool>,
|
||||
pub test: Option<ProxyTestResult>,
|
||||
/// 外层为空保留配置,内层为空恢复全局继承。
|
||||
/// 外层为空保留配置,内层为空恢复全局继承
|
||||
pub location: Option<Option<gateway_core::account::RequestLocation>>,
|
||||
pub id: String,
|
||||
pub revision: Revision,
|
||||
@@ -119,7 +119,7 @@ pub struct ProxyMutation {
|
||||
pub record: ProxyRecord,
|
||||
}
|
||||
|
||||
/// 检测失败与连通性分开,双栈冲突不得继续应用旧位置。
|
||||
/// 检测失败与连通性分开,双栈冲突不得继续应用旧位置
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
pub enum ProxyLocationDetection {
|
||||
@@ -155,7 +155,7 @@ impl ProxyRecord {
|
||||
}
|
||||
}
|
||||
|
||||
/// 未知出口可暂用上次成功结果,已知出口变化或时区冲突必须撤销旧覆盖。
|
||||
/// 未知出口可暂用上次成功结果,已知出口变化或时区冲突必须撤销旧覆盖
|
||||
#[must_use]
|
||||
pub fn detected_location_after_test(
|
||||
&self,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 账号额度的只读容量估算,不参与金额结算或账号调度。
|
||||
//! 账号额度的只读容量估算,不参与金额结算或账号调度
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
|
||||
@@ -40,9 +40,9 @@ pub struct QuotaForecastSource {
|
||||
pub usd: Option<f64>,
|
||||
}
|
||||
|
||||
/// 为目标周期选择唯一的预测源窗口;缺少同周期窗口时复用最短的可统计窗口。
|
||||
/// 为目标周期选择唯一的预测源窗口;缺少同周期窗口时复用最短的可统计窗口
|
||||
///
|
||||
/// Admin 的历史查询与最终投影必须共用这里的顺序,避免查询不会进入响应的窗口。
|
||||
/// Admin 的历史查询与最终投影必须共用这里的顺序,避免查询不会进入响应的窗口
|
||||
pub(crate) fn quota_forecast_source_window(
|
||||
quota: &ProviderQuota,
|
||||
period: AccountUsagePeriod,
|
||||
@@ -53,8 +53,8 @@ pub(crate) fn quota_forecast_source_window(
|
||||
.or_else(|| quota.usage_windows().min_by_key(|(_, period)| *period))
|
||||
}
|
||||
|
||||
/// 优先预测真实的对应窗口;缺少对应周期时只给出明确标识的 7/30 天容量折算。
|
||||
/// 本地日志不能证明站外消耗或完整留存,因此即使样本充足也不声称官方额度。
|
||||
/// 优先预测真实的对应窗口;缺少对应周期时只给出明确标识的 7/30 天容量折算
|
||||
/// 本地日志不能证明站外消耗或完整留存,因此即使样本充足也不声称官方额度
|
||||
#[must_use]
|
||||
pub fn account_quota_forecasts(
|
||||
quota: &ProviderQuota,
|
||||
@@ -178,9 +178,9 @@ impl AccountQuotaForecast {
|
||||
};
|
||||
self.low_sample = sample.sampled_percent < LOW_SAMPLE_PERCENT
|
||||
|| (method == QuotaForecastMethod::Incremental && sample.block_count < 2);
|
||||
// 漏记和个别缺失只影响精度,仍按已记录数值估算,不按请求数补齐未知消耗。
|
||||
// 预测是近似展示值;不复用为账单金额,也不把月折算当成自然月或额外余额。
|
||||
// 已发生的用量保持本周期累计,只把近期消耗比例用于尚未使用的额度。
|
||||
// 漏记和个别缺失只影响精度,仍按已记录数值估算,不按请求数补齐未知消耗
|
||||
// 预测是近似展示值;不复用为账单金额,也不把月折算当成自然月或额外余额
|
||||
// 已发生的用量保持本周期累计,只把近期消耗比例用于尚未使用的额度
|
||||
let factor = self.target_seconds as f64 / seconds as f64;
|
||||
let remaining_factor = (100.0 - percent) / sample.sampled_percent;
|
||||
let remaining_tokens = Some(sample.usage.tokens)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! 预测专用的配对采样;不改变完整交付用量或费用账本的统计口径。
|
||||
//! 预测专用的配对采样;不改变完整交付用量或费用账本的统计口径
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::provider_credentials::ProviderDocument;
|
||||
|
||||
/// 限制带 Provider 文档的历史点数量;累计用量仍覆盖整个查询区间。
|
||||
/// 限制带 Provider 文档的历史点数量;累计用量仍覆盖整个查询区间
|
||||
pub const MAX_FORECAST_HISTORY_POINTS: usize = 128;
|
||||
const MIN_BLOCK_PERCENT: f64 = 5.0;
|
||||
const RECENT_BLOCKS: usize = 3;
|
||||
@@ -15,7 +15,7 @@ pub struct QuotaForecastUsage {
|
||||
pub tokens: u64,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cached_tokens: u64,
|
||||
pub cached_input_tokens: u64,
|
||||
pub missing_token_count: u64,
|
||||
pub known_cost_count: u64,
|
||||
pub unavailable_cost_count: u64,
|
||||
@@ -24,7 +24,7 @@ pub struct QuotaForecastUsage {
|
||||
}
|
||||
|
||||
impl QuotaForecastUsage {
|
||||
/// 累计计数倒退表示不能配对,不用饱和减法掩盖数据断点。
|
||||
/// 累计计数倒退表示不能配对,不用饱和减法掩盖数据断点
|
||||
fn difference(&self, baseline: &Self) -> Option<Self> {
|
||||
let usd = self.usd - baseline.usd;
|
||||
if !usd.is_finite() || usd < -1e-9 {
|
||||
@@ -35,7 +35,9 @@ impl QuotaForecastUsage {
|
||||
tokens: self.tokens.checked_sub(baseline.tokens)?,
|
||||
input_tokens: self.input_tokens.checked_sub(baseline.input_tokens)?,
|
||||
output_tokens: self.output_tokens.checked_sub(baseline.output_tokens)?,
|
||||
cached_tokens: self.cached_tokens.checked_sub(baseline.cached_tokens)?,
|
||||
cached_input_tokens: self
|
||||
.cached_input_tokens
|
||||
.checked_sub(baseline.cached_input_tokens)?,
|
||||
missing_token_count: self
|
||||
.missing_token_count
|
||||
.checked_sub(baseline.missing_token_count)?,
|
||||
@@ -68,7 +70,7 @@ pub struct QuotaForecastHistory {
|
||||
pub pending_request_count: u64,
|
||||
}
|
||||
|
||||
/// 具体 Provider 从自己的历史文档解释出的额度事实。
|
||||
/// 具体 Provider 从自己的历史文档解释出的额度事实
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct QuotaForecastObservation {
|
||||
pub used_percent: f64,
|
||||
@@ -101,13 +103,13 @@ pub struct QuotaForecastSample {
|
||||
pub observation_count: usize,
|
||||
pub pending_request_count: u64,
|
||||
pub discontinuous: bool,
|
||||
/// 本周期已记录用量;近期采样仅用于预测剩余量,不能覆盖这份累计值。
|
||||
/// 本周期已记录用量;近期采样仅用于预测剩余量,不能覆盖这份累计值
|
||||
pub cycle_usage: QuotaForecastUsage,
|
||||
pub usage: QuotaForecastUsage,
|
||||
}
|
||||
|
||||
/// 对同一额度段的累计点形成至少 5 个百分点的连续块,使用最近三个完整块及尾部。
|
||||
/// 不平均逐请求比值;重复百分比不增加块数,5/10 个百分点仅是量化噪声门槛。
|
||||
/// 对同一额度段的累计点形成至少 5 个百分点的连续块,使用最近三个完整块及尾部
|
||||
/// 不平均逐请求比值;重复百分比不增加块数,5/10 个百分点仅是量化噪声门槛
|
||||
#[must_use]
|
||||
pub fn select_forecast_sample(
|
||||
key: String,
|
||||
@@ -139,8 +141,8 @@ pub fn select_forecast_sample(
|
||||
usage: current.usage.clone(),
|
||||
};
|
||||
history.push(current);
|
||||
// 重置边界不在查询起点时,从首个新观测重新计数,避免把旧周期累计带入总量。
|
||||
let mut cycle_baseline = interrupted.then(|| &history[0]);
|
||||
// 重置边界不在查询起点时,从首个新观测重新计数,避免把旧周期累计带入总量
|
||||
let cycle_baseline = interrupted.then(|| &history[0]);
|
||||
let mut anchors: Vec<&QuotaForecastPoint> = Vec::new();
|
||||
let mut high_water: Option<&QuotaForecastPoint> = None;
|
||||
for point in &history {
|
||||
@@ -149,13 +151,13 @@ pub fn select_forecast_sample(
|
||||
continue;
|
||||
}
|
||||
if point.used_percent < previous.used_percent {
|
||||
// 一个百分点内的回落可能是量化/并发乱序;更大的回落不能跨越。
|
||||
// 一个百分点内的回落可能是量化/并发乱序;更大的回落不能跨越
|
||||
if previous.used_percent - point.used_percent <= 1.0 {
|
||||
continue;
|
||||
}
|
||||
// 同窗口回落不能证明周期重置,只重建采样并保留已记录累计
|
||||
sample.discontinuous = true;
|
||||
anchors.clear();
|
||||
cycle_baseline = Some(point);
|
||||
}
|
||||
}
|
||||
high_water = Some(point);
|
||||
@@ -198,7 +200,7 @@ pub fn select_forecast_sample(
|
||||
sample.sampled_percent = progress;
|
||||
sample.block_count = anchors.len() - baseline_index - 1;
|
||||
sample.usage = usage;
|
||||
// 断点后的新基线已有足够进度时,只消费新段;旧段从不参与本次估算。
|
||||
// 断点后的新基线已有足够进度时,只消费新段;旧段从不参与本次估算
|
||||
sample.discontinuous = false;
|
||||
sample
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 历史数据保留规则;存储实现只接受已校验的窗口。
|
||||
//! 历史数据保留规则;存储实现只接受已校验的窗口
|
||||
|
||||
use super::AdminError;
|
||||
|
||||
@@ -17,8 +17,26 @@ pub struct RetentionPolicy {
|
||||
}
|
||||
|
||||
impl RetentionPolicy {
|
||||
/// 保留期管理与全量设置保存共用的字段约束
|
||||
pub fn validate_values(
|
||||
usage_days: u32,
|
||||
ops_days: u32,
|
||||
audit_days: u32,
|
||||
) -> Result<(), &'static str> {
|
||||
for (valid, field) in [
|
||||
(usage_days >= 31, "usage_retention_days"),
|
||||
(ops_days > 0, "ops_event_retention_days"),
|
||||
(audit_days > 0, "audit_retention_days"),
|
||||
] {
|
||||
if !valid {
|
||||
return Err(field);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn try_new(usage_days: u32, ops_days: u32, audit_days: u32) -> Result<Self, AdminError> {
|
||||
if usage_days < 31 || ops_days == 0 || audit_days == 0 {
|
||||
if Self::validate_values(usage_days, ops_days, audit_days).is_err() {
|
||||
return Err(AdminError::invalid(
|
||||
"请求保留期至少 31 天,事件与审计保留期必须为正数",
|
||||
));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Runtime settings 与明文管理员 API Key 的语义模型。
|
||||
//! Runtime settings 与明文管理员 API Key 的语义模型
|
||||
|
||||
use std::{collections::BTreeMap, fmt};
|
||||
|
||||
@@ -8,96 +8,103 @@ use gateway_core::routing::{ProviderKind, PublicModelId, UpstreamModelId};
|
||||
|
||||
use super::Revision;
|
||||
|
||||
/// 客户端模型到上游模型的全局精确映射。
|
||||
/// 客户端模型到上游模型的全局精确映射
|
||||
pub type ModelMappings = BTreeMap<PublicModelId, UpstreamModelId>;
|
||||
|
||||
/// Provider-owned 请求画像选择;Provider ID 是唯一命名空间。
|
||||
/// 全局模型映射的数量与文本约束;调用方保留各自的键值表示
|
||||
pub fn validate_model_mappings<'a>(
|
||||
mappings: impl ExactSizeIterator<Item = (&'a str, &'a str)>,
|
||||
) -> Result<(), &'static str> {
|
||||
if mappings.len() > 512 {
|
||||
return Err("model_mappings");
|
||||
}
|
||||
for (requested, upstream) in mappings {
|
||||
for value in [requested, upstream] {
|
||||
if value.is_empty()
|
||||
|| value.len() > 256
|
||||
|| value.bytes().any(|byte| byte.is_ascii_control())
|
||||
{
|
||||
return Err("model_mappings");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Provider-owned 请求画像选择;Provider ID 是唯一命名空间
|
||||
pub type ProviderRequestProfiles =
|
||||
BTreeMap<ProviderKind, gateway_core::account::OpaqueProviderData>;
|
||||
|
||||
/// Provider-owned 请求画像的部分更新;`None` 只表示显式删除该 Provider 的历史选择。
|
||||
/// Provider-owned 请求画像的部分更新;`None` 只表示显式删除该 Provider 的历史选择
|
||||
pub type ProviderRequestProfileUpdates =
|
||||
BTreeMap<ProviderKind, Option<gateway_core::account::OpaqueProviderData>>;
|
||||
|
||||
/// 账号调度策略;由 Core 拥有稳定值与 wire 映射。
|
||||
/// 账号调度策略;由 Core 拥有稳定值与 wire 映射
|
||||
pub use gateway_core::account::RotationStrategy;
|
||||
|
||||
/// 完整运行设置事实。
|
||||
/// 运行设置的共同值;版本、Provider 画像更新和数据库秘密由外层类型拥有
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RuntimeSettingsValues {
|
||||
pub codex_privacy_policy: gateway_core::settings::privacy::CodexPrivacyPolicy,
|
||||
pub request_location_enabled: bool,
|
||||
pub request_location: gateway_core::account::RequestLocation,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u32,
|
||||
pub max_concurrent_per_account: u32,
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub openai_guardian_reserved_concurrency: u32,
|
||||
pub openai_account_affinity: gateway_core::account::AccountAffinity,
|
||||
pub max_account_rotations: u32,
|
||||
pub openai_session_affinity_ttl_hours: u32,
|
||||
pub responses_max_decompressed_body_bytes: u64,
|
||||
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
pub usage_retention_days: u32,
|
||||
pub ops_event_retention_days: u32,
|
||||
pub audit_retention_days: u32,
|
||||
pub account_auto_freeze_enabled: bool,
|
||||
pub account_auto_freeze_threshold: u32,
|
||||
pub account_auto_freeze_window_seconds: u64,
|
||||
pub account_auto_freeze_duration_seconds: u64,
|
||||
pub account_auto_freeze_probe_enabled: bool,
|
||||
pub account_auto_freeze_probe_model: Option<String>,
|
||||
pub account_auto_freeze_adaptive_concurrency: bool,
|
||||
pub account_warmup_enabled: bool,
|
||||
pub account_warmup_schedule_time: String,
|
||||
pub account_warmup_model: Option<String>,
|
||||
}
|
||||
|
||||
/// 完整运行设置事实
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeSettings {
|
||||
#[serde(flatten)]
|
||||
pub values: RuntimeSettingsValues,
|
||||
pub request_profiles: ProviderRequestProfiles,
|
||||
pub config_revision: Revision,
|
||||
pub request_location_enabled: bool,
|
||||
pub request_location: gateway_core::account::RequestLocation,
|
||||
pub model_mappings: ModelMappings,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u32,
|
||||
pub max_concurrent_per_account: u32,
|
||||
pub request_interval_ms: u64,
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub responses_max_decompressed_body_bytes: u64,
|
||||
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
|
||||
pub rotation_strategy: RotationStrategy,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
pub usage_retention_days: u32,
|
||||
pub ops_event_retention_days: u32,
|
||||
pub audit_retention_days: u32,
|
||||
pub account_auto_freeze_enabled: bool,
|
||||
pub account_auto_freeze_threshold: u32,
|
||||
pub account_auto_freeze_window_seconds: u64,
|
||||
pub account_auto_freeze_duration_seconds: u64,
|
||||
pub account_auto_freeze_probe_enabled: bool,
|
||||
pub account_auto_freeze_probe_model: Option<String>,
|
||||
pub account_auto_freeze_adaptive_concurrency: bool,
|
||||
pub account_warmup_enabled: bool,
|
||||
pub account_warmup_schedule_time: String,
|
||||
pub account_warmup_model: Option<String>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 原子替换运行设置的命令。
|
||||
/// 原子替换运行设置的命令
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ReplaceRuntimeSettings {
|
||||
/// 读取设置时的版本;与写入在同一事务内比较,防止覆盖并发更新。
|
||||
#[serde(flatten)]
|
||||
pub values: RuntimeSettingsValues,
|
||||
/// 读取设置时的版本;与写入在同一事务内比较,防止覆盖并发更新
|
||||
pub expected_revision: Revision,
|
||||
/// 只覆盖提交的 Provider;未提交项保留当前持久值。
|
||||
/// 只覆盖提交的 Provider;未提交项保留当前持久值
|
||||
pub request_profile_updates: ProviderRequestProfileUpdates,
|
||||
pub request_location_enabled: bool,
|
||||
pub request_location: gateway_core::account::RequestLocation,
|
||||
pub model_mappings: ModelMappings,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u32,
|
||||
pub max_concurrent_per_account: u32,
|
||||
pub request_interval_ms: u64,
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub responses_max_decompressed_body_bytes: u64,
|
||||
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
|
||||
pub rotation_strategy: RotationStrategy,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
pub usage_retention_days: u32,
|
||||
pub ops_event_retention_days: u32,
|
||||
pub audit_retention_days: u32,
|
||||
pub account_auto_freeze_enabled: bool,
|
||||
pub account_auto_freeze_threshold: u32,
|
||||
pub account_auto_freeze_window_seconds: u64,
|
||||
pub account_auto_freeze_duration_seconds: u64,
|
||||
pub account_auto_freeze_probe_enabled: bool,
|
||||
pub account_auto_freeze_probe_model: Option<String>,
|
||||
pub account_auto_freeze_adaptive_concurrency: bool,
|
||||
pub account_warmup_enabled: bool,
|
||||
pub account_warmup_schedule_time: String,
|
||||
pub account_warmup_model: Option<String>,
|
||||
}
|
||||
|
||||
/// 明文管理员 API Key;按产品约束明文落库,但禁止 Debug 泄漏。
|
||||
/// 明文管理员 API Key;按产品约束明文落库,但禁止 Debug 泄漏
|
||||
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct AdminApiKey(String);
|
||||
@@ -113,7 +120,7 @@ impl AdminApiKey {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// 仅供显式 regenerate 响应读取一次。
|
||||
/// 仅供显式 regenerate 响应读取一次
|
||||
#[must_use]
|
||||
pub fn expose_for_response(&self) -> &str {
|
||||
&self.0
|
||||
@@ -126,7 +133,7 @@ impl fmt::Debug for AdminApiKey {
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理员 API Key 更新结果。
|
||||
/// 管理员 API Key 更新结果
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AdminApiKeyMutation {
|
||||
@@ -134,7 +141,7 @@ pub struct AdminApiKeyMutation {
|
||||
pub exists: bool,
|
||||
}
|
||||
|
||||
/// 新管理员 API Key 的一次性返回结果。
|
||||
/// 新管理员 API Key 的一次性返回结果
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RegeneratedAdminApiKey {
|
||||
@@ -172,35 +179,59 @@ impl From<RuntimeSettings> for ReplaceRuntimeSettings {
|
||||
.into_iter()
|
||||
.map(|(provider, value)| (provider, Some(value)))
|
||||
.collect(),
|
||||
request_location_enabled: settings.request_location_enabled,
|
||||
request_location: settings.request_location,
|
||||
values: settings.values,
|
||||
model_mappings: settings.model_mappings,
|
||||
refresh_margin_seconds: settings.refresh_margin_seconds,
|
||||
refresh_concurrency: settings.refresh_concurrency,
|
||||
max_concurrent_per_account: settings.max_concurrent_per_account,
|
||||
request_interval_ms: settings.request_interval_ms,
|
||||
max_waiting_per_key: settings.max_waiting_per_key,
|
||||
max_waiting_per_account: settings.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
|
||||
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
|
||||
smart_scheduling: settings.smart_scheduling,
|
||||
rotation_strategy: settings.rotation_strategy,
|
||||
min_codex_desktop_version: settings.min_codex_desktop_version,
|
||||
min_codex_cli_version: settings.min_codex_cli_version,
|
||||
usage_retention_days: settings.usage_retention_days,
|
||||
ops_event_retention_days: settings.ops_event_retention_days,
|
||||
audit_retention_days: settings.audit_retention_days,
|
||||
account_auto_freeze_enabled: settings.account_auto_freeze_enabled,
|
||||
account_auto_freeze_threshold: settings.account_auto_freeze_threshold,
|
||||
account_auto_freeze_window_seconds: settings.account_auto_freeze_window_seconds,
|
||||
account_auto_freeze_duration_seconds: settings.account_auto_freeze_duration_seconds,
|
||||
account_auto_freeze_probe_enabled: settings.account_auto_freeze_probe_enabled,
|
||||
account_auto_freeze_probe_model: settings.account_auto_freeze_probe_model,
|
||||
account_auto_freeze_adaptive_concurrency: settings
|
||||
.account_auto_freeze_adaptive_concurrency,
|
||||
account_warmup_enabled: settings.account_warmup_enabled,
|
||||
account_warmup_schedule_time: settings.account_warmup_schedule_time,
|
||||
account_warmup_model: settings.account_warmup_model,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeSettingsValues {
|
||||
/// 校验共同事实,返回字段名供边界转换错误;SQL 类型转换仍由 Store 处理
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
self.request_location
|
||||
.validate()
|
||||
.map_err(|_| "request_location")?;
|
||||
gateway_core::settings::validate_request_limits(
|
||||
self.max_waiting_per_key,
|
||||
self.max_waiting_per_account,
|
||||
self.concurrency_wait_timeout_seconds,
|
||||
self.max_account_rotations,
|
||||
self.openai_session_affinity_ttl_hours,
|
||||
)?;
|
||||
gateway_core::settings::response_body_limit(self.responses_max_decompressed_body_bytes)?;
|
||||
gateway_core::settings::client_min_versions(
|
||||
self.min_codex_desktop_version.as_deref(),
|
||||
self.min_codex_cli_version.as_deref(),
|
||||
)?;
|
||||
for (valid, field) in [
|
||||
(
|
||||
self.refresh_margin_seconds > 0
|
||||
&& i64::try_from(self.refresh_margin_seconds).is_ok(),
|
||||
"refresh_margin_seconds",
|
||||
),
|
||||
(self.refresh_concurrency > 0, "refresh_concurrency"),
|
||||
] {
|
||||
if !valid {
|
||||
return Err(field);
|
||||
}
|
||||
}
|
||||
super::retention::RetentionPolicy::validate_values(
|
||||
self.usage_retention_days,
|
||||
self.ops_event_retention_days,
|
||||
self.audit_retention_days,
|
||||
)?;
|
||||
gateway_core::provider_ports::ProviderFreezePolicy::validate_values(
|
||||
self.account_auto_freeze_threshold,
|
||||
self.account_auto_freeze_window_seconds,
|
||||
self.account_auto_freeze_duration_seconds,
|
||||
self.account_auto_freeze_probe_model.as_deref(),
|
||||
)?;
|
||||
gateway_core::provider_ports::ProviderWarmupPolicy::validate_values(
|
||||
self.account_warmup_enabled,
|
||||
&self.account_warmup_schedule_time,
|
||||
self.account_warmup_model.as_deref(),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! 版本、自更新、回滚与进程重启的 UTC 语义模型。
|
||||
//! 版本、自更新、回滚与进程重启的 UTC 语义模型
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 本次检查或安装选择的更新稳定性范围,具体版本准入由 Host 判定。
|
||||
/// 本次检查或安装选择的更新稳定性范围,具体版本准入由 Host 判定
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SystemUpdateChannel {
|
||||
@@ -28,7 +28,7 @@ impl SystemUpdateChannel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Host 返回的本次检查通道及当前构建允许选择的范围。
|
||||
/// Host 返回的本次检查通道及当前构建允许选择的范围
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SystemUpdatePolicy {
|
||||
@@ -36,9 +36,9 @@ pub struct SystemUpdatePolicy {
|
||||
pub available_channels: Vec<SystemUpdateChannel>,
|
||||
}
|
||||
|
||||
/// 当前运行版本及更新检查摘要。
|
||||
/// 当前运行版本及更新检查摘要
|
||||
///
|
||||
/// `deployment_mode_label` 之类的本地化字符串由 API 根据原始枚举值生成。
|
||||
/// `deployment_mode_label` 之类的本地化字符串由 API 根据原始枚举值生成
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SystemVersion {
|
||||
pub version: String,
|
||||
@@ -52,9 +52,9 @@ pub struct SystemVersion {
|
||||
pub update_warning: Option<String>,
|
||||
}
|
||||
|
||||
/// 发布源提供的完整更新详情。
|
||||
/// 发布源提供的完整更新详情
|
||||
///
|
||||
/// 部署模式与构建类型的展示标签归 API;其余字段均为 Host 已确认的原始事实。
|
||||
/// 部署模式与构建类型的展示标签归 API;其余字段均为 Host 已确认的原始事实
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SystemUpdateDetail {
|
||||
pub policy: SystemUpdatePolicy,
|
||||
@@ -71,7 +71,7 @@ pub struct SystemUpdateDetail {
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
/// 系统操作类型。
|
||||
/// 系统操作类型
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SystemOperationKind {
|
||||
Update,
|
||||
@@ -79,7 +79,7 @@ pub enum SystemOperationKind {
|
||||
Restart,
|
||||
}
|
||||
|
||||
/// 系统操作状态。
|
||||
/// 系统操作状态
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SystemOperationStatus {
|
||||
Idle,
|
||||
@@ -88,7 +88,7 @@ pub enum SystemOperationStatus {
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// 持久化系统操作的完整状态。
|
||||
/// 持久化系统操作的完整状态
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SystemOperationState {
|
||||
pub operation_id: Option<String>,
|
||||
@@ -101,7 +101,7 @@ pub struct SystemOperationState {
|
||||
pub finished_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 最近一次系统操作及版本交换状态。
|
||||
/// 最近一次系统操作及版本交换状态
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SystemUpdateStatus {
|
||||
pub previous_version: Option<String>,
|
||||
@@ -110,7 +110,7 @@ pub struct SystemUpdateStatus {
|
||||
pub operation: SystemOperationState,
|
||||
}
|
||||
|
||||
/// 自更新日志级别。
|
||||
/// 自更新日志级别
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SystemUpdateEventLevel {
|
||||
Info,
|
||||
@@ -119,7 +119,7 @@ pub enum SystemUpdateEventLevel {
|
||||
Error,
|
||||
}
|
||||
|
||||
/// 一条已脱敏的系统更新事件。
|
||||
/// 一条已脱敏的系统更新事件
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SystemUpdateEvent {
|
||||
pub id: String,
|
||||
@@ -132,7 +132,7 @@ pub struct SystemUpdateEvent {
|
||||
pub occurred_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 已受理的异步系统操作;不同 wire shape 由枚举保持为合法状态。
|
||||
/// 已受理的异步系统操作;不同 wire shape 由枚举保持为合法状态
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SystemOperationAccepted {
|
||||
Update {
|
||||
@@ -163,7 +163,7 @@ impl SystemOperationAccepted {
|
||||
}
|
||||
}
|
||||
|
||||
/// 重启前的只读确认快照;目标发行与全局配置版本一起绑定用户确认。
|
||||
/// 重启前的只读确认快照;目标发行与全局配置版本一起绑定用户确认
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct SystemRestartPlan {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! 备份控制面所需的持久化与基础设施能力。
|
||||
//! 备份控制面所需的持久化与基础设施能力
|
||||
//!
|
||||
//! 端口方法只接受/返回领域类型与既有错误体系:`BackupRepository` 复用
|
||||
//! `AdminStoreError`;`DatabaseDumpPort` 与 `BackupObjectStorePort` 返回只携带稳定
|
||||
//! 错误码的 [`BackupError`]。`gateway-store` 实现全部端口;`gateway-admin` 拥有
|
||||
//! 状态机、Cron、保留策略与 BackupTask 算法。
|
||||
//! 错误码的 [`BackupError`]
|
||||
//! `gateway-store` 实现全部端口;`gateway-admin` 拥有
|
||||
//! 状态机、Cron、保留策略与 BackupTask 算法
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -23,7 +24,7 @@ use crate::model::{
|
||||
};
|
||||
use crate::ports::store::AdminStoreResult;
|
||||
|
||||
/// Worker 状态迁移携带的字段更新;`None` 字段不写入。
|
||||
/// Worker 状态迁移携带的字段更新;`None` 字段不写入
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct StatusTransitionUpdate {
|
||||
pub size_bytes: Option<u64>,
|
||||
@@ -33,66 +34,75 @@ pub struct StatusTransitionUpdate {
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 备份配置、任务与计划游标的 PostgreSQL 仓储端口。
|
||||
/// 备份配置、任务与计划游标的 PostgreSQL 仓储端口
|
||||
#[async_trait]
|
||||
pub trait BackupRepository: Send + Sync {
|
||||
/// 读取完整设置(含 Secret)。
|
||||
/// 读取完整设置(含 Secret)
|
||||
async fn load_settings(&self) -> AdminStoreResult<BackupSettings>;
|
||||
|
||||
/// 同事务更新 S3 存储配置、递增 revision、清空验证状态并写审计事件。
|
||||
/// 同事务更新 S3 存储配置、递增 revision、清空验证状态并写审计事件
|
||||
///
|
||||
/// 已有记录时禁止切换 endpoint/region/bucket/path-style(存储身份锁定)。
|
||||
/// 返回更新后的设置与新的全局 `config_revision`。
|
||||
/// 已有记录时禁止切换 endpoint/region/bucket/path-style(存储身份锁定)
|
||||
/// 返回更新后的设置与新的全局 `config_revision`
|
||||
async fn update_storage_settings(
|
||||
&self,
|
||||
command: UpdateBackupStorageCommand,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<(BackupSettings, Revision)>;
|
||||
|
||||
/// 同事务更新调度配置、推进计划游标并写审计事件。
|
||||
/// 同事务更新调度配置、推进计划游标并写审计事件
|
||||
async fn update_schedule_settings(
|
||||
&self,
|
||||
command: UpdateBackupScheduleCommand,
|
||||
next_run_at: Option<DateTime<Utc>>,
|
||||
context: &MutationContext,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> AdminStoreResult<BackupSettings>;
|
||||
|
||||
/// CAS 记录探针成功;`storage_revision` 与当前配置不一致时丢弃结果(返回 `false`)。
|
||||
/// CAS 记录探针成功;`storage_revision` 与当前配置不一致时丢弃结果(返回 `false`)
|
||||
async fn record_verification(
|
||||
&self,
|
||||
storage_revision: u64,
|
||||
at: DateTime<Utc>,
|
||||
) -> AdminStoreResult<bool>;
|
||||
|
||||
/// 插入 queued 任务;存在活跃任务时返回 `backup.active_task_conflict`。
|
||||
/// 插入 queued 任务;存在活跃任务时返回 `backup.active_task_conflict`
|
||||
async fn insert_backup_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<BackupRecord>;
|
||||
|
||||
/// 幂等插入 scheduled 任务;同一时间点或活跃冲突时返回 `false`(调用方推进游标即可)。
|
||||
async fn insert_scheduled_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<bool>;
|
||||
/// 核对启停、Cron、时区及旧游标后,同事务推进游标并插入 scheduled 任务
|
||||
/// 配置已变化时不写入;同一时间点或活跃冲突时只推进游标,返回 `false`
|
||||
async fn insert_scheduled_record(
|
||||
&self,
|
||||
seed: BackupRecordSeed,
|
||||
next_run_at: Option<DateTime<Utc>>,
|
||||
expected_cron: &str,
|
||||
expected_timezone: &str,
|
||||
expected_next_run_at: Option<DateTime<Utc>>,
|
||||
) -> AdminStoreResult<bool>;
|
||||
|
||||
/// 分页查询记录。
|
||||
/// 分页查询记录
|
||||
async fn list_backup_records(
|
||||
&self,
|
||||
query: BackupRecordListQuery,
|
||||
) -> AdminStoreResult<BackupRecordPage>;
|
||||
|
||||
/// 读取单条记录。
|
||||
/// 读取单条记录
|
||||
async fn load_backup_record(&self, id: &str) -> AdminStoreResult<Option<BackupRecord>>;
|
||||
|
||||
/// 读取中间状态记录(dumping/uploading),供启动恢复。
|
||||
/// 读取中间状态记录(dumping/uploading),供启动恢复
|
||||
async fn list_intermediate_records(&self) -> AdminStoreResult<Vec<BackupRecord>>;
|
||||
|
||||
/// 读取等待完成墓碑的记录(status = deleting)。
|
||||
/// 读取等待完成墓碑的记录(status = deleting)
|
||||
async fn list_pending_deletions(&self, limit: u32) -> AdminStoreResult<Vec<BackupRecord>>;
|
||||
|
||||
/// 读取 `expires_at` 已到期且非活跃的记录(completed/failed),供保留清理。
|
||||
/// 读取 `expires_at` 已到期且非活跃的记录(completed/failed),供保留清理
|
||||
async fn list_expired_records(&self, limit: u32) -> AdminStoreResult<Vec<BackupRecord>>;
|
||||
|
||||
/// 原子领取最旧的 queued 任务并迁移到 dumping;无任务返回 `None`。
|
||||
/// 原子领取最旧的 queued 任务并迁移到 dumping;无任务返回 `None`
|
||||
async fn claim_next_queued(&self, now: DateTime<Utc>)
|
||||
-> AdminStoreResult<Option<BackupRecord>>;
|
||||
|
||||
/// 条件迁移状态:要求当前状态一致;不匹配返回 `None`。
|
||||
/// 条件迁移状态:要求当前状态一致;不匹配返回 `None`
|
||||
async fn transition_status(
|
||||
&self,
|
||||
id: &str,
|
||||
@@ -101,53 +111,56 @@ pub trait BackupRepository: Send + Sync {
|
||||
now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>>;
|
||||
|
||||
/// 迁移到删除流程(completed/failed → deleting);供管理员与保留策略调用。
|
||||
/// 迁移到删除流程(completed/failed → deleting);供管理员与保留策略调用
|
||||
async fn transition_to_deleting(
|
||||
&self,
|
||||
id: &str,
|
||||
now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>>;
|
||||
|
||||
/// 远端删除成功后硬删除记录行。
|
||||
/// 远端删除成功后硬删除记录行
|
||||
async fn delete_record(&self, id: &str) -> AdminStoreResult<()>;
|
||||
|
||||
/// 条件推进计划游标;仅当计划表达式和时区仍是 `expected_*` 时才写入,返回是否推进。
|
||||
/// 条件推进计划游标;仅当启用状态、计划表达式、时区及旧游标仍匹配时才写入,返回是否推进
|
||||
async fn advance_schedule_cursor(
|
||||
&self,
|
||||
next_run_at: DateTime<Utc>,
|
||||
expected_cron: &str,
|
||||
expected_timezone: &str,
|
||||
expected_timezone: Option<&str>,
|
||||
expected_next_run_at: Option<DateTime<Utc>>,
|
||||
timezone: &str,
|
||||
) -> AdminStoreResult<bool>;
|
||||
|
||||
/// 按完成时间倒序读取 completed 计划备份,供保留策略扫描(最多 `limit` 条)。
|
||||
/// 按完成时间倒序读取 completed 计划备份,供保留策略扫描(最多 `limit` 条)
|
||||
async fn list_scheduled_completed_desc(
|
||||
&self,
|
||||
limit: u32,
|
||||
) -> AdminStoreResult<Vec<BackupRecord>>;
|
||||
}
|
||||
|
||||
/// `pg_dump` 导出与本地暂存端口。
|
||||
/// `pg_dump` 导出与本地暂存端口
|
||||
#[async_trait]
|
||||
pub trait DatabaseDumpPort: Send + Sync {
|
||||
/// 创建归档;完成后原子落盘。取消时终止子进程并清理部分文件。
|
||||
/// 创建归档;完成后原子落盘
|
||||
/// 取消时终止子进程并清理部分文件
|
||||
async fn dump(&self, request: DumpRequest) -> Result<DumpArtifact, BackupError>;
|
||||
|
||||
/// 检查暂存目录是否存在完整归档。
|
||||
/// 检查暂存目录是否存在完整归档
|
||||
async fn inspect_staging(&self, backup_id: &str)
|
||||
-> Result<Option<StagedArtifact>, BackupError>;
|
||||
|
||||
/// 清理暂存目录中该备份的全部文件。
|
||||
/// 清理暂存目录中该备份的全部文件
|
||||
async fn cleanup_staging(&self, backup_id: &str) -> Result<(), BackupError>;
|
||||
}
|
||||
|
||||
/// 一次 `pg_dump` 请求。
|
||||
/// 一次 `pg_dump` 请求
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DumpRequest {
|
||||
pub backup_id: String,
|
||||
pub cancellation: gateway_core::lifecycle::CancellationToken,
|
||||
}
|
||||
|
||||
/// 已完成并落盘的归档事实。
|
||||
/// 已完成并落盘的归档事实
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DumpArtifact {
|
||||
pub path: PathBuf,
|
||||
@@ -155,7 +168,7 @@ pub struct DumpArtifact {
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
/// 暂存目录中已存在的完整归档。
|
||||
/// 暂存目录中已存在的完整归档
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StagedArtifact {
|
||||
pub path: PathBuf,
|
||||
@@ -163,39 +176,39 @@ pub struct StagedArtifact {
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
/// S3 兼容对象存储适配器端口。
|
||||
/// S3 兼容对象存储适配器端口
|
||||
///
|
||||
/// 方法接受已经校验的存储配置;SDK client 的构造与缓存属于 adapter 实现细节。
|
||||
/// 方法接受已经校验的存储配置;SDK client 的构造与缓存属于 adapter 实现细节
|
||||
#[async_trait]
|
||||
pub trait BackupObjectStorePort: Send + Sync {
|
||||
/// 对专用探针对象执行 Put/Head/Get/Delete。
|
||||
/// 对专用探针对象执行 Put/Head/Get/Delete
|
||||
async fn test_connection(
|
||||
&self,
|
||||
config: &BackupStorageConfig,
|
||||
) -> Result<ConnectionTestResult, BackupError>;
|
||||
|
||||
/// 分片流式上传本地归档;取消时中止 multipart 并返回 `backup.cancelled`。
|
||||
/// 分片流式上传本地归档;取消时中止 multipart 并返回 `backup.cancelled`
|
||||
async fn upload_file(
|
||||
&self,
|
||||
config: &BackupStorageConfig,
|
||||
request: UploadObjectRequest,
|
||||
) -> Result<(), BackupError>;
|
||||
|
||||
/// 读取对象大小与 metadata;对象不存在返回 `Ok(None)`。
|
||||
/// 读取对象大小与 metadata;对象不存在返回 `Ok(None)`
|
||||
async fn head_object(
|
||||
&self,
|
||||
config: &BackupStorageConfig,
|
||||
object_key: &str,
|
||||
) -> Result<Option<BackupObjectMetadata>, BackupError>;
|
||||
|
||||
/// 删除对象;对象不存在视为成功。
|
||||
/// 删除对象;对象不存在视为成功
|
||||
async fn delete_object(
|
||||
&self,
|
||||
config: &BackupStorageConfig,
|
||||
object_key: &str,
|
||||
) -> Result<(), BackupError>;
|
||||
|
||||
/// 创建短时预签名下载 URL。
|
||||
/// 创建短时预签名下载 URL
|
||||
async fn presigned_download(
|
||||
&self,
|
||||
config: &BackupStorageConfig,
|
||||
@@ -205,7 +218,7 @@ pub trait BackupObjectStorePort: Send + Sync {
|
||||
) -> Result<String, BackupError>;
|
||||
}
|
||||
|
||||
/// 一次对象上传请求。
|
||||
/// 一次对象上传请求
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UploadObjectRequest {
|
||||
pub object_key: String,
|
||||
@@ -214,7 +227,7 @@ pub struct UploadObjectRequest {
|
||||
pub cancellation: gateway_core::lifecycle::CancellationToken,
|
||||
}
|
||||
|
||||
/// 备份控制面所需的全部 Store 能力集合。
|
||||
/// 备份控制面所需的全部 Store 能力集合
|
||||
#[derive(Clone)]
|
||||
pub struct BackupStorePorts {
|
||||
repository: Arc<dyn BackupRepository>,
|
||||
@@ -223,7 +236,7 @@ pub struct BackupStorePorts {
|
||||
}
|
||||
|
||||
impl BackupStorePorts {
|
||||
/// 组合备份端口。
|
||||
/// 组合备份端口
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repository: Arc<dyn BackupRepository>,
|
||||
@@ -237,25 +250,25 @@ impl BackupStorePorts {
|
||||
}
|
||||
}
|
||||
|
||||
/// 备份任务/配置仓储。
|
||||
/// 备份任务/配置仓储
|
||||
#[must_use]
|
||||
pub fn repository(&self) -> Arc<dyn BackupRepository> {
|
||||
self.repository.clone()
|
||||
}
|
||||
|
||||
/// `pg_dump` 导出与暂存。
|
||||
/// `pg_dump` 导出与暂存
|
||||
#[must_use]
|
||||
pub fn dump(&self) -> Arc<dyn DatabaseDumpPort> {
|
||||
self.dump.clone()
|
||||
}
|
||||
|
||||
/// S3/R2 对象存储适配器。
|
||||
/// S3/R2 对象存储适配器
|
||||
#[must_use]
|
||||
pub fn object_store(&self) -> Arc<dyn BackupObjectStorePort> {
|
||||
self.object_store.clone()
|
||||
}
|
||||
|
||||
/// 测试与不可用路径使用的空实现;任何调用都返回不可用错误。
|
||||
/// 测试与不可用路径使用的空实现;任何调用都返回不可用错误
|
||||
#[doc(hidden)]
|
||||
#[must_use]
|
||||
pub fn disabled() -> Self {
|
||||
@@ -286,6 +299,7 @@ impl BackupRepository for UnavailableBackupRepository {
|
||||
_command: UpdateBackupScheduleCommand,
|
||||
_next_run_at: Option<DateTime<Utc>>,
|
||||
_context: &MutationContext,
|
||||
_timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> AdminStoreResult<BackupSettings> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
@@ -302,7 +316,14 @@ impl BackupRepository for UnavailableBackupRepository {
|
||||
) -> AdminStoreResult<BackupRecord> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn insert_scheduled_record(&self, _seed: BackupRecordSeed) -> AdminStoreResult<bool> {
|
||||
async fn insert_scheduled_record(
|
||||
&self,
|
||||
_seed: BackupRecordSeed,
|
||||
_next_run_at: Option<DateTime<Utc>>,
|
||||
_expected_cron: &str,
|
||||
_expected_timezone: &str,
|
||||
_expected_next_run_at: Option<DateTime<Utc>>,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn list_backup_records(
|
||||
@@ -352,7 +373,9 @@ impl BackupRepository for UnavailableBackupRepository {
|
||||
&self,
|
||||
_next_run_at: DateTime<Utc>,
|
||||
_expected_cron: &str,
|
||||
_expected_timezone: &str,
|
||||
_expected_timezone: Option<&str>,
|
||||
_expected_next_run_at: Option<DateTime<Utc>>,
|
||||
_timezone: &str,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 客户端安装包解析的外部能力端口。
|
||||
//! 客户端安装包解析的外部能力端口
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 管理用例依赖的外部能力端口。
|
||||
//! 管理用例依赖的外部能力端口
|
||||
|
||||
pub mod backup;
|
||||
pub mod client_distribution;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 插件宿主访问账号权威所需的窄端口。
|
||||
//! 插件宿主访问账号权威所需的窄端口
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::account::ProviderAccountId;
|
||||
@@ -11,7 +11,7 @@ use crate::model::{
|
||||
},
|
||||
};
|
||||
|
||||
/// Runtime 只取得账号读写用例,不接触 Store、SQL 或完整管理服务集合。
|
||||
/// Runtime 只取得账号读写用例,不接触 Store、SQL 或完整管理服务集合
|
||||
#[async_trait]
|
||||
pub trait PluginAccountAccess: Send + Sync {
|
||||
async fn list(&self, query: PluginAccountListQuery) -> Result<PluginAccountPage, AdminError>;
|
||||
@@ -26,7 +26,7 @@ pub trait PluginAccountAccess: Send + Sync {
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<PluginAccountCredential, AdminError>;
|
||||
|
||||
/// 只读 Provider 已有额度观测;实现不得主动刷新上游或附加用量预测。
|
||||
/// 只读 Provider 已有额度观测;实现不得主动刷新上游或附加用量预测
|
||||
async fn get_quota(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
@@ -34,7 +34,7 @@ pub trait PluginAccountAccess: Send + Sync {
|
||||
Err(AdminError::unavailable("插件额度事实查询暂不可用"))
|
||||
}
|
||||
|
||||
/// 通过 Provider 原生管理路径刷新额度;不向插件暴露凭据或执行额度重置。
|
||||
/// 通过 Provider 原生管理路径刷新额度;不向插件暴露凭据或执行额度重置
|
||||
async fn refresh_quota(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 插件 Client Key 非秘密目录与受控预算管理端口。
|
||||
//! 插件 Client Key 非秘密目录与受控预算管理端口
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::{engine::budget::ClientBudgetStatus, policy::ClientApiKeyId};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! 插件管理页面、资源访问与授权回调的运行时端口
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::runtime::extensions::ExtensionSetReference;
|
||||
use gateway_core::routing::extensions::ExtensionSetReference;
|
||||
|
||||
use crate::model::{
|
||||
AdminError,
|
||||
@@ -9,10 +11,10 @@ use crate::model::{
|
||||
},
|
||||
};
|
||||
|
||||
/// 仅按 Admin 固定的发布引用分派;Runtime 不自行选择当前代次,也不拥有管理员身份。
|
||||
/// 仅按 Admin 固定的发布引用分派;Runtime 不自行选择当前代次,也不拥有管理员身份
|
||||
#[async_trait]
|
||||
pub trait PluginManagement: Send + Sync {
|
||||
/// 复核固定发布代次中的可用目标;调用方已校验持久实例仍为当前版本。
|
||||
/// 复核固定发布代次中的可用目标;调用方已校验持久实例仍为当前版本
|
||||
async fn validate_target(
|
||||
&self,
|
||||
published: &ExtensionSetReference,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! 官方插件发行清单与制品文件的读取端口
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
@@ -9,7 +11,7 @@ pub enum OfficialPluginReleaseReadErrorKind {
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// 文件系统错误不携带部署路径,避免把宿主布局传播到管理域和日志之外。
|
||||
/// 文件系统错误不携带部署路径,避免把宿主布局传播到管理域和日志之外
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("official plugin release files are unavailable")]
|
||||
pub struct OfficialPluginReleaseReadError {
|
||||
@@ -28,7 +30,7 @@ impl OfficialPluginReleaseReadError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Host 只暴露与宿主发行物同信任边界的只读文件,不解释官方身份或插件包。
|
||||
/// Host 只暴露与宿主发行物同信任边界的只读文件,不解释官方身份或插件包
|
||||
#[async_trait]
|
||||
pub trait OfficialPluginReleaseFiles: Send + Sync {
|
||||
async fn manifest(&self) -> Result<Option<Arc<[u8]>>, OfficialPluginReleaseReadError>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 插件自有资源的管理入口与原子持久化端口。
|
||||
//! 插件自有资源的管理入口与原子持久化端口
|
||||
|
||||
use super::store::AdminStoreResult;
|
||||
use crate::model::{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! 插件准备、分发、持久化与私有状态生命周期的外部能力端口
|
||||
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
@@ -24,12 +26,12 @@ use crate::model::{
|
||||
PluginCompatibilityRequirements, PluginSource,
|
||||
},
|
||||
};
|
||||
use gateway_core::runtime::extensions::ExtensionSetReference;
|
||||
use gateway_core::routing::extensions::ExtensionSetReference;
|
||||
|
||||
/// Admin 准备完整候选并保活到提交后的发布结束;进程状态不能代替持久启用状态。
|
||||
/// Admin 准备完整候选并保活到提交后的发布结束;进程状态不能代替持久启用状态
|
||||
#[async_trait]
|
||||
pub trait PluginPreparation: PluginRuntimeDiagnostics + PluginStateLifecycle {
|
||||
/// 静态判断业务配置是否完整,不启动进程;不完整的安装保留为待配置。
|
||||
/// 静态判断业务配置是否完整,不启动进程;不完整的安装保留为待配置
|
||||
async fn configuration_ready(
|
||||
&self,
|
||||
instance: PluginInstance,
|
||||
@@ -39,14 +41,14 @@ pub trait PluginPreparation: PluginRuntimeDiagnostics + PluginStateLifecycle {
|
||||
&self,
|
||||
instance: PluginInstance,
|
||||
) -> Result<PluginStateConfiguration, AdminError>;
|
||||
/// 本次修改的实例 revision 必须等于候选 config_revision,准备失败时拒绝提交;历史故障可隔离。
|
||||
/// 本次修改的实例 revision 必须等于候选 config_revision,准备失败时拒绝提交;历史故障可隔离
|
||||
async fn prepare(
|
||||
&self,
|
||||
snapshot: PluginInstanceSnapshot,
|
||||
) -> Result<ExtensionSetReference, AdminError>;
|
||||
}
|
||||
|
||||
/// 运行诊断独立于发布准备;没有进程事实时显式返回 None。
|
||||
/// 运行诊断独立于发布准备;没有进程事实时显式返回 None
|
||||
#[async_trait]
|
||||
pub trait PluginRuntimeDiagnostics: Send + Sync {
|
||||
async fn runtime_diagnostics(
|
||||
@@ -57,7 +59,7 @@ pub trait PluginRuntimeDiagnostics: Send + Sync {
|
||||
) -> Option<BTreeMap<String, PluginInstanceRuntime>>;
|
||||
}
|
||||
|
||||
/// 发布实现必须显式承担状态激活、排空和迁移,不能以缺省成功跳过生命周期。
|
||||
/// 发布实现必须显式承担状态激活、排空和迁移,不能以缺省成功跳过生命周期
|
||||
#[async_trait]
|
||||
pub trait PluginStateLifecycle: Send + Sync {
|
||||
async fn activate_state(
|
||||
@@ -103,7 +105,7 @@ impl PluginStateStoreError {
|
||||
|
||||
pub type PluginStateStoreResult<T> = Result<T, PluginStateStoreError>;
|
||||
|
||||
/// 私有状态的窄端口;不暴露 SQL、连接或跨命名空间事务。
|
||||
/// 私有状态的窄端口;不暴露 SQL、连接或跨命名空间事务
|
||||
#[async_trait]
|
||||
pub trait PluginStateStore: Send + Sync {
|
||||
async fn load_owner(
|
||||
@@ -151,7 +153,7 @@ pub trait PluginStateStore: Send + Sync {
|
||||
async fn abort_transition(&self, transition_id: &str) -> PluginStateStoreResult<()>;
|
||||
}
|
||||
|
||||
/// Host 拥有联网、重定向、凭据作用域、限流与有界下载,不解释插件包。
|
||||
/// Host 拥有联网、重定向、凭据作用域、限流与有界下载,不解释插件包
|
||||
#[async_trait]
|
||||
pub trait PluginDistribution: Send + Sync {
|
||||
fn validate_source(&self, source: &PluginUpdateSource) -> Result<(), AdminError>;
|
||||
@@ -170,16 +172,34 @@ pub trait PluginDistribution: Send + Sync {
|
||||
) -> Result<DownloadedPlugin, AdminError>;
|
||||
}
|
||||
|
||||
/// Runtime 只解释插件包格式;安装事务与来源选择归 Admin。
|
||||
/// Runtime 只解释插件包格式;安装事务与来源选择归 Admin
|
||||
#[async_trait]
|
||||
pub trait PluginPackageInspector: Send + Sync {
|
||||
/// 完整校验包并返回版本范围诊断;校验失败与可加载但未经宿主承诺的提醒分开
|
||||
async fn compatibility_warning(
|
||||
&self,
|
||||
archive: Arc<[u8]>,
|
||||
expected_sha256: String,
|
||||
) -> Result<Option<String>, AdminError> {
|
||||
self.inspect(archive, Some(expected_sha256)).await?;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// 按当前宿主的弃用计划提示旧接口,不改变安装和运行资格
|
||||
fn api_deprecations(
|
||||
&self,
|
||||
_metadata: &crate::model::plugins::PluginArtifactMetadata,
|
||||
) -> Result<Vec<crate::model::plugins::instances::PluginApiDeprecation>, AdminError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn inspect(
|
||||
&self,
|
||||
archive: Arc<[u8]>,
|
||||
expected_sha256: Option<String>,
|
||||
) -> Result<InspectedPluginArtifact, AdminError>;
|
||||
|
||||
/// 从已安装且摘要匹配的制品中读取可展示的静态图标,不启动插件实例。
|
||||
/// 从已安装且摘要匹配的制品中读取可展示的静态图标,不启动插件实例
|
||||
async fn icon(
|
||||
&self,
|
||||
_archive: Arc<[u8]>,
|
||||
@@ -189,7 +209,7 @@ pub trait PluginPackageInspector: Send + Sync {
|
||||
Err(AdminError::unavailable("插件图标读取不可用"))
|
||||
}
|
||||
|
||||
/// 只读取已校验包的静态宿主要求;不得准备或启动插件进程。
|
||||
/// 只读取已校验包的静态宿主要求;不得准备或启动插件进程
|
||||
async fn compatibility(
|
||||
&self,
|
||||
_archive: Arc<[u8]>,
|
||||
@@ -201,13 +221,13 @@ pub trait PluginPackageInspector: Send + Sync {
|
||||
|
||||
#[async_trait]
|
||||
pub trait PluginStore: Send + Sync {
|
||||
/// 只复核精确管理目标的启用、接受事实和版本,不读取实例配置或密钥。
|
||||
/// 只复核精确管理目标的启用、接受事实和版本,不读取实例配置或密钥
|
||||
async fn management_target_is_current(
|
||||
&self,
|
||||
target: &crate::model::plugins::management::PluginManagementTarget,
|
||||
) -> AdminStoreResult<bool>;
|
||||
async fn load_instances(&self) -> AdminStoreResult<PluginInstanceSnapshot>;
|
||||
/// 读取对应制品最近一次启用时提交的配置,密钥始终留在服务端。
|
||||
/// 读取对应制品最近一次启用时提交的配置,密钥始终留在服务端
|
||||
async fn load_version_configuration(
|
||||
&self,
|
||||
_id: &str,
|
||||
@@ -215,11 +235,11 @@ pub trait PluginStore: Send + Sync {
|
||||
) -> AdminStoreResult<Option<PluginVersionConfiguration>> {
|
||||
Ok(None)
|
||||
}
|
||||
/// 只返回有可恢复配置的制品摘要,不读取敏感值。
|
||||
/// 只返回有可恢复配置的制品摘要,不读取敏感值
|
||||
async fn configuration_versions(&self, _id: &str) -> AdminStoreResult<Vec<String>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
/// 在单个事务中停用确认快照中的实例,保留配置、密钥及私有状态。
|
||||
/// 在单个事务中停用确认快照中的实例,保留配置、密钥及私有状态
|
||||
async fn disable_instances(
|
||||
&self,
|
||||
_ids: &[String],
|
||||
@@ -255,7 +275,7 @@ pub trait PluginStore: Send + Sync {
|
||||
self.save_instance(instance, expected_revision, context)
|
||||
.await
|
||||
}
|
||||
/// 保存目标并停用明确确认的配置,必须共享事务和配置版本检查。
|
||||
/// 保存目标并停用明确确认的配置,必须共享事务和配置版本检查
|
||||
async fn save_instance_replacing(
|
||||
&self,
|
||||
instance: PluginInstance,
|
||||
@@ -307,13 +327,13 @@ pub trait PluginStore: Send + Sync {
|
||||
source: PluginSource,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<PluginArtifactMutation>;
|
||||
/// 确认信任并安装精确摘要的制品;安装事实不随实例设置变化。
|
||||
/// 确认信任并安装精确摘要的制品;安装事实不随实例设置变化
|
||||
async fn accept_artifact(
|
||||
&self,
|
||||
digest: &str,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<PluginArtifactMutation>;
|
||||
/// 删除制品及其无引用下载凭据,最后一个版本同时删除来源规则,清理与审计必须原子提交。
|
||||
/// 删除制品及其无引用下载凭据,最后一个版本同时删除来源规则,清理与审计必须原子提交
|
||||
async fn delete_artifact(
|
||||
&self,
|
||||
digest: &str,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 手动同步的外部价目来源;网络和外部文档解析由 Host 实现。
|
||||
//! 手动同步的外部价目来源;网络和外部文档解析由 Host 实现
|
||||
|
||||
use crate::model::{AdminError, pricing::PricingSyncPreview};
|
||||
use async_trait::async_trait;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 原生 Provider 管理能力与启动时注册表。
|
||||
//! 原生 Provider 管理能力与启动时注册表
|
||||
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
@@ -26,14 +26,14 @@ use crate::model::{
|
||||
quota_forecast_sampling::QuotaForecastObservation,
|
||||
};
|
||||
|
||||
/// Provider 管理失败的稳定分类。
|
||||
/// Provider 管理失败的稳定分类
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderAdminErrorKind {
|
||||
Invalid,
|
||||
Unsupported,
|
||||
NotFound,
|
||||
Conflict,
|
||||
/// Provider 已发起不可逆操作,但无法确认最终执行结果。
|
||||
/// Provider 已发起不可逆操作,但无法确认最终执行结果
|
||||
Ambiguous,
|
||||
Unavailable,
|
||||
CredentialRefreshRequired,
|
||||
@@ -41,16 +41,17 @@ pub enum ProviderAdminErrorKind {
|
||||
Internal,
|
||||
}
|
||||
|
||||
/// 不携带 OAuth 请求材料的管理错误。
|
||||
/// 不携带 OAuth 请求材料的管理错误
|
||||
///
|
||||
/// `message` 是 Provider 局部诊断,可能包含原始上游正文;通用管理用例不得自动把它作为公开文案。
|
||||
/// 只有明确拥有原始诊断合同的调用方才能读取,`Debug` 始终只记录是否存在。
|
||||
/// `public_message` 则是明确标记为可公开的静态提示,不允许携带动态上游材料。
|
||||
#[derive(Clone, PartialEq, Eq, thiserror::Error)]
|
||||
/// `message` 是 Provider 局部诊断,可能包含原始上游正文;通用管理用例不得自动把它作为公开文案
|
||||
/// 只有明确拥有原始诊断合同的调用方才能读取,`Debug` 始终只记录是否存在
|
||||
/// `public_message` 则是明确标记为可公开的静态提示,不允许携带动态上游材料
|
||||
#[derive(Clone, thiserror::Error)]
|
||||
#[error("provider admin operation failed: {kind:?}")]
|
||||
pub struct ProviderAdminError {
|
||||
kind: ProviderAdminErrorKind,
|
||||
message: Option<String>,
|
||||
#[source]
|
||||
diagnostic: Option<Arc<ProviderAdminDiagnostic>>,
|
||||
public_message: Option<&'static str>,
|
||||
}
|
||||
|
||||
@@ -59,7 +60,7 @@ impl std::fmt::Debug for ProviderAdminError {
|
||||
formatter
|
||||
.debug_struct("ProviderAdminError")
|
||||
.field("kind", &self.kind)
|
||||
.field("message", &self.message.as_ref().map(|_| "<redacted>"))
|
||||
.field("message", &self.message().map(|_| "<redacted>"))
|
||||
.field("public_message", &self.public_message)
|
||||
.finish()
|
||||
}
|
||||
@@ -70,18 +71,26 @@ impl ProviderAdminError {
|
||||
pub const fn new(kind: ProviderAdminErrorKind) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: None,
|
||||
diagnostic: None,
|
||||
public_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_message(mut self, message: impl Into<String>) -> Self {
|
||||
self.message = Some(message.into());
|
||||
pub fn with_source(mut self, source: impl Into<gateway_core::error::ErrorSource>) -> Self {
|
||||
Arc::make_mut(self.diagnostic.get_or_insert_with(Default::default)).source =
|
||||
Some(source.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Provider 明确允许公开的静态提示;不得承载上游响应或凭据材料。
|
||||
#[must_use]
|
||||
pub fn with_message(mut self, message: impl Into<String>) -> Self {
|
||||
Arc::make_mut(self.diagnostic.get_or_insert_with(Default::default)).message =
|
||||
Some(message.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Provider 明确允许公开的静态提示;不得承载上游响应或凭据材料
|
||||
#[must_use]
|
||||
pub const fn with_public_message(mut self, message: &'static str) -> Self {
|
||||
self.public_message = Some(message);
|
||||
@@ -100,24 +109,61 @@ impl ProviderAdminError {
|
||||
|
||||
#[must_use]
|
||||
pub fn message(&self) -> Option<&str> {
|
||||
self.message.as_deref()
|
||||
self.diagnostic
|
||||
.as_ref()
|
||||
.and_then(|diagnostic| diagnostic.message.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个具体 Provider 对管理控制面提供的解析、验证、上游交互与运行时资源回收能力。
|
||||
// Provider 局部消息与 typed 来源共用一个不公开的上下文,避免跨用例转换后丢失
|
||||
#[derive(Debug, Clone, Default, thiserror::Error)]
|
||||
#[error("{}", message.as_deref().unwrap_or("provider admin dependency failed"))]
|
||||
struct ProviderAdminDiagnostic {
|
||||
message: Option<String>,
|
||||
source: Option<gateway_core::error::ErrorSource>,
|
||||
}
|
||||
|
||||
/// 一个具体 Provider 对管理控制面提供的解析、验证、上游交互与运行时资源回收能力
|
||||
///
|
||||
/// 数据变更由 Provider 返回 prepared facts;config revision、审计与 PostgreSQL 事务
|
||||
/// 全部由 [`crate::ports::store::AccountStore`] 提交。运行时资源通知只在事务成功后发生。
|
||||
/// 全部由 [`crate::ports::store::AccountStore`] 提交
|
||||
/// 运行时资源通知只在事务成功后发生
|
||||
#[async_trait]
|
||||
pub trait ProviderAdmin: Send + Sync {
|
||||
/// 只读内置价目;没有本地计价能力的 Provider 返回空目录。
|
||||
fn compile_privacy_policy(
|
||||
&self,
|
||||
_policy: &gateway_core::settings::privacy::CodexPrivacyPolicy,
|
||||
) -> Result<
|
||||
Arc<dyn gateway_core::settings::privacy::CompiledPrivacyPolicy>,
|
||||
gateway_core::settings::privacy::PrivacyError,
|
||||
> {
|
||||
Err(gateway_core::settings::privacy::PrivacyError {
|
||||
rule_index: 0,
|
||||
reason: "Provider 不支持隐私策略",
|
||||
})
|
||||
}
|
||||
|
||||
fn preview_privacy_policy(
|
||||
&self,
|
||||
_request: gateway_core::settings::privacy::PrivacyPreviewRequest,
|
||||
) -> Result<
|
||||
gateway_core::settings::privacy::PrivacyPreviewResult,
|
||||
gateway_core::settings::privacy::PrivacyError,
|
||||
> {
|
||||
Err(gateway_core::settings::privacy::PrivacyError {
|
||||
rule_index: 0,
|
||||
reason: "Provider 不支持隐私策略",
|
||||
})
|
||||
}
|
||||
|
||||
/// 只读内置价目;没有本地计价能力的 Provider 返回空目录
|
||||
fn pricing_catalog(&self) -> crate::model::pricing::ProviderPricingCatalog {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
fn provider_kind(&self) -> &ProviderKind;
|
||||
|
||||
/// 只读取原生 Provider 能力及账号类型,不执行网络或数据库查询。
|
||||
/// 只读取原生 Provider 能力及账号类型,不执行网络或数据库查询
|
||||
fn account_capabilities(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
@@ -126,19 +172,19 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
/// 提供该 Provider 的可选客户端身份;通用管理层不解释内部字段。
|
||||
/// 提供该 Provider 的可选客户端身份;通用管理层不解释内部字段
|
||||
fn client_profile_options(
|
||||
&self,
|
||||
) -> Result<gateway_core::account::OpaqueProviderData, ProviderAdminError> {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// 没有持久选择时使用的 Provider 默认画像;只返回已准备的本地事实。
|
||||
/// 没有持久选择时使用的 Provider 默认画像;只返回已准备的本地事实
|
||||
fn default_client_profile(&self) -> Option<gateway_core::account::OpaqueProviderData> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 校验并投影客户端身份,结果不含认证或账号材料。
|
||||
/// 校验并投影客户端身份,结果不含认证或账号材料
|
||||
fn preview_client_profile(
|
||||
&self,
|
||||
_configuration: &gateway_core::account::OpaqueProviderData,
|
||||
@@ -146,31 +192,33 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// 将原始套餐值投影为展示名称;默认保留未知 Provider 的原始名称。
|
||||
/// 提供最终套餐展示名称;未覆盖时采用通用的大驼峰格式
|
||||
fn plan_type_display(&self, plan_type: &str) -> String {
|
||||
plan_type.to_owned()
|
||||
default_plan_type_display(plan_type)
|
||||
}
|
||||
|
||||
/// 账号已经由控制面提交为不可调度状态,释放 Provider 持有的账号级运行时资源。
|
||||
/// 账号已经由控制面提交为不可调度状态,释放 Provider 持有的账号级运行时资源
|
||||
///
|
||||
/// 无账号级运行时资源的 Provider 不需要执行额外操作。该通知发生在 Store 事务
|
||||
/// 成功之后,不参与事务成败,也不得恢复或改写已经提交的账号状态。
|
||||
/// 无账号级运行时资源的 Provider 不需要执行额外操作
|
||||
/// 该通知发生在 Store 事务
|
||||
/// 成功之后,不参与事务成败,也不得恢复或改写已经提交的账号状态
|
||||
async fn account_unavailable(&self, account_id: &ProviderAccountId);
|
||||
|
||||
/// 账号资格事实已经由控制面提交,失效 Provider 持有的可重建派生状态。
|
||||
/// 账号资格事实已经由控制面提交,失效 Provider 持有的可重建派生状态
|
||||
///
|
||||
/// 该通知发生在 Store 事务成功之后、下一份 RuntimeSnapshot 编译之前;通知
|
||||
/// 不参与已提交事务成败。没有账号派生状态的 Provider 可使用默认空实现。
|
||||
/// 不参与已提交事务成败
|
||||
/// 没有账号派生状态的 Provider 可使用默认空实现
|
||||
async fn account_facts_changed(&self, _account_ids: &[ProviderAccountId]) {}
|
||||
|
||||
/// 生成一次连接测试所需的 Provider-owned operation;Core 负责实际执行与落账。
|
||||
/// 生成一次连接测试所需的 Provider-owned operation;Core 负责实际执行与落账
|
||||
async fn connection_test_operation(
|
||||
&self,
|
||||
upstream_model: &UpstreamModelId,
|
||||
input_text: &str,
|
||||
) -> Result<Operation, ProviderAdminError>;
|
||||
|
||||
/// 返回该 Provider 实际持有的 Dashboard 上游身份画像。
|
||||
/// 返回该 Provider 实际持有的 Dashboard 上游身份画像
|
||||
fn dashboard_wire_profile(&self) -> Option<DashboardWireProfile>;
|
||||
|
||||
fn configured_wire_profile(
|
||||
@@ -180,8 +228,8 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
self.dashboard_wire_profile()
|
||||
}
|
||||
|
||||
/// 使用 Provider-owned 价格规则恢复持久请求的逐项费用。
|
||||
/// 未保存的长上下文计费标记不得从当前价格反推。
|
||||
/// 使用 Provider-owned 价格规则恢复持久请求的逐项费用
|
||||
/// 未保存的长上下文计费标记不得从当前价格反推
|
||||
fn calculated_billing(
|
||||
&self,
|
||||
input: &ProviderBillingInput,
|
||||
@@ -212,7 +260,7 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
command: PrepareCredentialRefresh,
|
||||
) -> Result<PreparedCredentialRotation, ProviderAdminError>;
|
||||
|
||||
/// 可公开的账号连接设置;实现只能显式投影非敏感字段,不能返回凭据原文。
|
||||
/// 可公开的账号连接设置;实现只能显式投影非敏感字段,不能返回凭据原文
|
||||
async fn account_configuration(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
@@ -225,7 +273,7 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
request: ProviderQuotaRequest,
|
||||
) -> Result<ProviderQuota, ProviderAdminError>;
|
||||
|
||||
/// 历史观测的协议字段仅由具体 Provider 解释;不支持时保留累计估算。
|
||||
/// 历史观测的协议字段仅由具体 Provider 解释;不支持时保留累计估算
|
||||
fn quota_forecast_observation(
|
||||
&self,
|
||||
_document: &ProviderDocument,
|
||||
@@ -234,8 +282,8 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
None
|
||||
}
|
||||
|
||||
/// 查询 Provider 官方个人资料统计;不支持该能力的 Provider 使用默认拒绝。
|
||||
/// 只由个人信息显式查询,不在额度刷新或后台任务中预取。
|
||||
/// 查询 Provider 官方个人资料统计;不支持该能力的 Provider 使用默认拒绝
|
||||
/// 只由个人信息显式查询,不在额度刷新或后台任务中预取
|
||||
async fn subscription(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
@@ -250,7 +298,7 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// 打开 Provider 官方头像字节流;不支持该能力的 Provider 使用默认拒绝。
|
||||
/// 打开 Provider 官方头像字节流;不支持该能力的 Provider 使用默认拒绝
|
||||
async fn profile_avatar(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
@@ -258,7 +306,7 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// 查询 Provider 主动额度重置卡;不支持该能力的 Provider 使用默认拒绝。
|
||||
/// 查询 Provider 主动额度重置卡;不支持该能力的 Provider 使用默认拒绝
|
||||
async fn reset_credits(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
@@ -266,7 +314,7 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// 消费 Provider 主动额度重置卡;不支持该能力的 Provider 使用默认拒绝。
|
||||
/// 消费 Provider 主动额度重置卡;不支持该能力的 Provider 使用默认拒绝
|
||||
async fn consume_reset_credit(
|
||||
&self,
|
||||
_command: ConsumeProviderResetCredit,
|
||||
@@ -280,7 +328,7 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
refresh: bool,
|
||||
) -> Result<ProviderModels, ProviderAdminError>;
|
||||
|
||||
/// 导出该账号的 Provider 原生模型目录正文;不提供原生目录的 Provider 使用默认拒绝。
|
||||
/// 导出该账号的 Provider 原生模型目录正文;不提供原生目录的 Provider 使用默认拒绝
|
||||
async fn model_catalog_document(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
@@ -294,7 +342,7 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
) -> Result<ProviderExport, ProviderAdminError>;
|
||||
}
|
||||
|
||||
/// 启动时注册原生 Provider,按 ProviderKind 查找管理能力。
|
||||
/// 启动时注册原生 Provider,按 ProviderKind 查找管理能力
|
||||
#[derive(Clone)]
|
||||
pub struct ProviderAdminRegistry {
|
||||
providers: Arc<BTreeMap<ProviderKind, Arc<dyn ProviderAdmin>>>,
|
||||
@@ -308,11 +356,11 @@ impl ProviderAdminRegistry {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 创建无重复 ProviderKind 的注册表。
|
||||
/// 创建无重复 ProviderKind 的注册表
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 重复注册同一 ProviderKind 时返回 Conflict。
|
||||
/// 重复注册同一 ProviderKind 时返回 Conflict
|
||||
pub fn new(
|
||||
providers: impl IntoIterator<Item = Arc<dyn ProviderAdmin>>,
|
||||
) -> Result<Self, ProviderAdminError> {
|
||||
@@ -338,7 +386,7 @@ impl ProviderAdminRegistry {
|
||||
.ok_or_else(|| ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// 账号目录与关联列表共用套餐补全和展示规则,已知账号套餐优先于额度快照。
|
||||
/// 账号目录与关联列表共用套餐补全和展示规则,已知账号套餐优先于额度快照
|
||||
pub(crate) fn resolve_account_plan(
|
||||
&self,
|
||||
provider_kind: &str,
|
||||
@@ -351,7 +399,7 @@ impl ProviderAdminRegistry {
|
||||
self.plan_type_display(provider_kind, plan_type.as_deref())
|
||||
}
|
||||
|
||||
/// 账号页和 Dashboard 共用的大驼峰套餐展示名称,不修改原始套餐值。
|
||||
/// 账号页和 Dashboard 共用 Provider 的最终展示名称,不改写官方拼写或原始套餐值
|
||||
pub(crate) fn plan_type_display(
|
||||
&self,
|
||||
provider_kind: &str,
|
||||
@@ -361,15 +409,13 @@ impl ProviderAdminRegistry {
|
||||
let provider = ProviderKind::new(provider_kind.to_owned())
|
||||
.ok()
|
||||
.and_then(|kind| self.require(&kind).ok());
|
||||
let display = provider.map_or_else(
|
||||
|| plan_type.to_owned(),
|
||||
Some(provider.map_or_else(
|
||||
|| default_plan_type_display(plan_type),
|
||||
|provider| provider.plan_type_display(plan_type),
|
||||
);
|
||||
// 所有 Provider 及未知套餐统一排版,保留 Plus 后缀的含义。
|
||||
Some(display.replace('+', " Plus ").to_upper_camel_case())
|
||||
))
|
||||
}
|
||||
|
||||
/// 返回所有已注册 Provider 的 Dashboard 上游身份画像。
|
||||
/// 返回所有已注册 Provider 的 Dashboard 上游身份画像
|
||||
pub fn dashboard_wire_profiles(
|
||||
&self,
|
||||
configurations: &std::collections::BTreeMap<
|
||||
@@ -386,7 +432,7 @@ impl ProviderAdminRegistry {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 按平台计算请求费用。
|
||||
/// 按平台计算请求费用
|
||||
pub fn calculated_billing(
|
||||
&self,
|
||||
provider_kind: &ProviderKind,
|
||||
@@ -395,3 +441,7 @@ impl ProviderAdminRegistry {
|
||||
self.require(provider_kind)?.calculated_billing(input)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_plan_type_display(plan_type: &str) -> String {
|
||||
plan_type.replace('+', " Plus ").to_upper_camel_case()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! 账号出站代理的持久化、导入占用与连通性探测端口
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::account::{OutboundProxy, ProviderAccountId};
|
||||
|
||||
@@ -12,7 +14,7 @@ use crate::model::{
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProxyStore: Send + Sync {
|
||||
/// 在凭据交换到提交期间保护选定代理的连接配置和测试结果。
|
||||
/// 在凭据交换到提交期间保护选定代理的连接配置和测试结果
|
||||
async fn reserve_import(&self, id: &str) -> AdminStoreResult<ProxyImportReservation>;
|
||||
async fn list(&self, query: ProxyListQuery) -> AdminStoreResult<ProxyPage>;
|
||||
async fn list_accounts(
|
||||
@@ -20,7 +22,7 @@ pub trait ProxyStore: Send + Sync {
|
||||
query: ProxyAccountListQuery,
|
||||
) -> AdminStoreResult<ProxyAccountPage>;
|
||||
async fn get(&self, id: &str) -> AdminStoreResult<ProxyRecord>;
|
||||
/// 仅在账号仍绑定指定代理时解除关联,并清除账号保存的连接地址。
|
||||
/// 仅在账号仍绑定指定代理时解除关联,并清除账号保存的连接地址
|
||||
async fn remove_account(
|
||||
&self,
|
||||
proxy_id: &str,
|
||||
@@ -52,7 +54,7 @@ pub trait ProxyStore: Send + Sync {
|
||||
) -> AdminStoreResult<ProxyMutation>;
|
||||
}
|
||||
|
||||
/// 离开作用域时释放保护,错误返回和请求取消也遵循相同规则。
|
||||
/// 离开作用域时释放保护,错误返回和请求取消也遵循相同规则
|
||||
pub trait ProxyImportGuard: Send + Sync {}
|
||||
|
||||
pub struct ProxyImportReservation {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 有界保留期删除,不承载周期、暂停或重试策略。
|
||||
//! 有界保留期删除,不承载周期、暂停或重试策略
|
||||
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::model::retention::{RetentionPolicy, RetentionTarget};
|
||||
pub trait RetentionStore: Send + Sync {
|
||||
async fn load_policy(&self) -> AdminStoreResult<RetentionPolicy>;
|
||||
|
||||
/// 每次最多删除 limit 行;请求关联事件随请求事务级联删除。
|
||||
/// 每次最多删除 limit 行;请求关联事件随请求事务级联删除
|
||||
async fn purge_batch(
|
||||
&self,
|
||||
target: RetentionTarget,
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
//! 管理控制面所需的持久化能力。
|
||||
//! 管理控制面所需的持久化能力
|
||||
//!
|
||||
//! 端口按业务资源拆分,方法使用领域模型,不暴露连接池、事务或 Redis client。
|
||||
//! 端口按业务资源拆分,方法使用领域模型,不暴露连接池、事务或 Redis client
|
||||
|
||||
use std::{collections::BTreeMap, net::IpAddr, sync::Arc, time::Duration};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::stream::BoxStream;
|
||||
|
||||
use super::backup::BackupStorePorts;
|
||||
use crate::model::{
|
||||
MutationContext, Revision,
|
||||
account_groups::{
|
||||
AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation, AccountGroupPage,
|
||||
DeleteAccountGroup, NewAccountGroup, SetAccountGroupEnabled, UpdateAccountGroup,
|
||||
AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation,
|
||||
AccountGroupOptionsPage, AccountGroupPage, DeleteAccountGroup, NewAccountGroup,
|
||||
SetAccountGroupEnabled, UpdateAccountGroup,
|
||||
},
|
||||
accounts::{
|
||||
AccountListQuery, AccountPage, AccountPageItem, AccountRuntimeSnapshot,
|
||||
@@ -27,9 +27,10 @@ use crate::model::{
|
||||
UpdateClientKey, UpdateClientKeyBudgetLimits,
|
||||
},
|
||||
observability::{
|
||||
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticsObservation,
|
||||
OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange, UsageCalculatedBillingFact,
|
||||
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
|
||||
DashboardObservation, DashboardQuery, DiagnosticDimension, DiagnosticsObservation,
|
||||
Granularity, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
|
||||
UsageBillingProjection, UsageCalculatedBillingFact, UsageDetail, UsageFilter,
|
||||
UsageInsightsObservation, UsageOverview, UsagePage, UsageQuery,
|
||||
},
|
||||
provider_credentials::{
|
||||
AuthorizationCommit, CredentialDetails, CredentialImportCommit, CredentialImportResult,
|
||||
@@ -40,7 +41,7 @@ use crate::model::{
|
||||
settings::{AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RuntimeSettings},
|
||||
};
|
||||
|
||||
/// 管理端可判定的持久化失败类型。
|
||||
/// 管理端可判定的持久化失败类型
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AdminStoreErrorKind {
|
||||
Invalid,
|
||||
@@ -51,13 +52,14 @@ pub enum AdminStoreErrorKind {
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// 隐藏数据库实现细节的持久化错误。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
/// 隐藏数据库实现细节的持久化错误
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
#[error("{resource} store operation failed: {message}")]
|
||||
pub struct AdminStoreError {
|
||||
kind: AdminStoreErrorKind,
|
||||
resource: &'static str,
|
||||
message: String,
|
||||
source: Option<gateway_core::error::ErrorSource>,
|
||||
}
|
||||
|
||||
impl AdminStoreError {
|
||||
@@ -71,9 +73,27 @@ impl AdminStoreError {
|
||||
kind,
|
||||
resource,
|
||||
message: message.into(),
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 来源留在内部诊断链,公开消息不展开基础设施错误
|
||||
#[must_use]
|
||||
pub fn with_source(mut self, source: impl Into<gateway_core::error::ErrorSource>) -> Self {
|
||||
self.source = Some(source.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 清理失败不覆盖最初操作的分类与原因
|
||||
#[must_use]
|
||||
pub fn with_cleanup(mut self, cleanup: impl Into<gateway_core::error::ErrorSource>) -> Self {
|
||||
self.source = Some(gateway_core::error::ErrorSource::cleanup(
|
||||
self.source.take(),
|
||||
cleanup,
|
||||
));
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> AdminStoreErrorKind {
|
||||
self.kind
|
||||
@@ -87,10 +107,10 @@ impl AdminStoreError {
|
||||
|
||||
pub type AdminStoreResult<T> = Result<T, AdminStoreError>;
|
||||
|
||||
/// 账号目录与公共账号写操作。
|
||||
/// 账号目录与公共账号写操作
|
||||
#[async_trait]
|
||||
pub trait AccountStore: Send + Sync {
|
||||
/// 插件回调按授权 Provider/账号在数据库过滤后做稳定 cursor 分页。
|
||||
/// 插件回调按授权 Provider/账号在数据库过滤后做稳定 cursor 分页
|
||||
async fn list_plugin_accounts(
|
||||
&self,
|
||||
query: PluginAccountListQuery,
|
||||
@@ -119,7 +139,7 @@ pub trait AccountStore: Send + Sync {
|
||||
windows: &[AccountUsageWindowQuery],
|
||||
) -> AdminStoreResult<Vec<AccountUsageWindowResult>>;
|
||||
|
||||
/// 从同一数据库语句取得截止快照的累计用量和有界历史观测。
|
||||
/// 从同一数据库语句取得截止快照的累计用量和有界历史观测
|
||||
async fn load_quota_forecast_history(
|
||||
&self,
|
||||
window: &AccountUsageWindowQuery,
|
||||
@@ -131,7 +151,7 @@ pub trait AccountStore: Send + Sync {
|
||||
account_id: &gateway_core::account::ProviderAccountId,
|
||||
) -> AdminStoreResult<Option<CredentialDetails>>;
|
||||
|
||||
/// 插件按全局账号 ID 读取时,由数据库记录提供权威 Provider 归属。
|
||||
/// 插件按全局账号 ID 读取时,由数据库记录提供权威 Provider 归属
|
||||
async fn credential_details_by_id(
|
||||
&self,
|
||||
account_id: &gateway_core::account::ProviderAccountId,
|
||||
@@ -143,7 +163,7 @@ pub trait AccountStore: Send + Sync {
|
||||
account_ids: &[gateway_core::account::ProviderAccountId],
|
||||
) -> AdminStoreResult<Vec<ProviderExportCredentialInput>>;
|
||||
|
||||
/// 插件凭据读取只接收账号 ID,不接受调用方另行声明 Provider。
|
||||
/// 插件凭据读取只接收账号 ID,不接受调用方另行声明 Provider
|
||||
async fn load_credential_for_plugin(
|
||||
&self,
|
||||
account_id: &gateway_core::account::ProviderAccountId,
|
||||
@@ -161,7 +181,7 @@ pub trait AccountStore: Send + Sync {
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<crate::model::provider_credentials::AuthorizationCommitResult>;
|
||||
|
||||
/// 已提交的授权结果独立于 Redis 临时状态;只有原管理员身份可读。
|
||||
/// 已提交的授权结果独立于 Redis 临时状态;只有原管理员身份可读
|
||||
async fn authorization_receipt(
|
||||
&self,
|
||||
key: &crate::model::provider_credentials::AuthorizationReceiptKey,
|
||||
@@ -185,7 +205,7 @@ pub trait AccountStore: Send + Sync {
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AccountUpdateResult>;
|
||||
|
||||
/// 在事务内按最新启用状态、账号上限和全局默认值判断,只降低并发上限。
|
||||
/// 在事务内按最新启用状态、账号上限和全局默认值判断,只降低并发上限
|
||||
async fn lower_concurrency_limit(
|
||||
&self,
|
||||
account_id: &gateway_core::account::ProviderAccountId,
|
||||
@@ -218,7 +238,7 @@ pub trait AccountStore: Send + Sync {
|
||||
) -> AdminStoreResult<()>;
|
||||
}
|
||||
|
||||
/// 可丢失账号运行态的管理读端口;跨存储编排由 Admin application service 拥有。
|
||||
/// 可丢失账号运行态的管理读端口;跨存储编排由 Admin application service 拥有
|
||||
#[async_trait]
|
||||
pub trait AccountRuntimeStore: Send + Sync {
|
||||
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot>;
|
||||
@@ -228,18 +248,18 @@ pub trait AccountRuntimeStore: Send + Sync {
|
||||
account_ids: &[String],
|
||||
) -> AdminStoreResult<AccountRuntimeSnapshot>;
|
||||
|
||||
/// 容量熔断自动冻结中的账号与其冻结截止时间;429 临时限流不包含在内。
|
||||
/// 容量熔断自动冻结中的账号与其冻结截止时间;429 临时限流不包含在内
|
||||
async fn active_freezes(
|
||||
&self,
|
||||
) -> AdminStoreResult<BTreeMap<String, crate::model::accounts::AccountFreeze>>;
|
||||
|
||||
/// 读取容量失败窗口内观测到的在途并发峰值(自适应并发下调的证据)。
|
||||
/// 读取容量失败窗口内观测到的在途并发峰值(自适应并发下调的证据)
|
||||
async fn capacity_peaks(
|
||||
&self,
|
||||
account_ids: &[String],
|
||||
) -> AdminStoreResult<BTreeMap<String, u32>>;
|
||||
|
||||
/// 仅当冻结快照仍匹配时解除或顺延;旧探测不得覆盖手动恢复或新一轮冻结。
|
||||
/// 仅当冻结快照仍匹配时解除或顺延;旧探测不得覆盖手动恢复或新一轮冻结
|
||||
async fn finish_freeze(
|
||||
&self,
|
||||
account_id: &str,
|
||||
@@ -248,12 +268,12 @@ pub trait AccountRuntimeStore: Send + Sync {
|
||||
) -> AdminStoreResult<bool>;
|
||||
}
|
||||
|
||||
/// 控制面凭据、统一会话、登录限流与管理员安全审计。
|
||||
/// 控制面凭据、统一会话、登录限流与管理员安全审计
|
||||
#[async_trait]
|
||||
pub trait AuthStore: Send + Sync {
|
||||
async fn load_password_hash(&self, admin_user_id: &str) -> AdminStoreResult<Option<String>>;
|
||||
|
||||
/// 密码更新与审计必须在同一事务提交;旧哈希不匹配时不写入。
|
||||
/// 密码更新与审计必须在同一事务提交;旧哈希不匹配时不写入
|
||||
async fn change_password(
|
||||
&self,
|
||||
admin_user_id: &str,
|
||||
@@ -274,6 +294,14 @@ pub trait AuthStore: Send + Sync {
|
||||
|
||||
async fn store_session(&self, session_id: &str, session: &AuthSession) -> AdminStoreResult<()>;
|
||||
|
||||
/// 原子替换或撤销仍匹配的会话并返回当前值;过期或退出后不重新创建
|
||||
async fn compare_exchange_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
expected: &AuthSession,
|
||||
replacement: Option<&AuthSession>,
|
||||
) -> AdminStoreResult<Option<AuthSession>>;
|
||||
|
||||
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>>;
|
||||
|
||||
async fn client_key_enabled(
|
||||
@@ -281,7 +309,7 @@ pub trait AuthStore: Send + Sync {
|
||||
id: &gateway_core::policy::ClientApiKeyId,
|
||||
) -> AdminStoreResult<bool>;
|
||||
|
||||
/// 原子消费来源桶与全局桶的一次登录尝试;被拒绝时返回建议重试间隔。
|
||||
/// 原子消费来源桶与全局桶的一次登录尝试;被拒绝时返回建议重试间隔
|
||||
async fn consume_login_attempt(
|
||||
&self,
|
||||
source_ip: IpAddr,
|
||||
@@ -293,10 +321,10 @@ pub trait AuthStore: Send + Sync {
|
||||
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()>;
|
||||
}
|
||||
|
||||
/// Client API Key 资料读取与管理写入。
|
||||
/// Client API Key 资料读取与管理写入
|
||||
#[async_trait]
|
||||
pub trait ClientKeyStore: Send + Sync {
|
||||
/// 按已验证的 ID 读取资料,不读取完整明文 Key。
|
||||
/// 按已验证的 ID 读取资料,不读取完整明文 Key
|
||||
async fn get_client_key(
|
||||
&self,
|
||||
id: &gateway_core::policy::ClientApiKeyId,
|
||||
@@ -333,7 +361,7 @@ pub trait ClientKeyStore: Send + Sync {
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision>;
|
||||
|
||||
/// 局部更新预算上限,保留其他策略和账本;无变化时不产生配置版本或审计。
|
||||
/// 局部更新预算上限,保留其他策略和账本;无变化时不产生配置版本或审计
|
||||
async fn update_client_key_budget_limits(
|
||||
&self,
|
||||
command: UpdateClientKeyBudgetLimits,
|
||||
@@ -341,7 +369,7 @@ pub trait ClientKeyStore: Send + Sync {
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Option<Revision>>;
|
||||
|
||||
/// 仅修改运行时账本并原子记录审计,不推进配置版本。
|
||||
/// 仅修改运行时账本并原子记录审计,不推进配置版本
|
||||
async fn reset_client_key_budget(
|
||||
&self,
|
||||
command: ResetClientKeyBudget,
|
||||
@@ -350,7 +378,7 @@ pub trait ClientKeyStore: Send + Sync {
|
||||
) -> AdminStoreResult<()>;
|
||||
}
|
||||
|
||||
/// Provider-neutral account group management transactions.
|
||||
/// 跨 Provider 的账号分组管理事务
|
||||
#[async_trait]
|
||||
pub trait AccountGroupStore: Send + Sync {
|
||||
async fn list_account_groups(
|
||||
@@ -358,6 +386,11 @@ pub trait AccountGroupStore: Send + Sync {
|
||||
query: AccountGroupListQuery,
|
||||
) -> AdminStoreResult<AccountGroupPage>;
|
||||
|
||||
async fn list_account_group_options(
|
||||
&self,
|
||||
query: AccountGroupListQuery,
|
||||
) -> AdminStoreResult<AccountGroupOptionsPage>;
|
||||
|
||||
async fn load_account_group_members(
|
||||
&self,
|
||||
group_ids: &[gateway_core::routing::AccountGroupId],
|
||||
@@ -388,46 +421,50 @@ pub trait AccountGroupStore: Send + Sync {
|
||||
) -> AdminStoreResult<AccountGroupMutation>;
|
||||
}
|
||||
|
||||
/// 逐条读取的已计算费用事实;消费结束或丢弃时释放查询资源。
|
||||
pub type UsageCalculatedBillingStream<'a> =
|
||||
BoxStream<'a, AdminStoreResult<UsageCalculatedBillingFact>>;
|
||||
/// 后台投影复用 Admin 与 Provider 的费用规则,Store 不重新解释价格
|
||||
pub trait UsageBillingProjector: Send + Sync {
|
||||
fn project(
|
||||
&self,
|
||||
fact: &UsageCalculatedBillingFact,
|
||||
) -> Result<UsageBillingProjection, crate::model::AdminError>;
|
||||
}
|
||||
|
||||
/// 用量、趋势、诊断与运维错误的只读能力。
|
||||
/// 用量、趋势、诊断与运维错误的只读能力
|
||||
#[async_trait]
|
||||
pub trait ObservabilityStore: Send + Sync {
|
||||
/// 返回历史统计区间和指定观测时刻下的实时账号状态。
|
||||
/// 读取实现声明的总预算,缓存等待沿用同一配置,避免外层提前截断
|
||||
fn query_timeout(&self) -> std::time::Duration;
|
||||
|
||||
/// 缓存必须与删除和后台投影提交的版本一致
|
||||
async fn analytics_cache_generation(&self) -> AdminStoreResult<u64>;
|
||||
|
||||
/// 返回历史统计区间和指定观测时刻下的实时账号状态
|
||||
async fn dashboard_summary(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
query: DashboardQuery,
|
||||
observed_at: DateTime<Utc>,
|
||||
) -> AdminStoreResult<DashboardObservation>;
|
||||
|
||||
/// 返回 Dashboard 可选的实时槽位事实。
|
||||
///
|
||||
/// 该状态来自可丢失的运行时存储;无实现或运行时存储不可用时返回 `None`,不影响
|
||||
/// 持久观测数据的读取。
|
||||
async fn dashboard_runtime_slots(
|
||||
async fn dashboard_trend(
|
||||
&self,
|
||||
_observed_at: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<DashboardRuntimeSlots>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn dashboard_trend(&self, range: TimeRange) -> AdminStoreResult<Vec<RequestMetricPoint>>;
|
||||
range: TimeRange,
|
||||
granularity: Granularity,
|
||||
) -> AdminStoreResult<Vec<RequestMetricPoint>>;
|
||||
|
||||
async fn usage_trend(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
granularity: Granularity,
|
||||
) -> AdminStoreResult<Vec<RequestMetricPoint>>;
|
||||
|
||||
/// 流式返回可由 Provider 重新校验的已计算费用事实,不保证顺序。
|
||||
/// 查询及解码错误由流返回;调用方应逐条聚合,避免收集整个区间。
|
||||
fn usage_calculated_billing_facts(
|
||||
/// 同一读取快照中的汇总、趋势和已投影情景费用
|
||||
async fn usage_insights_observation(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> UsageCalculatedBillingStream<'_>;
|
||||
granularity: Granularity,
|
||||
) -> AdminStoreResult<UsageInsightsObservation>;
|
||||
|
||||
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage>;
|
||||
|
||||
@@ -444,12 +481,13 @@ pub trait ObservabilityStore: Send + Sync {
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
dimension: DiagnosticDimension,
|
||||
limit: u16,
|
||||
) -> AdminStoreResult<DiagnosticsObservation>;
|
||||
|
||||
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage>;
|
||||
}
|
||||
|
||||
/// Runtime settings 与管理员 API Key 写入。
|
||||
/// Runtime settings 与管理员 API Key 写入
|
||||
#[async_trait]
|
||||
pub trait SettingsStore: Send + Sync {
|
||||
async fn load_pricing(&self) -> AdminStoreResult<crate::model::pricing::StoredPricing>;
|
||||
@@ -486,7 +524,7 @@ pub trait SettingsStore: Send + Sync {
|
||||
) -> AdminStoreResult<AdminApiKeyMutation>;
|
||||
}
|
||||
|
||||
/// 账号目录、运行态与分组所需的 Store 能力集合。
|
||||
/// 账号目录、运行态与分组所需的 Store 能力集合
|
||||
#[derive(Clone)]
|
||||
pub struct AdminAccountStorePorts {
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
@@ -512,9 +550,10 @@ impl AdminAccountStorePorts {
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理用例所需能力的封闭集合。
|
||||
/// 管理用例所需能力的封闭集合
|
||||
///
|
||||
/// 字段保持私有,每个 getter 只交出一种明确能力。该类型不提供通用拆包入口。
|
||||
/// 字段保持私有,每个 getter 只交出一种明确能力
|
||||
/// 该类型不提供通用拆包入口
|
||||
#[derive(Clone)]
|
||||
pub struct AdminStorePorts {
|
||||
accounts: AdminAccountStorePorts,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Host 为系统管理用例提供的进程与操作系统能力。
|
||||
//! Host 为系统管理用例提供的进程与操作系统能力
|
||||
|
||||
use std::{pin::Pin, sync::Arc};
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::model::system::{
|
||||
SystemUpdateStatus, SystemVersion,
|
||||
};
|
||||
|
||||
/// Host 已校验并解包的同一更新候选;字节只含发行清单,不含运行配置或凭据。
|
||||
/// Host 已校验并解包的同一更新候选;字节只含发行清单,不含运行配置或凭据
|
||||
#[derive(Clone)]
|
||||
pub struct SystemUpdateCandidate {
|
||||
pub target_version: String,
|
||||
@@ -28,7 +28,7 @@ impl std::fmt::Debug for SystemUpdateCandidate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Host 系统操作失败类型。
|
||||
/// Host 系统操作失败类型
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SystemOperationErrorKind {
|
||||
Invalid,
|
||||
@@ -37,7 +37,7 @@ pub enum SystemOperationErrorKind {
|
||||
Internal,
|
||||
}
|
||||
|
||||
/// 不泄漏路径、命令行或发布凭据的系统操作错误。
|
||||
/// 不泄漏路径、命令行或发布凭据的系统操作错误
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("system operation failed: {message}")]
|
||||
pub struct SystemOperationError {
|
||||
@@ -59,17 +59,17 @@ impl SystemOperationError {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// 返回 Host 已完成脱敏的客户端安全消息。
|
||||
/// 返回 Host 已完成脱敏的客户端安全消息
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
}
|
||||
|
||||
/// 每个订阅者独占的系统事件流。
|
||||
/// 每个订阅者独占的系统事件流
|
||||
pub type SystemUpdateEventStream = Pin<Box<dyn Stream<Item = SystemUpdateEvent> + Send + 'static>>;
|
||||
|
||||
/// Admin 对启用实例和制品作兼容决策;Host 只负责候选下载、校验与文件交换。
|
||||
/// Admin 对启用实例和制品作兼容决策;Host 只负责候选下载、校验与文件交换
|
||||
#[async_trait]
|
||||
pub trait SystemUpdatePreflight: Send + Sync {
|
||||
async fn validate(
|
||||
@@ -77,7 +77,7 @@ pub trait SystemUpdatePreflight: Send + Sync {
|
||||
candidate: SystemUpdateCandidate,
|
||||
) -> Result<Revision, SystemOperationError>;
|
||||
|
||||
/// 回滚保持严格预检,不能把旧目标当成具有当前重启确认能力的版本。
|
||||
/// 回滚保持严格预检,不能把旧目标当成具有当前重启确认能力的版本
|
||||
async fn validate_rollback(
|
||||
&self,
|
||||
candidate: SystemUpdateCandidate,
|
||||
@@ -85,11 +85,11 @@ pub trait SystemUpdatePreflight: Send + Sync {
|
||||
self.validate(candidate).await
|
||||
}
|
||||
|
||||
/// 在文件交换临界点复核全局配置 CAS,不能用较早快照掩盖实例变更。
|
||||
/// 在文件交换临界点复核全局配置 CAS,不能用较早快照掩盖实例变更
|
||||
async fn confirm_revision(&self, expected: Revision) -> Result<(), SystemOperationError>;
|
||||
}
|
||||
|
||||
/// 重启临界区内复查候选,确认后停用不兼容插件,再允许进程退出。
|
||||
/// 重启临界区内复查候选,确认后停用不兼容插件,再允许进程退出
|
||||
#[async_trait]
|
||||
pub trait SystemRestartPreflight: Send + Sync {
|
||||
async fn prepare(
|
||||
@@ -98,7 +98,7 @@ pub trait SystemRestartPreflight: Send + Sync {
|
||||
) -> Result<(), SystemOperationError>;
|
||||
}
|
||||
|
||||
/// 版本、自更新、回滚和重启能力;实现唯一归 gateway-host。
|
||||
/// 版本、自更新、回滚和重启能力;实现唯一归 gateway-host
|
||||
#[async_trait]
|
||||
pub trait SystemOperations: Send + Sync {
|
||||
async fn version(&self) -> Result<SystemVersion, SystemOperationError>;
|
||||
@@ -125,7 +125,7 @@ pub trait SystemOperations: Send + Sync {
|
||||
preflight: Arc<dyn SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError>;
|
||||
|
||||
/// 返回下一次启动实际使用的发行;源码运行没有封存发行清单。
|
||||
/// 返回下一次启动实际使用的发行;源码运行没有封存发行清单
|
||||
async fn restart_candidate(
|
||||
&self,
|
||||
) -> Result<Option<SystemUpdateCandidate>, SystemOperationError> {
|
||||
|
||||
+7
-7
@@ -1,4 +1,4 @@
|
||||
//! 主动服务调用的统一组合入口;普通业务方法由所属 HTTP 或执行边界包裹。
|
||||
//! 主动服务调用的统一组合入口;普通业务方法由所属 HTTP 或执行边界包裹
|
||||
|
||||
mod registry;
|
||||
mod settings;
|
||||
@@ -8,18 +8,18 @@ use std::{future::Future, sync::Arc};
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::{
|
||||
engine::middleware::FrozenMiddlewarePlan,
|
||||
middleware::{
|
||||
compose,
|
||||
engine::middleware::{
|
||||
FrozenMiddlewarePlan,
|
||||
service::{Context, Error, Value},
|
||||
},
|
||||
middleware::compose,
|
||||
};
|
||||
|
||||
use crate::model::{AdminError, AdminErrorKind};
|
||||
|
||||
pub type PlanSource = Arc<dyn Fn() -> Option<FrozenMiddlewarePlan> + Send + Sync>;
|
||||
|
||||
/// 空计划也是快照,只有未解析的外部入口才读取当前发布集合。
|
||||
/// 空计划也是快照,只有未解析的外部入口才读取当前发布集合
|
||||
#[derive(Clone, Default)]
|
||||
pub enum Plan {
|
||||
#[default]
|
||||
@@ -27,7 +27,7 @@ pub enum Plan {
|
||||
Frozen(Option<FrozenMiddlewarePlan>),
|
||||
}
|
||||
|
||||
/// 非服务入口也有父调用身份和取消信号;发布计划在首次进入公开服务时解析。
|
||||
/// 非服务入口也有父调用身份和取消信号;发布计划在首次进入公开服务时解析
|
||||
#[derive(Clone)]
|
||||
pub struct Origin {
|
||||
pub request_id: String,
|
||||
@@ -51,7 +51,7 @@ impl From<Context> for Origin {
|
||||
|
||||
tokio::task_local! { static CURRENT: Origin; }
|
||||
|
||||
/// task local 随 future poll 进入/退出,不泄漏给并行任务。
|
||||
/// task local 随 future poll 进入/退出,不泄漏给并行任务
|
||||
pub async fn scope<T>(context: impl Into<Origin>, future: impl Future<Output = T>) -> T {
|
||||
CURRENT.scope(context.into(), future).await
|
||||
}
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
//! 公开操作登记在所属服务;注册表只校验类型和分派,不实现业务。
|
||||
//! 公开操作登记在所属服务;注册表只校验类型和分派,不实现业务
|
||||
|
||||
use crate::model::AdminError;
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::middleware::service::{Error, Value};
|
||||
use gateway_core::engine::middleware::service::{Error, Value};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
@@ -21,7 +21,7 @@ impl Registry {
|
||||
}
|
||||
}
|
||||
|
||||
/// 网关与命令行复用同一组设置服务;注册不会执行读取或写入。
|
||||
/// 网关与命令行复用同一组设置服务;注册不会执行读取或写入
|
||||
pub fn register_settings(
|
||||
&mut self,
|
||||
service: &Arc<dyn crate::SettingsService>,
|
||||
+9
-2
@@ -1,4 +1,4 @@
|
||||
//! 设置服务的唯一公开操作声明;SDK 操作和数据合同由对应合同测试生成。
|
||||
//! 设置服务的唯一公开操作声明;SDK 操作和数据合同由对应合同测试生成
|
||||
|
||||
use crate::{
|
||||
SettingsService,
|
||||
@@ -14,7 +14,7 @@ pub(super) fn register(
|
||||
registry: &mut super::Registry,
|
||||
service: &Arc<dyn SettingsService>,
|
||||
) -> Result<(), AdminError> {
|
||||
// 声明保留 SDK 类型名和原生参数转换,分派名称由业务方法名直接派生。
|
||||
// 声明保留 SDK 类型名和原生参数转换,分派名称由业务方法名直接派生
|
||||
macro_rules! register {
|
||||
($sdk:ident, $method:ident, $input:ty, $pattern:pat $(, $argument:expr)* $(,)?) => {{
|
||||
let service = service.clone();
|
||||
@@ -25,6 +25,13 @@ pub(super) fn register(
|
||||
}};
|
||||
}
|
||||
register!(Load, load, (), ());
|
||||
register!(
|
||||
PreviewPrivacyPolicy,
|
||||
preview_privacy_policy,
|
||||
gateway_core::settings::privacy::PrivacyPreviewRequest,
|
||||
request,
|
||||
request
|
||||
);
|
||||
register!(
|
||||
Replace,
|
||||
replace,
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Account group management use cases.
|
||||
//! 账号分组的查询、配置与运行态容量聚合用例
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
@@ -19,9 +19,9 @@ use crate::{
|
||||
AdminError, MutationContext,
|
||||
account_groups::{
|
||||
AccountGroupAccountSummary, AccountGroupCapacity, AccountGroupListQuery,
|
||||
AccountGroupMemberFact, AccountGroupMutation, AccountGroupPage, AccountGroupRecord,
|
||||
CreateAccountGroup, DeleteAccountGroup, NewAccountGroup, SetAccountGroupEnabled,
|
||||
UpdateAccountGroup,
|
||||
AccountGroupMemberFact, AccountGroupMutation, AccountGroupOptionsPage,
|
||||
AccountGroupPage, AccountGroupRecord, CreateAccountGroup, DeleteAccountGroup,
|
||||
NewAccountGroup, SetAccountGroupEnabled, UpdateAccountGroup,
|
||||
},
|
||||
},
|
||||
ports::store::{AccountGroupStore, AccountRuntimeStore},
|
||||
@@ -29,10 +29,14 @@ use crate::{
|
||||
|
||||
use super::{map_store_error, publish_committed};
|
||||
|
||||
/// API-facing account group management service.
|
||||
/// 面向 API 的账号分组管理服务
|
||||
#[async_trait]
|
||||
pub trait AccountGroupService: Send + Sync {
|
||||
async fn list(&self, query: AccountGroupListQuery) -> Result<AccountGroupPage, AdminError>;
|
||||
async fn list_options(
|
||||
&self,
|
||||
query: AccountGroupListQuery,
|
||||
) -> Result<AccountGroupOptionsPage, AdminError>;
|
||||
async fn create(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
@@ -130,11 +134,26 @@ impl AccountGroupService for DefaultAccountGroupService {
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
async fn list_options(
|
||||
&self,
|
||||
query: AccountGroupListQuery,
|
||||
) -> Result<AccountGroupOptionsPage, AdminError> {
|
||||
self.store
|
||||
.list_account_group_options(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "account group options"))
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: CreateAccountGroup,
|
||||
) -> Result<AccountGroupMutation, AdminError> {
|
||||
crate::model::account_groups::validate_group_fields(
|
||||
&command.name,
|
||||
command.description.as_deref(),
|
||||
)
|
||||
.map_err(|_| AdminError::invalid("账号组请求不合法"))?;
|
||||
let id = AccountGroupId::new(format!("grp_{}", Uuid::now_v7().simple()))
|
||||
.map_err(|_| AdminError::internal("创建账号组 ID 失败"))?;
|
||||
self.publish(
|
||||
@@ -144,7 +163,7 @@ impl AccountGroupService for DefaultAccountGroupService {
|
||||
id,
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
disable_fast: command.disable_fast,
|
||||
fast_mode: command.fast_mode,
|
||||
color: command.color,
|
||||
},
|
||||
context,
|
||||
@@ -159,6 +178,11 @@ impl AccountGroupService for DefaultAccountGroupService {
|
||||
context: &MutationContext,
|
||||
command: UpdateAccountGroup,
|
||||
) -> Result<AccountGroupMutation, AdminError> {
|
||||
crate::model::account_groups::validate_group_fields(
|
||||
&command.name,
|
||||
command.description.as_deref(),
|
||||
)
|
||||
.map_err(|_| AdminError::invalid("账号组请求不合法"))?;
|
||||
self.publish(self.store.update_account_group(command, context).await)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 统一账号目录与原生 Provider 分派。
|
||||
//! 统一账号目录与原生 Provider 分派
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
@@ -49,7 +49,7 @@ use super::{
|
||||
|
||||
const CONNECTION_TEST_INPUT: &str = "Reply with exactly OK.";
|
||||
|
||||
/// 统一账号页消费的服务。
|
||||
/// 统一账号页消费的服务
|
||||
#[async_trait]
|
||||
pub trait AccountsService: Send + Sync {
|
||||
async fn list(&self, query: AccountListQuery) -> Result<AccountDirectoryPage, AdminError>;
|
||||
@@ -279,7 +279,7 @@ impl DefaultAccountsService {
|
||||
accounts: &[AccountPageItem],
|
||||
) -> Result<BTreeMap<String, AccountUsage>, AdminError> {
|
||||
let now = Utc::now();
|
||||
// API Key 没有套餐周期;本地累计直接查询账号创建后仍保留的请求记录。
|
||||
// API Key 没有套餐周期;本地累计直接查询账号创建后仍保留的请求记录
|
||||
let windows = accounts
|
||||
.iter()
|
||||
.filter(|item| item.account.authentication_kind == "api_key")
|
||||
@@ -333,7 +333,7 @@ impl DefaultAccountsService {
|
||||
.await
|
||||
{
|
||||
Ok(quota) => quota,
|
||||
// 凭据更新后的账号投影不要求 Provider 提供额度;显式刷新仍须支持该操作。
|
||||
// 凭据更新后的账号投影不要求 Provider 提供额度;显式刷新仍须支持该操作
|
||||
Err(error)
|
||||
if !refresh_quota
|
||||
&& error.kind()
|
||||
@@ -403,7 +403,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
.iter()
|
||||
.map(|item| item.account.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
// 只读取当前页的租约占用;观测失败不能把账号误报为空闲或拖垮目录。
|
||||
// 只读取当前页的租约占用;观测失败不能把账号误报为空闲或拖垮目录
|
||||
let in_flight = if ids.is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -429,7 +429,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
let account_id = ProviderAccountId::new(account.id.clone())
|
||||
.map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?;
|
||||
// 单个账号的 quota 投影失败(Provider 未注册或 quota 读取失败)不拖垮整页:
|
||||
// 该账号降级为空额度投影,其余账号与页面状态照常返回。
|
||||
// 该账号降级为空额度投影,其余账号与页面状态照常返回
|
||||
let provider = match providers.require(&account.provider_kind) {
|
||||
Ok(provider) => provider,
|
||||
Err(error) => {
|
||||
@@ -536,7 +536,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
}) {
|
||||
return Err(AdminError::invalid("账号导出列表包含重复 ID"));
|
||||
}
|
||||
// 混选时先确认全部 Provider 已注册,拒绝路径不读取任何一组明文凭据。
|
||||
// 混选时先确认全部 Provider 已注册,拒绝路径不读取任何一组明文凭据
|
||||
let export_groups = grouped
|
||||
.into_iter()
|
||||
.map(|(provider_kind, ids)| {
|
||||
@@ -615,7 +615,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
.map_err(|error| map_store_error(error, "provider account recovery"))?
|
||||
.config_revision
|
||||
} else {
|
||||
// 停用只表示不参与调度,重新启用不能抹除已观测的额度、凭据或冷却事实。
|
||||
// 停用只表示不参与调度,重新启用不能抹除已观测的额度、凭据或冷却事实
|
||||
self.accounts
|
||||
.batch_update_accounts(
|
||||
BatchUpdateAccounts {
|
||||
@@ -774,7 +774,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
let Some((window, _)) = quota_forecast_source_window("a, period) else {
|
||||
continue;
|
||||
};
|
||||
// 缺少一个周期时两个结果会复用同一窗口,只查询一次历史快照。
|
||||
// 缺少一个周期时两个结果会复用同一窗口,只查询一次历史快照
|
||||
if !selected_keys.insert(window.key.as_str()) {
|
||||
continue;
|
||||
}
|
||||
@@ -814,8 +814,8 @@ impl AccountsService for DefaultAccountsService {
|
||||
.as_deref()
|
||||
.zip(fact.plan_type.as_deref())
|
||||
.is_some_and(|(current, previous)| current.eq_ignore_ascii_case(previous));
|
||||
// 仅容纳已观测到的秒级量化抖动,不用宽时间容差合并实际重置。
|
||||
// 不匹配的段截断基线;之后的有效观测可以重新积累。
|
||||
// 仅容纳已观测到的秒级量化抖动,不用宽时间容差合并实际重置
|
||||
// 不匹配的段截断基线;之后的有效观测可以重新积累
|
||||
if !same_plan || (fact.reset_at - reset_at).abs() > Duration::seconds(2) {
|
||||
points.clear();
|
||||
interrupted = true;
|
||||
@@ -853,7 +853,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<AccountPersonalInfo, AdminError> {
|
||||
let (initial, provider) = self.provider_for_account(account_id).await?;
|
||||
// 两项读取相互独立;不因其中一项失败而取消另一项,也不触发凭据或额度刷新。
|
||||
// 两项读取相互独立;不因其中一项失败而取消另一项,也不触发凭据或额度刷新
|
||||
let (profile, subscription) = futures::join!(
|
||||
provider.profile_statistics(account_id),
|
||||
provider.subscription(account_id),
|
||||
@@ -865,7 +865,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
// 汇聚等待期间发生重新授权、换绑或删除时,不返回混合身份的数据。
|
||||
// 汇聚等待期间发生重新授权、换绑或删除时,不返回混合身份的数据
|
||||
let current = self.load_account(account_id).await?;
|
||||
if current.account.provider_kind != initial.account.provider_kind
|
||||
|| current.account.credential_revision != initial.account.credential_revision
|
||||
@@ -921,7 +921,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
) -> Result<ProviderResetCreditResult, AdminError> {
|
||||
let account_id = command.account_id.clone();
|
||||
// 覆盖 credential refresh + 同键重试的完整账号级临界区,避免 401 两次调用
|
||||
// 之间插入另一笔不可逆消费。
|
||||
// 之间插入另一笔不可逆消费
|
||||
let lock = self.reset_credit_lock(&account_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
let (_, provider) = self.provider_for_account(&account_id).await?;
|
||||
@@ -1049,9 +1049,10 @@ fn map_reset_credits_error_after_refresh(
|
||||
map_provider_error(error, "provider reset credits")
|
||||
}
|
||||
|
||||
/// 账号目录中单个账号 quota 读取失败时使用的空额度投影。
|
||||
/// 账号目录中单个账号 quota 读取失败时使用的空额度投影
|
||||
fn empty_quota() -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
@@ -1072,7 +1073,7 @@ fn quota_usage_window(
|
||||
let seconds = i64::try_from(window.window_seconds?).ok()?;
|
||||
let start = reset_at.checked_sub_signed(Duration::try_seconds(seconds)?)?;
|
||||
let range = TimeRange::new(start, reset_at).ok()?;
|
||||
// 上游百分比以该 reset 边界定义;以当前时间回推会让本地 Token 属于另一窗口。
|
||||
// 上游百分比以该 reset 边界定义;以当前时间回推会让本地 Token 属于另一窗口
|
||||
Some(AccountUsageWindowQuery {
|
||||
account_id: account_id.to_owned(),
|
||||
key: window.key.clone(),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! 控制面认证端口、会话策略与访问和续期共用的身份校验
|
||||
|
||||
use std::{net::IpAddr, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{Duration, Utc};
|
||||
use gateway_core::engine::execution::ClientKeyVerifier;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
use super::map_store_error;
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError,
|
||||
auth::{
|
||||
AuthSession, ChangePassword, LoginCommand, LoginError, SessionGrant, SessionSubject,
|
||||
},
|
||||
},
|
||||
ports::store::AuthStore,
|
||||
};
|
||||
|
||||
mod service;
|
||||
mod session;
|
||||
|
||||
/// 所有控制面接口消费同一个会话服务,权限由服务端身份决定
|
||||
#[async_trait]
|
||||
pub trait AuthService: Send + Sync {
|
||||
async fn change_password(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
command: ChangePassword,
|
||||
source_ip: IpAddr,
|
||||
) -> Result<(), AdminError>;
|
||||
async fn ensure_default_admin(&self, password: &str) -> Result<bool, AdminError>;
|
||||
async fn session(&self, session_id: Option<&str>) -> Result<Option<AuthSession>, AdminError>;
|
||||
async fn renew_session(
|
||||
&self,
|
||||
refresh_token: Option<&str>,
|
||||
access_token: Option<&str>,
|
||||
) -> Result<Option<SessionGrant>, AdminError>;
|
||||
async fn resolve_admin_user_id(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Option<String>, AdminError>;
|
||||
async fn verify_admin_api_key(&self, key: &str) -> Result<bool, AdminError>;
|
||||
async fn login(
|
||||
&self,
|
||||
command: LoginCommand,
|
||||
source_ip: IpAddr,
|
||||
previous_session_id: Option<&str>,
|
||||
) -> Result<SessionGrant, LoginError>;
|
||||
async fn logout(&self, session_id: &str) -> Result<(), AdminError>;
|
||||
}
|
||||
|
||||
const MAX_SESSION_TTL_MINUTES: i64 = 366 * 24 * 60;
|
||||
|
||||
pub(crate) struct DefaultAuthService {
|
||||
default_admin_user_id: String,
|
||||
admin_access_ttl: Duration,
|
||||
admin_session_ttl: Duration,
|
||||
admin_session_absolute_ttl: Duration,
|
||||
key_session_ttl: Duration,
|
||||
store: Arc<dyn AuthStore>,
|
||||
verifier: Arc<dyn ClientKeyVerifier>,
|
||||
}
|
||||
|
||||
impl DefaultAuthService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
config: &crate::AdminConfig,
|
||||
key_session_ttl_minutes: u64,
|
||||
store: Arc<dyn AuthStore>,
|
||||
verifier: Arc<dyn ClientKeyVerifier>,
|
||||
) -> Self {
|
||||
let admin_session_ttl = session_ttl(config.session_ttl_minutes);
|
||||
Self {
|
||||
default_admin_user_id: config.default_username.clone(),
|
||||
// 访问凭据最多有效 24 小时,较短的不活动期限也要留出续期窗口
|
||||
admin_access_ttl: Duration::hours(24).min(admin_session_ttl / 4),
|
||||
admin_session_ttl,
|
||||
admin_session_absolute_ttl: session_ttl(config.session_absolute_ttl_minutes),
|
||||
key_session_ttl: session_ttl(key_session_ttl_minutes),
|
||||
store,
|
||||
verifier,
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_live_session(&self, id: &str) -> Result<Option<AuthSession>, AdminError> {
|
||||
let Some(session) = self
|
||||
.store
|
||||
.load_session(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "authentication session"))?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if session.retained_until() <= Utc::now()
|
||||
|| session
|
||||
.absolute_expires_at
|
||||
.is_some_and(|expiry| expiry <= Utc::now())
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let valid = match &session.subject {
|
||||
SessionSubject::Admin {
|
||||
admin_user_id,
|
||||
credential_fingerprint,
|
||||
} => self
|
||||
.store
|
||||
.load_password_hash(admin_user_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator session"))?
|
||||
.is_some_and(|hash| password_fingerprint(&hash) == *credential_fingerprint),
|
||||
SessionSubject::Key { client_key_id } => self
|
||||
.store
|
||||
.client_key_enabled(client_key_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "session key"))?,
|
||||
};
|
||||
if !valid {
|
||||
let _ = self.store.delete_session(id).await;
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(session))
|
||||
}
|
||||
}
|
||||
|
||||
fn session_ttl(minutes: u64) -> Duration {
|
||||
Duration::minutes(
|
||||
i64::try_from(minutes)
|
||||
.unwrap_or(MAX_SESSION_TTL_MINUTES)
|
||||
.clamp(1, MAX_SESSION_TTL_MINUTES),
|
||||
)
|
||||
}
|
||||
|
||||
// 指纹只绑定已加盐的密码哈希,不把密码或原始哈希复制到 Redis 会话
|
||||
fn password_fingerprint(password_hash: &str) -> String {
|
||||
URL_SAFE_NO_PAD.encode(Sha256::digest(password_hash.as_bytes()))
|
||||
}
|
||||
+89
-119
@@ -1,86 +1,35 @@
|
||||
//! 控制面登录、会话恢复与身份校验的唯一 owner。
|
||||
//! 管理员和 Key 的登录、退出、密码策略与会话访问
|
||||
|
||||
use std::{net::IpAddr, sync::Arc, time::Duration as StdDuration};
|
||||
use std::{net::IpAddr, time::Duration as StdDuration};
|
||||
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{Duration, Utc};
|
||||
use gateway_core::engine::execution::ClientKeyVerifier;
|
||||
use chrono::Utc;
|
||||
use rand_core::{OsRng, RngCore as _};
|
||||
use sha2::{Digest, Sha256};
|
||||
use subtle::ConstantTimeEq as _;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
AuthService, DefaultAuthService, password_fingerprint,
|
||||
session::{self, Credential, storage_id},
|
||||
};
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, AdminErrorKind,
|
||||
auth::{
|
||||
AdminAuditEvent, AuditActorKind, AuthSession, ChangePassword, LoginCommand, LoginError,
|
||||
LoginResult, SessionSubject,
|
||||
SessionGrant, SessionSubject,
|
||||
},
|
||||
},
|
||||
ports::store::AuthStore,
|
||||
use_case::map_store_error,
|
||||
};
|
||||
|
||||
use super::map_store_error;
|
||||
|
||||
/// 所有控制面接口消费同一个会话服务,权限由服务端身份决定。
|
||||
#[async_trait]
|
||||
pub trait AuthService: Send + Sync {
|
||||
async fn change_password(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
command: ChangePassword,
|
||||
source_ip: IpAddr,
|
||||
) -> Result<(), AdminError>;
|
||||
async fn ensure_default_admin(&self, password: &str) -> Result<bool, AdminError>;
|
||||
async fn session(&self, session_id: Option<&str>) -> Result<Option<AuthSession>, AdminError>;
|
||||
async fn resolve_admin_user_id(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Option<String>, AdminError>;
|
||||
async fn verify_admin_api_key(&self, key: &str) -> Result<bool, AdminError>;
|
||||
async fn login(
|
||||
&self,
|
||||
command: LoginCommand,
|
||||
source_ip: IpAddr,
|
||||
previous_session_id: Option<&str>,
|
||||
) -> Result<LoginResult, LoginError>;
|
||||
async fn logout(&self, session_id: &str) -> Result<(), AdminError>;
|
||||
}
|
||||
|
||||
const MAX_SESSION_TTL_MINUTES: i64 = 366 * 24 * 60;
|
||||
const LOGIN_WINDOW: StdDuration = StdDuration::from_secs(60);
|
||||
const LOGIN_ATTEMPTS_PER_SOURCE: u32 = 10;
|
||||
const LOGIN_ATTEMPTS_GLOBAL: u32 = 200;
|
||||
|
||||
pub(crate) struct DefaultAuthService {
|
||||
default_admin_user_id: String,
|
||||
admin_session_ttl: Duration,
|
||||
key_session_ttl: Duration,
|
||||
store: Arc<dyn AuthStore>,
|
||||
verifier: Arc<dyn ClientKeyVerifier>,
|
||||
}
|
||||
|
||||
impl DefaultAuthService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
default_admin_user_id: impl Into<String>,
|
||||
admin_session_ttl_minutes: u64,
|
||||
key_session_ttl_minutes: u64,
|
||||
store: Arc<dyn AuthStore>,
|
||||
verifier: Arc<dyn ClientKeyVerifier>,
|
||||
) -> Self {
|
||||
Self {
|
||||
default_admin_user_id: default_admin_user_id.into(),
|
||||
admin_session_ttl: session_ttl(admin_session_ttl_minutes),
|
||||
key_session_ttl: session_ttl(key_session_ttl_minutes),
|
||||
store,
|
||||
verifier,
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_audit(&self, action: &str, admin_user_id: &str) -> AdminAuditEvent {
|
||||
AdminAuditEvent {
|
||||
id: format!("audit_{}", Uuid::now_v7().simple()),
|
||||
@@ -208,7 +157,7 @@ impl AuthService for DefaultAuthService {
|
||||
{
|
||||
return Err(AdminError::conflict("密码已变更,请重新登录"));
|
||||
}
|
||||
// 密码事务提交后旧指纹不再匹配,会话撤销不依赖 Redis 删除成功。
|
||||
// 密码事务提交后旧指纹不再匹配,会话撤销不依赖 Redis 删除成功
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -221,51 +170,51 @@ impl AuthService for DefaultAuthService {
|
||||
}
|
||||
|
||||
async fn session(&self, session_id: Option<&str>) -> Result<Option<AuthSession>, AdminError> {
|
||||
let Some(session_id) = session_id.filter(|value| !value.is_empty()) else {
|
||||
let Some(raw) = session_id.filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(session) = self
|
||||
.store
|
||||
.load_session(session_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "authentication session"))?
|
||||
else {
|
||||
let Some(session) = self.load_live_session(storage_id(raw)).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if session.expires_at <= Utc::now() {
|
||||
let _ = self.store.delete_session(session_id).await;
|
||||
return Ok(None);
|
||||
}
|
||||
if let SessionSubject::Admin {
|
||||
admin_user_id,
|
||||
credential_fingerprint,
|
||||
} = &session.subject
|
||||
{
|
||||
let password_hash = self
|
||||
.store
|
||||
.load_password_hash(admin_user_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator session"))?;
|
||||
if password_hash
|
||||
.is_none_or(|hash| password_fingerprint(&hash) != *credential_fingerprint)
|
||||
{
|
||||
let _ = self.store.delete_session(session_id).await;
|
||||
if let Some(renewal) = &session.renewal {
|
||||
let Some(credential) = Credential::parse(raw).filter(|value| !value.refresh) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !credential.owns(renewal) {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
if let SessionSubject::Key { client_key_id } = &session.subject
|
||||
&& !self
|
||||
.store
|
||||
.client_key_enabled(client_key_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "session key"))?
|
||||
{
|
||||
let _ = self.store.delete_session(session_id).await;
|
||||
} else if Credential::parse(raw).is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(session))
|
||||
}
|
||||
|
||||
async fn renew_session(
|
||||
&self,
|
||||
refresh_token: Option<&str>,
|
||||
access_token: Option<&str>,
|
||||
) -> Result<Option<SessionGrant>, AdminError> {
|
||||
if let Some(refresh) = refresh_token {
|
||||
return self.refresh(refresh).await;
|
||||
}
|
||||
// Key 与升级前的单凭据仅返回仍有效的原会话,不授予新的续期能力
|
||||
let Some(session) = self
|
||||
.session(access_token)
|
||||
.await?
|
||||
.filter(|session| session.renewal.is_none())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(SessionGrant {
|
||||
session_id: access_token.unwrap_or_default().to_owned(),
|
||||
refresh_token: None,
|
||||
session,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn resolve_admin_user_id(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
@@ -304,7 +253,7 @@ impl AuthService for DefaultAuthService {
|
||||
command: LoginCommand,
|
||||
source_ip: IpAddr,
|
||||
previous_session_id: Option<&str>,
|
||||
) -> Result<LoginResult, LoginError> {
|
||||
) -> Result<SessionGrant, LoginError> {
|
||||
if let Some(retry_after) = self
|
||||
.store
|
||||
.consume_login_attempt(
|
||||
@@ -325,13 +274,34 @@ impl AuthService for DefaultAuthService {
|
||||
SessionSubject::Admin { .. } => self.admin_session_ttl,
|
||||
SessionSubject::Key { .. } => self.key_session_ttl,
|
||||
};
|
||||
let session = AuthSession {
|
||||
subject,
|
||||
expires_at: Utc::now() + ttl,
|
||||
let now = Utc::now();
|
||||
let absolute_expires_at = matches!(subject, SessionSubject::Admin { .. })
|
||||
.then_some(now + self.admin_session_absolute_ttl);
|
||||
let refresh_expiry = (now + ttl).min(absolute_expires_at.unwrap_or(now + ttl));
|
||||
let grant = if let Some(absolute) = absolute_expires_at {
|
||||
session::issue(
|
||||
subject,
|
||||
(now + self.admin_access_ttl).min(refresh_expiry),
|
||||
refresh_expiry,
|
||||
absolute,
|
||||
)
|
||||
.map_err(|_| LoginError::Unavailable)?
|
||||
} else {
|
||||
SessionGrant {
|
||||
session_id: random_session_token(),
|
||||
refresh_token: None,
|
||||
session: AuthSession {
|
||||
subject,
|
||||
expires_at: refresh_expiry,
|
||||
absolute_expires_at: None,
|
||||
renewal: None,
|
||||
},
|
||||
}
|
||||
};
|
||||
let session_id = random_session_token();
|
||||
let session_id = storage_id(&grant.session_id);
|
||||
let session = &grant.session;
|
||||
self.store
|
||||
.store_session(&session_id, &session)
|
||||
.store_session(session_id, session)
|
||||
.await
|
||||
.map_err(|_| LoginError::Unavailable)?;
|
||||
if let SessionSubject::Admin { admin_user_id, .. } = &session.subject
|
||||
@@ -341,26 +311,39 @@ impl AuthService for DefaultAuthService {
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let _ = self.store.delete_session(&session_id).await;
|
||||
let _ = self.store.delete_session(session_id).await;
|
||||
return Err(LoginError::Unavailable);
|
||||
}
|
||||
// 新身份验证成功后才撤销旧会话;撤销失败时不向浏览器提交新会话。
|
||||
// 新身份验证成功后才撤销旧会话;撤销失败时不向浏览器提交新会话
|
||||
if let Some(previous) = previous_session_id.filter(|value| !value.is_empty())
|
||||
&& self.logout(previous).await.is_err()
|
||||
{
|
||||
let _ = self.store.delete_session(&session_id).await;
|
||||
let _ = self.store.delete_session(session_id).await;
|
||||
return Err(LoginError::Unavailable);
|
||||
}
|
||||
Ok(LoginResult {
|
||||
session_id,
|
||||
session,
|
||||
})
|
||||
Ok(grant)
|
||||
}
|
||||
|
||||
async fn logout(&self, session_id: &str) -> Result<(), AdminError> {
|
||||
let id = storage_id(session_id);
|
||||
let Some(stored) = self
|
||||
.store
|
||||
.load_session(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "authentication session"))?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(renewal) = &stored.renewal {
|
||||
if !Credential::parse(session_id).is_some_and(|credential| credential.owns(renewal)) {
|
||||
return Ok(());
|
||||
}
|
||||
} else if Credential::parse(session_id).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let session = self
|
||||
.store
|
||||
.delete_session(session_id)
|
||||
.delete_session(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "authentication session"))?;
|
||||
if let Some(AuthSession {
|
||||
@@ -377,14 +360,6 @@ impl AuthService for DefaultAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
fn session_ttl(minutes: u64) -> Duration {
|
||||
Duration::minutes(
|
||||
i64::try_from(minutes)
|
||||
.unwrap_or(MAX_SESSION_TTL_MINUTES)
|
||||
.clamp(1, MAX_SESSION_TTL_MINUTES),
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_new_password(password: &str) -> Result<(), AdminError> {
|
||||
if password.trim().chars().count() < 12
|
||||
|| password.len() > 1024
|
||||
@@ -424,8 +399,3 @@ fn valid_admin_api_key_shape(value: &str) -> bool {
|
||||
&& value.starts_with("admin-")
|
||||
&& value[6..].bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
// 指纹只绑定已加盐的密码哈希,不把密码或原始哈希复制到 Redis 会话。
|
||||
fn password_fingerprint(password_hash: &str) -> String {
|
||||
URL_SAFE_NO_PAD.encode(Sha256::digest(password_hash.as_bytes()))
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! 双凭据会话的签发、摘要校验与短窗口重试,不持久化可直接使用的凭据
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use rand_core::{OsRng, RngCore as _};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use subtle::ConstantTimeEq as _;
|
||||
|
||||
use super::DefaultAuthService;
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, AdminErrorKind,
|
||||
auth::{AuthSession, SessionGrant, SessionRefreshReplay, SessionRenewal, SessionSubject},
|
||||
},
|
||||
use_case::map_store_error,
|
||||
};
|
||||
|
||||
const REPLAY_WINDOW: Duration = Duration::seconds(30);
|
||||
|
||||
pub(super) struct Credential<'a> {
|
||||
pub id: &'a str,
|
||||
pub secret: &'a str,
|
||||
pub refresh: bool,
|
||||
}
|
||||
|
||||
impl<'a> Credential<'a> {
|
||||
pub fn parse(value: &'a str) -> Option<Self> {
|
||||
let mut parts = value.split('.');
|
||||
let refresh = match parts.next()? {
|
||||
"a2" => false,
|
||||
"r2" => true,
|
||||
_ => return None,
|
||||
};
|
||||
let id = parts.next()?;
|
||||
let secret = parts.next()?;
|
||||
if parts.next().is_some() || !valid_secret(id) || !valid_secret(secret) {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
id,
|
||||
secret,
|
||||
refresh,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn digest(&self) -> [u8; 32] {
|
||||
digest(self.secret)
|
||||
}
|
||||
|
||||
pub fn owns(&self, renewal: &SessionRenewal) -> bool {
|
||||
if self.refresh {
|
||||
matches_digest(&renewal.refresh_digest, &self.digest())
|
||||
|| renewal
|
||||
.previous
|
||||
.as_ref()
|
||||
.is_some_and(|previous| matches_digest(&previous.digest, &self.digest()))
|
||||
} else {
|
||||
matches_digest(&renewal.access_digest, &self.digest())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_secret(value: &str) -> bool {
|
||||
value.len() == 43
|
||||
&& URL_SAFE_NO_PAD
|
||||
.decode(value)
|
||||
.is_ok_and(|bytes| bytes.len() == 32)
|
||||
}
|
||||
|
||||
pub(super) fn storage_id(value: &str) -> &str {
|
||||
Credential::parse(value).map_or(value, |credential| credential.id)
|
||||
}
|
||||
|
||||
fn matches_digest(left: &[u8; 32], right: &[u8; 32]) -> bool {
|
||||
bool::from(left.ct_eq(right))
|
||||
}
|
||||
|
||||
fn digest(secret: &str) -> [u8; 32] {
|
||||
Sha256::digest(secret.as_bytes()).into()
|
||||
}
|
||||
|
||||
fn random_bytes() -> [u8; 32] {
|
||||
let mut bytes = [0; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
bytes
|
||||
}
|
||||
|
||||
// 使用标准 HKDF 的独立用途标签;只有盐和摘要留在 Store,重试仍须持有原始凭据
|
||||
fn derive(secret: &str, salt: Option<&[u8]>, purpose: &[u8]) -> Result<String, AdminError> {
|
||||
let mut output = [0; 32];
|
||||
hkdf::Hkdf::<Sha256>::new(salt, secret.as_bytes())
|
||||
.expand(purpose, &mut output)
|
||||
.map_err(|_| AdminError::internal("会话凭据派生失败"))?;
|
||||
Ok(URL_SAFE_NO_PAD.encode(output))
|
||||
}
|
||||
|
||||
fn access_secret(refresh: &str) -> Result<String, AdminError> {
|
||||
derive(refresh, None, b"cpr-session-v2/access")
|
||||
}
|
||||
|
||||
pub(super) fn issue(
|
||||
subject: SessionSubject,
|
||||
access_expiry: DateTime<Utc>,
|
||||
refresh_expiry: DateTime<Utc>,
|
||||
absolute: DateTime<Utc>,
|
||||
) -> Result<SessionGrant, AdminError> {
|
||||
let id = URL_SAFE_NO_PAD.encode(random_bytes());
|
||||
let secret = URL_SAFE_NO_PAD.encode(random_bytes());
|
||||
let session = AuthSession {
|
||||
subject,
|
||||
expires_at: access_expiry,
|
||||
absolute_expires_at: Some(absolute),
|
||||
renewal: Some(SessionRenewal {
|
||||
expires_at: refresh_expiry,
|
||||
access_digest: digest(&access_secret(&secret)?),
|
||||
refresh_digest: digest(&secret),
|
||||
previous: None,
|
||||
}),
|
||||
};
|
||||
grant(&id, &secret, session)
|
||||
}
|
||||
|
||||
fn grant(id: &str, refresh: &str, session: AuthSession) -> Result<SessionGrant, AdminError> {
|
||||
Ok(SessionGrant {
|
||||
session_id: format!("a2.{id}.{}", access_secret(refresh)?),
|
||||
refresh_token: Some(format!("r2.{id}.{refresh}")),
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
impl DefaultAuthService {
|
||||
pub(super) async fn refresh(&self, raw: &str) -> Result<Option<SessionGrant>, AdminError> {
|
||||
let Some(credential) = Credential::parse(raw).filter(|value| value.refresh) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut current = self.load_live_session(credential.id).await?;
|
||||
// CAS 冲突只重新解释同一请求的凭据,不对并发退出进行补建
|
||||
for _ in 0..3 {
|
||||
let Some(session) = current else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(renewal) = &session.renewal else {
|
||||
return Ok(None);
|
||||
};
|
||||
let now = Utc::now();
|
||||
if session.retained_until() <= now
|
||||
|| session.absolute_expires_at.is_none_or(|limit| limit <= now)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
if matches_digest(&renewal.refresh_digest, &credential.digest()) {
|
||||
// 其他标签页已经刷新或客户端重试时复用当前代,避免 Cookie 响应相互覆盖
|
||||
if session.expires_at > now {
|
||||
return grant(credential.id, credential.secret, session).map(Some);
|
||||
}
|
||||
let absolute = session
|
||||
.absolute_expires_at
|
||||
.ok_or_else(|| AdminError::internal("缺少会话最长有效期"))?;
|
||||
let salt = random_bytes();
|
||||
let next_secret =
|
||||
derive(credential.secret, Some(&salt), b"cpr-session-v2/refresh")?;
|
||||
let refresh_expiry = (now + self.admin_session_ttl).min(absolute);
|
||||
let access_expiry = (now + self.admin_access_ttl).min(refresh_expiry);
|
||||
let replacement = AuthSession {
|
||||
expires_at: access_expiry,
|
||||
renewal: Some(SessionRenewal {
|
||||
expires_at: refresh_expiry,
|
||||
access_digest: digest(&access_secret(&next_secret)?),
|
||||
refresh_digest: digest(&next_secret),
|
||||
previous: Some(SessionRefreshReplay {
|
||||
digest: credential.digest(),
|
||||
salt,
|
||||
expires_at: (now + REPLAY_WINDOW).min(access_expiry),
|
||||
}),
|
||||
}),
|
||||
..session.clone()
|
||||
};
|
||||
current = self
|
||||
.store
|
||||
.compare_exchange_session(credential.id, &session, Some(&replacement))
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "authentication session rotation"))?;
|
||||
if current.as_ref() == Some(&replacement) {
|
||||
return grant(credential.id, &next_secret, replacement).map(Some);
|
||||
}
|
||||
} else if let Some(previous) = renewal
|
||||
.previous
|
||||
.as_ref()
|
||||
.filter(|previous| matches_digest(&previous.digest, &credential.digest()))
|
||||
{
|
||||
if previous.expires_at > now {
|
||||
let secret = derive(
|
||||
credential.secret,
|
||||
Some(&previous.salt),
|
||||
b"cpr-session-v2/refresh",
|
||||
)?;
|
||||
return grant(credential.id, &secret, session).map(Some);
|
||||
}
|
||||
// 已知上一代在重试窗口外再次使用,原子撤销整个会话
|
||||
current = self
|
||||
.store
|
||||
.compare_exchange_session(credential.id, &session, None)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "authentication session revocation"))?;
|
||||
} else {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Err(AdminError::new(
|
||||
AdminErrorKind::Unavailable,
|
||||
"会话正在变更,请重试",
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 备份配置、手动创建、下载与删除用例。
|
||||
//! 备份配置、手动创建、下载与删除用例
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -26,10 +26,10 @@ use crate::ports::{
|
||||
|
||||
use super::map_store_error;
|
||||
|
||||
/// 默认下载地址有效期。
|
||||
/// 默认下载地址有效期
|
||||
const DOWNLOAD_TTL: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
/// API 消费的备份管理服务。
|
||||
/// API 消费的备份管理服务
|
||||
#[async_trait]
|
||||
pub trait BackupService: Send + Sync {
|
||||
async fn load_settings(&self) -> Result<BackupSettings, AdminError>;
|
||||
@@ -76,6 +76,7 @@ pub trait BackupService: Send + Sync {
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultBackupService {
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
repository: Arc<dyn BackupRepository>,
|
||||
object_store: Arc<dyn BackupObjectStorePort>,
|
||||
auth: Arc<dyn AuthStore>,
|
||||
@@ -89,8 +90,10 @@ impl DefaultBackupService {
|
||||
object_store: Arc<dyn BackupObjectStorePort>,
|
||||
auth: Arc<dyn AuthStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> Self {
|
||||
Self {
|
||||
timezone,
|
||||
repository,
|
||||
object_store,
|
||||
auth,
|
||||
@@ -128,22 +131,20 @@ impl BackupService for DefaultBackupService {
|
||||
context: &MutationContext,
|
||||
command: UpdateBackupScheduleCommand,
|
||||
) -> Result<BackupSettings, AdminError> {
|
||||
validate_schedule(&command)?;
|
||||
let schedule =
|
||||
crate::backup::policy::BackupSchedule::parse(&command.cron_expression, self.timezone)
|
||||
.map_err(map_backup_error)?;
|
||||
let next_run_at = if command.schedule_enabled {
|
||||
Some(
|
||||
crate::backup::policy::BackupSchedule::parse(
|
||||
&command.cron_expression,
|
||||
&command.schedule_timezone,
|
||||
)
|
||||
.map_err(map_backup_error)?
|
||||
.next_after(Utc::now())
|
||||
.ok_or_else(|| AdminError::bad_request("无法从计划推导下次执行时间"))?,
|
||||
schedule
|
||||
.next_after(Utc::now())
|
||||
.ok_or_else(|| AdminError::bad_request("无法从计划推导下次执行时间"))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.repository
|
||||
.update_schedule_settings(command, next_run_at, context)
|
||||
.update_schedule_settings(command, next_run_at, context, self.timezone)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup schedule"))
|
||||
}
|
||||
@@ -358,7 +359,7 @@ impl DefaultBackupService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验存储配置;空串视为非法,Secret 缺省表示保留旧值。
|
||||
/// 校验存储配置;空串视为非法,Secret 缺省表示保留旧值
|
||||
fn validate_storage(command: &UpdateBackupStorageCommand) -> Result<(), AdminError> {
|
||||
validate_endpoint(&command.endpoint)?;
|
||||
validate_nonempty("region", &command.region)?;
|
||||
@@ -408,23 +409,10 @@ fn validate_length(field: &str, value: &str, max: usize) -> Result<(), AdminErro
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 校验调度配置并解析 Cron 与时区。
|
||||
fn validate_schedule(command: &UpdateBackupScheduleCommand) -> Result<(), AdminError> {
|
||||
if command.cron_expression.trim().is_empty() {
|
||||
return Err(AdminError::bad_request("cronExpression 不能为空"));
|
||||
}
|
||||
crate::backup::policy::BackupSchedule::parse(
|
||||
&command.cron_expression,
|
||||
&command.schedule_timezone,
|
||||
)
|
||||
.map_err(map_backup_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 备份基础设施错误到既有 `AdminErrorKind` 的唯一边界映射。
|
||||
/// 备份基础设施错误到既有 `AdminErrorKind` 的唯一边界映射
|
||||
fn map_backup_error(error: BackupError) -> AdminError {
|
||||
match error.code() {
|
||||
code::INVALID_CONFIG | code::INVALID_CRON | code::INVALID_TIMEZONE => {
|
||||
code::INVALID_CONFIG | code::INVALID_CRON => {
|
||||
AdminError::bad_request(error.message().to_owned())
|
||||
}
|
||||
code::RECORD_NOT_FOUND => AdminError::not_found(error.message().to_owned()),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Codex 客户端下载信息用例。
|
||||
//! Codex 客户端下载信息用例
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Client API Key 管理用例。
|
||||
//! Client API Key 管理用例
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -25,7 +25,7 @@ use crate::{
|
||||
|
||||
use super::{map_store_error, publish_committed};
|
||||
|
||||
/// API 消费的 Client Key 管理服务。
|
||||
/// API 消费的 Client Key 管理服务
|
||||
#[async_trait]
|
||||
pub trait ClientKeyService: Send + Sync {
|
||||
async fn get(&self, id: &ClientApiKeyId) -> Result<ClientKeyRecord, AdminError>;
|
||||
@@ -157,6 +157,8 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
context: &MutationContext,
|
||||
command: CreateClientKey,
|
||||
) -> Result<CreatedClientKey, AdminError> {
|
||||
crate::model::client_keys::validate_group_ids(&command.group_ids)
|
||||
.map_err(|_| AdminError::invalid("Client API Key 分组不合法"))?;
|
||||
for (provider, profile) in &command.request_profile_overrides {
|
||||
self.providers
|
||||
.require(provider)
|
||||
@@ -199,6 +201,8 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
context: &MutationContext,
|
||||
command: UpdateClientKey,
|
||||
) -> Result<ClientKeyMutation, AdminError> {
|
||||
crate::model::client_keys::validate_group_ids(&command.group_ids)
|
||||
.map_err(|_| AdminError::invalid("Client API Key 分组不合法"))?;
|
||||
for (provider, profile) in &command.request_profile_override_updates {
|
||||
if let Some(profile) = profile {
|
||||
self.providers
|
||||
@@ -300,7 +304,7 @@ fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> {
|
||||
}
|
||||
}
|
||||
|
||||
// 原生与插件创建共用相同的密钥生成规则。
|
||||
// 原生与插件创建共用相同的密钥生成规则
|
||||
pub(super) fn generate_key() -> String {
|
||||
let mut bytes = [0_u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
//! 凭据变更共享的绑定校验、事务结算、失效与提交后观察
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext,
|
||||
accounts::DeleteAccounts,
|
||||
provider_credentials::{
|
||||
AuthorizationMutationTarget, CredentialDeletion, CredentialDeletionResult,
|
||||
CredentialDetails, CredentialMutationResult, CredentialRotationCommit,
|
||||
PendingAuthorizationMutation, PreparedAuthorizationCommit,
|
||||
PreparedAuthorizationCredential, PreparedCredentialImport, PreparedCredentialRotation,
|
||||
ProviderQuotaRequest, StartAuthorization,
|
||||
},
|
||||
},
|
||||
ports::{provider::ProviderAdmin, store::AccountStore},
|
||||
};
|
||||
use gateway_core::{account::ProviderAccountId, routing::ProviderKind, runtime::SnapshotControl};
|
||||
|
||||
use super::{map_store_error, publish_committed};
|
||||
|
||||
pub(super) async fn publish_credentials_and_observe_quota(
|
||||
provider: &std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
snapshot: &dyn SnapshotControl,
|
||||
revision: crate::model::Revision,
|
||||
account_ids: &[ProviderAccountId],
|
||||
request_id: &str,
|
||||
) -> Result<(), AdminError> {
|
||||
provider.account_facts_changed(account_ids).await;
|
||||
publish_committed(snapshot, revision).await?;
|
||||
|
||||
// 凭据已经提交;额度只是可重建的观察,不能拖住管理请求或回滚提交结果
|
||||
let provider = provider.clone();
|
||||
let account_ids = account_ids.to_vec();
|
||||
let request_id = request_id.to_owned();
|
||||
tokio::spawn(async move {
|
||||
for account_id in account_ids {
|
||||
if let Err(error) = provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
request_id,
|
||||
provider = %provider.provider_kind().as_str(),
|
||||
account_id = %account_id.as_str(),
|
||||
quota_error = ?error.kind(),
|
||||
"initial quota observation failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn required_credential(
|
||||
accounts: &dyn AccountStore,
|
||||
provider_kind: &ProviderKind,
|
||||
account_id: &ProviderAccountId,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialDetails, AdminError> {
|
||||
accounts
|
||||
.credential_details(provider_kind, account_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, resource))?
|
||||
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
|
||||
}
|
||||
|
||||
pub(super) async fn required_plugin_credential(
|
||||
accounts: &dyn AccountStore,
|
||||
account_id: &ProviderAccountId,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialDetails, AdminError> {
|
||||
accounts
|
||||
.credential_details_by_id(account_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, resource))?
|
||||
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
|
||||
}
|
||||
|
||||
pub(super) async fn import_proxy_binding(
|
||||
proxies: &dyn crate::ports::proxy::ProxyStore,
|
||||
id: Option<&str>,
|
||||
) -> Result<Option<crate::ports::proxy::ProxyImportReservation>, AdminError> {
|
||||
let Some(id) = id else { return Ok(None) };
|
||||
let reservation = proxies
|
||||
.reserve_import(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
Ok(Some(reservation))
|
||||
}
|
||||
|
||||
pub(super) async fn pending_authorization(
|
||||
accounts: &dyn AccountStore,
|
||||
proxies: &dyn crate::ports::proxy::ProxyStore,
|
||||
provider_kind: &ProviderKind,
|
||||
command: &StartAuthorization,
|
||||
resource: &'static str,
|
||||
) -> Result<PendingAuthorizationMutation, AdminError> {
|
||||
let proxy;
|
||||
let mut proxy_id = None;
|
||||
let target = match &command.reauthorization {
|
||||
Some(account_id) => {
|
||||
proxy = required_credential(accounts, provider_kind, account_id, resource)
|
||||
.await?
|
||||
.credential
|
||||
.outbound_proxy;
|
||||
AuthorizationMutationTarget::Reauthorize {
|
||||
account_id: account_id.clone(),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
use crate::model::proxies::AccountProxySelection;
|
||||
proxy = match &command.outbound_proxy {
|
||||
Some(AccountProxySelection::Url(value)) => Some(value.clone()),
|
||||
Some(AccountProxySelection::Saved(id)) => {
|
||||
let record = proxies
|
||||
.get(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
proxy_id = Some(record.id);
|
||||
Some(record.proxy)
|
||||
}
|
||||
Some(AccountProxySelection::Direct) | None => None,
|
||||
};
|
||||
AuthorizationMutationTarget::Create {
|
||||
name: command.name.clone(),
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(PendingAuthorizationMutation::new(
|
||||
provider_kind.clone(),
|
||||
target,
|
||||
crate::model::provider_credentials::AuthorizationOwnerBinding::from_context(
|
||||
&command.context,
|
||||
),
|
||||
)
|
||||
.with_outbound_proxy(proxy)
|
||||
.with_outbound_proxy_id(proxy_id))
|
||||
}
|
||||
|
||||
pub(super) fn validate_prepared_import(
|
||||
provider_kind: &ProviderKind,
|
||||
prepared: &PreparedCredentialImport,
|
||||
_resource: &'static str,
|
||||
) -> Result<(), AdminError> {
|
||||
if prepared.provider_kind != *provider_kind
|
||||
|| prepared
|
||||
.credentials
|
||||
.iter()
|
||||
.any(|credential| credential.provider_kind != *provider_kind)
|
||||
{
|
||||
return Err(AdminError::conflict("Provider 准备结果与请求范围不一致"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_prepared_rotation(
|
||||
account: &crate::model::accounts::AccountRecord,
|
||||
prepared: &PreparedCredentialRotation,
|
||||
_resource: &'static str,
|
||||
) -> Result<(), AdminError> {
|
||||
validate_prepared_rotation_facts(account, prepared.facts())
|
||||
}
|
||||
|
||||
pub(super) fn validate_prepared_rotation_facts(
|
||||
account: &crate::model::accounts::AccountRecord,
|
||||
facts: &crate::model::provider_credentials::PreparedCredentialRotationFacts,
|
||||
) -> Result<(), AdminError> {
|
||||
if facts.account_id.as_str() != account.id.as_str()
|
||||
|| facts.provider_kind != account.provider_kind
|
||||
{
|
||||
return Err(AdminError::conflict("Provider 准备结果与当前凭据不一致"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn validate_authorization_commit(
|
||||
provider_kind: &ProviderKind,
|
||||
context: &MutationContext,
|
||||
prepared: PreparedAuthorizationCommit,
|
||||
resource: &'static str,
|
||||
) -> Result<PreparedAuthorizationCommit, AdminError> {
|
||||
if prepared.pending.provider_kind() != provider_kind
|
||||
|| !prepared.pending.owner_binding().matches_context(context)
|
||||
{
|
||||
let validation_error = AdminError::conflict("OAuth 授权绑定已失效,请重新发起授权");
|
||||
if let Err(error) = prepared.abort().await {
|
||||
tracing::warn!(
|
||||
resource,
|
||||
settlement_error = %error,
|
||||
"OAuth authorization claim release failed after preparation validation"
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
return Err(validation_error);
|
||||
}
|
||||
let matches_target = match (prepared.pending.target(), &prepared.credential) {
|
||||
(
|
||||
AuthorizationMutationTarget::Create { .. },
|
||||
PreparedAuthorizationCredential::Create(credential),
|
||||
) => credential.provider_kind == *provider_kind,
|
||||
(
|
||||
AuthorizationMutationTarget::Reauthorize { account_id },
|
||||
PreparedAuthorizationCredential::Reauthorize(credential),
|
||||
) => {
|
||||
let facts = credential.facts();
|
||||
facts.provider_kind == *provider_kind && facts.account_id == *account_id
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !matches_target {
|
||||
let validation_error = AdminError::conflict("OAuth 凭据与待提交目标不一致");
|
||||
if let Err(error) = prepared.abort().await {
|
||||
tracing::warn!(
|
||||
resource,
|
||||
settlement_error = %error,
|
||||
"OAuth authorization claim release failed after preparation validation"
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
return Err(validation_error);
|
||||
}
|
||||
Ok(prepared)
|
||||
}
|
||||
|
||||
pub(super) async fn commit_authorization(
|
||||
accounts: &dyn AccountStore,
|
||||
prepared: PreparedAuthorizationCommit,
|
||||
key: crate::model::provider_credentials::AuthorizationReceiptKey,
|
||||
settings: Option<crate::model::accounts::AccountImportSettings>,
|
||||
context: &MutationContext,
|
||||
resource: &'static str,
|
||||
) -> Result<crate::model::provider_credentials::AuthorizationCommitResult, AdminError> {
|
||||
if settings.is_some()
|
||||
&& matches!(
|
||||
&prepared.credential,
|
||||
PreparedAuthorizationCredential::Reauthorize(_)
|
||||
)
|
||||
{
|
||||
prepared.abort().await?;
|
||||
return Err(AdminError::invalid("重新授权不能修改账号设置"));
|
||||
}
|
||||
let crate::model::provider_credentials::AuthorizationCommitSettlement {
|
||||
command,
|
||||
credential_guard,
|
||||
authorization_guard,
|
||||
} = prepared.into_commit(settings, key);
|
||||
match accounts.commit_authorization(command, context).await {
|
||||
Ok(result) => {
|
||||
if let Some(guard) = credential_guard
|
||||
&& result.newly_committed
|
||||
{
|
||||
guard.finish();
|
||||
}
|
||||
if let Some(guard) = authorization_guard
|
||||
&& let Err(error) = guard.commit().await
|
||||
{
|
||||
// 账号与回执已经原子提交,Redis 清理失败不能把确定的成功改写为失败
|
||||
tracing::warn!(resource, settlement_error = %error, "authorization committed but pending cleanup failed");
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
Err(error) => {
|
||||
drop(credential_guard);
|
||||
if let Some(guard) = authorization_guard
|
||||
&& let Err(settlement_error) = guard.abort().await
|
||||
{
|
||||
tracing::warn!(
|
||||
resource,
|
||||
store_error = %error,
|
||||
settlement_error = %settlement_error,
|
||||
"OAuth authorization claim release failed after Store commit failure"
|
||||
);
|
||||
return Err(settlement_error);
|
||||
}
|
||||
Err(map_store_error(error, resource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn commit_credential_rotation(
|
||||
accounts: &dyn AccountStore,
|
||||
prepared: PreparedCredentialRotation,
|
||||
settings: Option<crate::model::accounts::UpdateAccount>,
|
||||
context: &MutationContext,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let (facts, guard) = prepared.into_parts();
|
||||
match accounts
|
||||
.commit_credential_rotation(
|
||||
CredentialRotationCommit {
|
||||
prepared: facts,
|
||||
settings,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
guard.finish();
|
||||
Ok(result)
|
||||
}
|
||||
Err(error) => {
|
||||
drop(guard);
|
||||
Err(map_store_error(error, resource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn commit_credential_refresh(
|
||||
accounts: &dyn AccountStore,
|
||||
prepared: PreparedCredentialRotation,
|
||||
context: &MutationContext,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let (facts, guard) = prepared.into_parts();
|
||||
match accounts
|
||||
.commit_credential_refresh(
|
||||
CredentialRotationCommit {
|
||||
prepared: facts,
|
||||
settings: None,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
guard.finish();
|
||||
Ok(result)
|
||||
}
|
||||
Err(error) => {
|
||||
drop(guard);
|
||||
Err(map_store_error(error, resource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn delete_credentials(
|
||||
accounts: &dyn AccountStore,
|
||||
provider: &dyn ProviderAdmin,
|
||||
command: CredentialDeletion,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialDeletionResult, AdminError> {
|
||||
for account_id in &command.account_ids {
|
||||
required_credential(accounts, provider.provider_kind(), account_id, resource).await?;
|
||||
}
|
||||
let account_ids = command.account_ids;
|
||||
let revision = accounts
|
||||
.delete_accounts(
|
||||
DeleteAccounts {
|
||||
account_ids: account_ids
|
||||
.iter()
|
||||
.map(|account_id| account_id.as_str().to_owned())
|
||||
.collect(),
|
||||
},
|
||||
&command.context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, resource))?;
|
||||
for account_id in &account_ids {
|
||||
provider.account_unavailable(account_id).await;
|
||||
}
|
||||
provider.account_facts_changed(&account_ids).await;
|
||||
Ok(CredentialDeletionResult {
|
||||
config_revision: revision,
|
||||
account_ids,
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 已冻结 Provider 的凭据工作流;Admin 保持事务、审计与发布的唯一所有权。
|
||||
//! 已冻结 Provider 的凭据工作流;Admin 保持事务、审计与发布的唯一所有权
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! 按 Provider 分派凭据管理服务,组合授权与导入流程
|
||||
|
||||
mod flow;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -11,7 +13,7 @@ use crate::{
|
||||
|
||||
pub use flow::ProviderCredentials;
|
||||
|
||||
/// 所有 Provider 共用凭据用例;每次操作从已发布目录冻结自己的管理实现。
|
||||
/// 所有 Provider 共用凭据用例;每次操作从已发布目录冻结自己的管理实现
|
||||
pub struct CredentialsService {
|
||||
providers: ProviderAdminRegistry,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! 管理端口错误到用例错误的统一分类与来源保留
|
||||
|
||||
use crate::model::{AdminError, AdminErrorKind};
|
||||
use crate::ports::store::{AdminStoreError, AdminStoreErrorKind};
|
||||
|
||||
pub(super) fn map_store_error(error: AdminStoreError, resource: &'static str) -> AdminError {
|
||||
let kind = match error.kind() {
|
||||
AdminStoreErrorKind::Invalid => AdminErrorKind::Invalid,
|
||||
AdminStoreErrorKind::NotFound => AdminErrorKind::NotFound,
|
||||
AdminStoreErrorKind::StaleRevision
|
||||
| AdminStoreErrorKind::DuplicateName
|
||||
| AdminStoreErrorKind::Conflict => AdminErrorKind::Conflict,
|
||||
AdminStoreErrorKind::Unavailable => AdminErrorKind::Unavailable,
|
||||
};
|
||||
let message = match kind {
|
||||
AdminErrorKind::Invalid => "请求参数不合法",
|
||||
AdminErrorKind::NotFound => "请求的资源不存在",
|
||||
AdminErrorKind::Conflict => "当前资源状态冲突,请刷新后重试",
|
||||
AdminErrorKind::Unavailable => "依赖服务暂不可用",
|
||||
_ => "服务内部错误",
|
||||
};
|
||||
AdminError::new(kind, message).with_source(OperationFailure {
|
||||
operation: resource,
|
||||
source: error.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn map_provider_error(
|
||||
error: crate::ports::provider::ProviderAdminError,
|
||||
resource: &'static str,
|
||||
) -> AdminError {
|
||||
use crate::ports::provider::ProviderAdminErrorKind;
|
||||
|
||||
let kind = match error.kind() {
|
||||
ProviderAdminErrorKind::Invalid => AdminErrorKind::Invalid,
|
||||
ProviderAdminErrorKind::Unsupported => AdminErrorKind::Invalid,
|
||||
ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound,
|
||||
ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict,
|
||||
ProviderAdminErrorKind::Ambiguous => AdminErrorKind::UpstreamResultUnknown,
|
||||
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
|
||||
ProviderAdminErrorKind::CredentialRefreshRequired => AdminErrorKind::Unavailable,
|
||||
ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway,
|
||||
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
|
||||
};
|
||||
let message = match kind {
|
||||
AdminErrorKind::Invalid => "Provider 请求不合法",
|
||||
AdminErrorKind::NotFound => "Provider 资源不存在",
|
||||
AdminErrorKind::Conflict => "Provider 资源状态冲突,请刷新后重试",
|
||||
AdminErrorKind::BadGateway => "上游服务请求失败",
|
||||
AdminErrorKind::UpstreamResultUnknown => "上游执行结果未知,请刷新状态后再决定是否重试",
|
||||
AdminErrorKind::Unavailable => "Provider 服务暂不可用",
|
||||
AdminErrorKind::Internal => "服务内部错误",
|
||||
_ => "Provider 操作失败",
|
||||
};
|
||||
AdminError::new(kind, error.public_message().unwrap_or(message)).with_source(OperationFailure {
|
||||
operation: resource,
|
||||
source: error.into(),
|
||||
})
|
||||
}
|
||||
|
||||
// 用例的操作名补充端口资源上下文,底层分类与原始原因仍由来源持有
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("admin operation failed: {operation}")]
|
||||
struct OperationFailure {
|
||||
operation: &'static str,
|
||||
source: gateway_core::error::ErrorSource,
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 导入队列由 Host 管理生命周期,HTTP 只负责接受输入和读取快照。
|
||||
//! 导入队列由 Host 管理生命周期,HTTP 只负责接受输入和读取快照
|
||||
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
@@ -100,7 +100,7 @@ impl Entry {
|
||||
{
|
||||
self.finished_at = Some(Utc::now());
|
||||
self.finished_instant = Some(Instant::now());
|
||||
// 终态仅保留结果,连已清空的输入槽位分配也一并释放。
|
||||
// 终态仅保留结果,连已清空的输入槽位分配也一并释放
|
||||
self.inputs = Vec::new();
|
||||
}
|
||||
}
|
||||
@@ -163,7 +163,7 @@ impl DefaultImportTasksService {
|
||||
.tasks
|
||||
.iter()
|
||||
.position(|entry| entry.inputs.iter().any(Option::is_some))?;
|
||||
// 轮转任务,避免大批次独占后续全部执行机会。
|
||||
// 轮转任务,避免大批次独占后续全部执行机会
|
||||
let mut entry = registry.tasks.remove(task_index)?;
|
||||
let index = entry.inputs.iter().position(Option::is_some)?;
|
||||
let input = entry.inputs[index].take()?;
|
||||
@@ -192,7 +192,7 @@ impl DefaultImportTasksService {
|
||||
item.account_ids = result.credential_ids;
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
// 存储或发布异常也可能发生在提交之后;不自动重放可能轮换 RT 的操作。
|
||||
// 存储或发布异常也可能发生在提交之后;不自动重放可能轮换 RT 的操作
|
||||
item.status = if matches!(
|
||||
error.kind(),
|
||||
AdminErrorKind::UpstreamResultUnknown
|
||||
@@ -335,7 +335,7 @@ impl DaemonTask for ImportTaskWorker {
|
||||
fn run(&self, cancellation: CancellationToken) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move {
|
||||
let mut active = FuturesUnordered::new();
|
||||
// 固定一个取消 future,避免循环反复注册中立 CancellationToken 的 waiter。
|
||||
// 固定一个取消 future,避免循环反复注册中立 CancellationToken 的 waiter
|
||||
let cancelled = cancellation.cancelled();
|
||||
tokio::pin!(cancelled);
|
||||
let mut cleanup = tokio::time::interval(Duration::from_secs(60));
|
||||
@@ -361,7 +361,7 @@ impl DaemonTask for ImportTaskWorker {
|
||||
_ = cleanup.tick() => { drop(self.0.registry()); },
|
||||
}
|
||||
}
|
||||
// Host 提供总关闭预算;此处让已发出的交换与入库尽量完成。
|
||||
// Host 提供总关闭预算;此处让已发出的交换与入库尽量完成
|
||||
while active.next().await.is_some() {}
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
//! Key 自助查询从统一会话或只读 Key 校验取得范围,复用现有观测和额度账本。
|
||||
//! Key 自助查询从统一会话或只读 Key 校验取得范围,复用现有观测和额度账本
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::{
|
||||
AuthService, SystemService,
|
||||
@@ -16,7 +15,7 @@ use crate::{
|
||||
KeyUsageRecordsQuery,
|
||||
},
|
||||
observability::{
|
||||
OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery, china_day_start,
|
||||
Granularity, OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery,
|
||||
},
|
||||
system::SystemVersion,
|
||||
},
|
||||
@@ -34,10 +33,10 @@ use super::{map_store_error, observability::health_timeline_at};
|
||||
|
||||
#[async_trait]
|
||||
pub trait KeyUsageService: Send + Sync {
|
||||
/// 验证 Key 并只读查询当前额度,不记录 Key 使用或执行推理准入。
|
||||
/// 验证 Key 并只读查询当前额度,不记录 Key 使用或执行推理准入
|
||||
async fn budget(&self, plaintext: &str) -> Result<Option<ClientBudgetStatus>, AdminError>;
|
||||
|
||||
/// 使用 Core 已认证的宿主身份查询额度,不接收插件自报的 Key ID。
|
||||
/// 使用 Core 已认证的宿主身份查询额度,不接收插件自报的 Key ID
|
||||
async fn budget_for_client(
|
||||
&self,
|
||||
id: &ClientApiKeyId,
|
||||
@@ -62,6 +61,7 @@ pub trait KeyUsageService: Send + Sync {
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultKeyUsageService {
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
auth: Arc<dyn AuthService>,
|
||||
verifier: Arc<dyn ClientKeyVerifier>,
|
||||
keys: Arc<dyn ClientKeyStore>,
|
||||
@@ -76,8 +76,10 @@ impl DefaultKeyUsageService {
|
||||
keys: Arc<dyn ClientKeyStore>,
|
||||
observations: Arc<dyn ObservabilityStore>,
|
||||
system: Arc<dyn SystemService>,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> Self {
|
||||
Self {
|
||||
timezone,
|
||||
auth,
|
||||
verifier,
|
||||
keys,
|
||||
@@ -155,7 +157,7 @@ impl KeyUsageService for DefaultKeyUsageService {
|
||||
let Some(id) = self.key_id(session_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
// 明文只按服务端会话绑定的 Key 读取,禁用或删除后不再提供配置。
|
||||
// 明文只按服务端会话绑定的 Key 读取,禁用或删除后不再提供配置
|
||||
self.keys
|
||||
.reveal_client_key(&id)
|
||||
.await
|
||||
@@ -181,24 +183,33 @@ impl KeyUsageService for DefaultKeyUsageService {
|
||||
return Ok(None);
|
||||
};
|
||||
let filter = usage_filter(&id, query.model);
|
||||
let now = Utc::now();
|
||||
// 健康条始终展示北京时间今日,不随历史范围或模型筛选改变。
|
||||
let now = query.range.end;
|
||||
// 健康条始终展示部署时区今日,不随历史范围或模型筛选改变
|
||||
let today = TimeRange {
|
||||
start: china_day_start(now),
|
||||
start: self
|
||||
.timezone
|
||||
.day_start(now)
|
||||
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?,
|
||||
end: now,
|
||||
};
|
||||
let (overview, trend, health_points) = futures::try_join!(
|
||||
self.observations.usage_summary(query.range, filter.clone()),
|
||||
self.observations.usage_trend(query.range, filter),
|
||||
self.observations
|
||||
.usage_trend(today, usage_filter(&id, None)),
|
||||
let (observation, health_points) = futures::try_join!(
|
||||
self.observations.usage_insights_observation(
|
||||
query.range,
|
||||
filter,
|
||||
Granularity::for_range(query.range)
|
||||
),
|
||||
self.observations.usage_trend(
|
||||
today,
|
||||
usage_filter(&id, None),
|
||||
Granularity::for_range(today)
|
||||
),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "key usage overview"))?;
|
||||
Ok(Some(KeyUsageOverview {
|
||||
key,
|
||||
overview,
|
||||
trend,
|
||||
health_timeline: health_timeline_at(&health_points, now),
|
||||
overview: observation.overview,
|
||||
trend: observation.trend,
|
||||
health_timeline: health_timeline_at(&health_points, now, self.timezone)?,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 管理控制面的用例实现。
|
||||
//! 管理控制面的用例实现
|
||||
|
||||
pub mod account_groups;
|
||||
pub mod accounts;
|
||||
@@ -19,438 +19,16 @@ pub mod proxies;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, AdminErrorKind, MutationContext,
|
||||
accounts::DeleteAccounts,
|
||||
provider_credentials::{
|
||||
AuthorizationMutationTarget, CredentialDeletion, CredentialDeletionResult,
|
||||
CredentialDetails, CredentialMutationResult, CredentialRotationCommit,
|
||||
PendingAuthorizationMutation, PreparedAuthorizationCommit,
|
||||
PreparedAuthorizationCredential, PreparedCredentialImport, PreparedCredentialRotation,
|
||||
ProviderQuotaRequest, StartAuthorization,
|
||||
},
|
||||
},
|
||||
ports::{
|
||||
provider::ProviderAdmin,
|
||||
store::{AccountStore, AdminStoreError, AdminStoreErrorKind},
|
||||
},
|
||||
mod credential_mutation;
|
||||
mod error;
|
||||
mod publication;
|
||||
|
||||
use credential_mutation::{
|
||||
commit_authorization, commit_credential_refresh, commit_credential_rotation,
|
||||
delete_credentials, import_proxy_binding, pending_authorization,
|
||||
publish_credentials_and_observe_quota, required_credential, required_plugin_credential,
|
||||
validate_authorization_commit, validate_prepared_import, validate_prepared_rotation,
|
||||
validate_prepared_rotation_facts,
|
||||
};
|
||||
use gateway_core::{
|
||||
account::ProviderAccountId,
|
||||
routing::{ConfigRevision, ProviderKind},
|
||||
runtime::SnapshotControl,
|
||||
};
|
||||
|
||||
fn map_store_error(error: AdminStoreError, resource: &'static str) -> AdminError {
|
||||
let kind = match error.kind() {
|
||||
AdminStoreErrorKind::Invalid => AdminErrorKind::Invalid,
|
||||
AdminStoreErrorKind::NotFound => AdminErrorKind::NotFound,
|
||||
AdminStoreErrorKind::StaleRevision
|
||||
| AdminStoreErrorKind::DuplicateName
|
||||
| AdminStoreErrorKind::Conflict => AdminErrorKind::Conflict,
|
||||
AdminStoreErrorKind::Unavailable => AdminErrorKind::Unavailable,
|
||||
};
|
||||
tracing::warn!(resource, error_kind = ?error.kind(), "admin store operation failed");
|
||||
let message = match kind {
|
||||
AdminErrorKind::Invalid => "请求参数不合法",
|
||||
AdminErrorKind::NotFound => "请求的资源不存在",
|
||||
AdminErrorKind::Conflict => "当前资源状态冲突,请刷新后重试",
|
||||
AdminErrorKind::Unavailable => "依赖服务暂不可用",
|
||||
_ => "服务内部错误",
|
||||
};
|
||||
AdminError::new(kind, message)
|
||||
}
|
||||
|
||||
fn map_provider_error(
|
||||
error: crate::ports::provider::ProviderAdminError,
|
||||
resource: &'static str,
|
||||
) -> AdminError {
|
||||
use crate::ports::provider::ProviderAdminErrorKind;
|
||||
|
||||
let kind = match error.kind() {
|
||||
ProviderAdminErrorKind::Invalid => AdminErrorKind::Invalid,
|
||||
ProviderAdminErrorKind::Unsupported => AdminErrorKind::Invalid,
|
||||
ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound,
|
||||
ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict,
|
||||
ProviderAdminErrorKind::Ambiguous => AdminErrorKind::UpstreamResultUnknown,
|
||||
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
|
||||
ProviderAdminErrorKind::CredentialRefreshRequired => AdminErrorKind::Unavailable,
|
||||
ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway,
|
||||
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
|
||||
};
|
||||
tracing::warn!(resource, error_kind = ?error.kind(), "admin provider operation failed");
|
||||
let message = match kind {
|
||||
AdminErrorKind::Invalid => "Provider 请求不合法",
|
||||
AdminErrorKind::NotFound => "Provider 资源不存在",
|
||||
AdminErrorKind::Conflict => "Provider 资源状态冲突,请刷新后重试",
|
||||
AdminErrorKind::BadGateway => "上游服务请求失败",
|
||||
AdminErrorKind::UpstreamResultUnknown => "上游执行结果未知,请刷新状态后再决定是否重试",
|
||||
AdminErrorKind::Unavailable => "Provider 服务暂不可用",
|
||||
AdminErrorKind::Internal => "服务内部错误",
|
||||
_ => "Provider 操作失败",
|
||||
};
|
||||
AdminError::new(kind, error.public_message().unwrap_or(message))
|
||||
}
|
||||
|
||||
async fn publish_committed(
|
||||
snapshot: &dyn SnapshotControl,
|
||||
revision: crate::model::Revision,
|
||||
) -> Result<(), AdminError> {
|
||||
let revision = ConfigRevision::new(revision.get())
|
||||
.map_err(|_| AdminError::internal("已提交的配置版本不合法"))?;
|
||||
snapshot.publish_committed(revision).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn publish_credentials_and_observe_quota(
|
||||
provider: &std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
snapshot: &dyn SnapshotControl,
|
||||
revision: crate::model::Revision,
|
||||
account_ids: &[ProviderAccountId],
|
||||
request_id: &str,
|
||||
) -> Result<(), AdminError> {
|
||||
provider.account_facts_changed(account_ids).await;
|
||||
publish_committed(snapshot, revision).await?;
|
||||
|
||||
// 凭据已经提交;额度只是可重建的观察,不能拖住管理请求或回滚提交结果。
|
||||
let provider = provider.clone();
|
||||
let account_ids = account_ids.to_vec();
|
||||
let request_id = request_id.to_owned();
|
||||
tokio::spawn(async move {
|
||||
for account_id in account_ids {
|
||||
if let Err(error) = provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
request_id,
|
||||
provider = %provider.provider_kind().as_str(),
|
||||
account_id = %account_id.as_str(),
|
||||
quota_error = ?error.kind(),
|
||||
"initial quota observation failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn required_credential(
|
||||
accounts: &dyn AccountStore,
|
||||
provider_kind: &ProviderKind,
|
||||
account_id: &ProviderAccountId,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialDetails, AdminError> {
|
||||
accounts
|
||||
.credential_details(provider_kind, account_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, resource))?
|
||||
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
|
||||
}
|
||||
|
||||
async fn required_plugin_credential(
|
||||
accounts: &dyn AccountStore,
|
||||
account_id: &ProviderAccountId,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialDetails, AdminError> {
|
||||
accounts
|
||||
.credential_details_by_id(account_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, resource))?
|
||||
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
|
||||
}
|
||||
|
||||
async fn import_proxy_binding(
|
||||
proxies: &dyn crate::ports::proxy::ProxyStore,
|
||||
id: Option<&str>,
|
||||
) -> Result<Option<crate::ports::proxy::ProxyImportReservation>, AdminError> {
|
||||
let Some(id) = id else { return Ok(None) };
|
||||
let reservation = proxies
|
||||
.reserve_import(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
Ok(Some(reservation))
|
||||
}
|
||||
|
||||
async fn pending_authorization(
|
||||
accounts: &dyn AccountStore,
|
||||
proxies: &dyn crate::ports::proxy::ProxyStore,
|
||||
provider_kind: &ProviderKind,
|
||||
command: &StartAuthorization,
|
||||
resource: &'static str,
|
||||
) -> Result<PendingAuthorizationMutation, AdminError> {
|
||||
let proxy;
|
||||
let mut proxy_id = None;
|
||||
let target = match &command.reauthorization {
|
||||
Some(account_id) => {
|
||||
proxy = required_credential(accounts, provider_kind, account_id, resource)
|
||||
.await?
|
||||
.credential
|
||||
.outbound_proxy;
|
||||
AuthorizationMutationTarget::Reauthorize {
|
||||
account_id: account_id.clone(),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
use crate::model::proxies::AccountProxySelection;
|
||||
proxy = match &command.outbound_proxy {
|
||||
Some(AccountProxySelection::Url(value)) => Some(value.clone()),
|
||||
Some(AccountProxySelection::Saved(id)) => {
|
||||
let record = proxies
|
||||
.get(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
proxy_id = Some(record.id);
|
||||
Some(record.proxy)
|
||||
}
|
||||
Some(AccountProxySelection::Direct) | None => None,
|
||||
};
|
||||
AuthorizationMutationTarget::Create {
|
||||
name: command.name.clone(),
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(PendingAuthorizationMutation::new(
|
||||
provider_kind.clone(),
|
||||
target,
|
||||
crate::model::provider_credentials::AuthorizationOwnerBinding::from_context(
|
||||
&command.context,
|
||||
),
|
||||
)
|
||||
.with_outbound_proxy(proxy)
|
||||
.with_outbound_proxy_id(proxy_id))
|
||||
}
|
||||
|
||||
fn validate_prepared_import(
|
||||
provider_kind: &ProviderKind,
|
||||
prepared: &PreparedCredentialImport,
|
||||
_resource: &'static str,
|
||||
) -> Result<(), AdminError> {
|
||||
if prepared.provider_kind != *provider_kind
|
||||
|| prepared
|
||||
.credentials
|
||||
.iter()
|
||||
.any(|credential| credential.provider_kind != *provider_kind)
|
||||
{
|
||||
return Err(AdminError::conflict("Provider 准备结果与请求范围不一致"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_prepared_rotation(
|
||||
account: &crate::model::accounts::AccountRecord,
|
||||
prepared: &PreparedCredentialRotation,
|
||||
_resource: &'static str,
|
||||
) -> Result<(), AdminError> {
|
||||
validate_prepared_rotation_facts(account, prepared.facts())
|
||||
}
|
||||
|
||||
fn validate_prepared_rotation_facts(
|
||||
account: &crate::model::accounts::AccountRecord,
|
||||
facts: &crate::model::provider_credentials::PreparedCredentialRotationFacts,
|
||||
) -> Result<(), AdminError> {
|
||||
if facts.account_id.as_str() != account.id.as_str()
|
||||
|| facts.provider_kind != account.provider_kind
|
||||
{
|
||||
return Err(AdminError::conflict("Provider 准备结果与当前凭据不一致"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_authorization_commit(
|
||||
provider_kind: &ProviderKind,
|
||||
context: &MutationContext,
|
||||
prepared: PreparedAuthorizationCommit,
|
||||
resource: &'static str,
|
||||
) -> Result<PreparedAuthorizationCommit, AdminError> {
|
||||
if prepared.pending.provider_kind() != provider_kind
|
||||
|| !prepared.pending.owner_binding().matches_context(context)
|
||||
{
|
||||
let validation_error = AdminError::conflict("OAuth 授权绑定已失效,请重新发起授权");
|
||||
if let Err(error) = prepared.abort().await {
|
||||
tracing::warn!(
|
||||
resource,
|
||||
settlement_error = %error,
|
||||
"OAuth authorization claim release failed after preparation validation"
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
return Err(validation_error);
|
||||
}
|
||||
let matches_target = match (prepared.pending.target(), &prepared.credential) {
|
||||
(
|
||||
AuthorizationMutationTarget::Create { .. },
|
||||
PreparedAuthorizationCredential::Create(credential),
|
||||
) => credential.provider_kind == *provider_kind,
|
||||
(
|
||||
AuthorizationMutationTarget::Reauthorize { account_id },
|
||||
PreparedAuthorizationCredential::Reauthorize(credential),
|
||||
) => {
|
||||
let facts = credential.facts();
|
||||
facts.provider_kind == *provider_kind && facts.account_id == *account_id
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !matches_target {
|
||||
let validation_error = AdminError::conflict("OAuth 凭据与待提交目标不一致");
|
||||
if let Err(error) = prepared.abort().await {
|
||||
tracing::warn!(
|
||||
resource,
|
||||
settlement_error = %error,
|
||||
"OAuth authorization claim release failed after preparation validation"
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
return Err(validation_error);
|
||||
}
|
||||
Ok(prepared)
|
||||
}
|
||||
|
||||
async fn commit_authorization(
|
||||
accounts: &dyn AccountStore,
|
||||
prepared: PreparedAuthorizationCommit,
|
||||
key: crate::model::provider_credentials::AuthorizationReceiptKey,
|
||||
settings: Option<crate::model::accounts::AccountImportSettings>,
|
||||
context: &MutationContext,
|
||||
resource: &'static str,
|
||||
) -> Result<crate::model::provider_credentials::AuthorizationCommitResult, AdminError> {
|
||||
if settings.is_some()
|
||||
&& matches!(
|
||||
&prepared.credential,
|
||||
PreparedAuthorizationCredential::Reauthorize(_)
|
||||
)
|
||||
{
|
||||
prepared.abort().await?;
|
||||
return Err(AdminError::invalid("重新授权不能修改账号设置"));
|
||||
}
|
||||
let crate::model::provider_credentials::AuthorizationCommitSettlement {
|
||||
command,
|
||||
credential_guard,
|
||||
authorization_guard,
|
||||
} = prepared.into_commit(settings, key);
|
||||
match accounts.commit_authorization(command, context).await {
|
||||
Ok(result) => {
|
||||
if let Some(guard) = credential_guard
|
||||
&& result.newly_committed
|
||||
{
|
||||
guard.finish();
|
||||
}
|
||||
if let Some(guard) = authorization_guard
|
||||
&& let Err(error) = guard.commit().await
|
||||
{
|
||||
// 账号与回执已经原子提交,Redis 清理失败不能把确定的成功改写为失败。
|
||||
tracing::warn!(resource, settlement_error = %error, "authorization committed but pending cleanup failed");
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
Err(error) => {
|
||||
drop(credential_guard);
|
||||
if let Some(guard) = authorization_guard
|
||||
&& let Err(settlement_error) = guard.abort().await
|
||||
{
|
||||
tracing::warn!(
|
||||
resource,
|
||||
store_error = %error,
|
||||
settlement_error = %settlement_error,
|
||||
"OAuth authorization claim release failed after Store commit failure"
|
||||
);
|
||||
return Err(settlement_error);
|
||||
}
|
||||
Err(map_store_error(error, resource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn commit_credential_rotation(
|
||||
accounts: &dyn AccountStore,
|
||||
prepared: PreparedCredentialRotation,
|
||||
settings: Option<crate::model::accounts::UpdateAccount>,
|
||||
context: &MutationContext,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let (facts, guard) = prepared.into_parts();
|
||||
match accounts
|
||||
.commit_credential_rotation(
|
||||
CredentialRotationCommit {
|
||||
prepared: facts,
|
||||
settings,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
guard.finish();
|
||||
Ok(result)
|
||||
}
|
||||
Err(error) => {
|
||||
drop(guard);
|
||||
Err(map_store_error(error, resource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn commit_credential_refresh(
|
||||
accounts: &dyn AccountStore,
|
||||
prepared: PreparedCredentialRotation,
|
||||
context: &MutationContext,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let (facts, guard) = prepared.into_parts();
|
||||
match accounts
|
||||
.commit_credential_refresh(
|
||||
CredentialRotationCommit {
|
||||
prepared: facts,
|
||||
settings: None,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
guard.finish();
|
||||
Ok(result)
|
||||
}
|
||||
Err(error) => {
|
||||
drop(guard);
|
||||
Err(map_store_error(error, resource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_credentials(
|
||||
accounts: &dyn AccountStore,
|
||||
provider: &dyn ProviderAdmin,
|
||||
command: CredentialDeletion,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialDeletionResult, AdminError> {
|
||||
for account_id in &command.account_ids {
|
||||
required_credential(accounts, provider.provider_kind(), account_id, resource).await?;
|
||||
}
|
||||
let account_ids = command.account_ids;
|
||||
let revision = accounts
|
||||
.delete_accounts(
|
||||
DeleteAccounts {
|
||||
account_ids: account_ids
|
||||
.iter()
|
||||
.map(|account_id| account_id.as_str().to_owned())
|
||||
.collect(),
|
||||
},
|
||||
&command.context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, resource))?;
|
||||
for account_id in &account_ids {
|
||||
provider.account_unavailable(account_id).await;
|
||||
}
|
||||
provider.account_facts_changed(&account_ids).await;
|
||||
Ok(CredentialDeletionResult {
|
||||
config_revision: revision,
|
||||
account_ids,
|
||||
})
|
||||
}
|
||||
use error::{map_provider_error, map_store_error};
|
||||
use publication::publish_committed;
|
||||
|
||||
+221
-209
@@ -1,15 +1,13 @@
|
||||
//! 观测查询、趋势、费用覆盖与健康阈值规则。
|
||||
//! 观测查询、趋势、费用覆盖与健康阈值规则
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap, btree_map::Entry},
|
||||
str::FromStr,
|
||||
sync::Arc,
|
||||
time::{Duration as StdDuration, Instant},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Duration, Timelike as _, Utc};
|
||||
use futures::TryStreamExt;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use gateway_core::{account::ProviderAccountId, routing::ProviderKind};
|
||||
|
||||
use crate::{
|
||||
@@ -17,84 +15,33 @@ use crate::{
|
||||
AdminError,
|
||||
observability::{
|
||||
CostCoverage, CurrencyCost, DashboardAccountUsage, DashboardCapacity,
|
||||
DashboardPeriodMetrics, DashboardResult, DecimalAmount, DiagnosticDimension,
|
||||
DiagnosticsItem, DiagnosticsResult, HealthStatus, HealthTimeline, HealthTimelinePoint,
|
||||
OpsErrorPage, OpsErrorQuery, ProviderBillingInput, RequestMetricPoint, RequestMetrics,
|
||||
TimeRange, Trend, TrendKind, TrendPoint, TrendSummary, UsageBilling,
|
||||
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageInsights, UsageInsightsCost,
|
||||
UsageInsightsCostPoint, UsageInsightsHealth, UsageInsightsHealthPoint,
|
||||
UsageInsightsPerformance, UsageInsightsPerformancePoint, UsageOverview, UsagePage,
|
||||
UsageQuery, UsageSummary, china_day_start,
|
||||
DashboardPeriodMetrics, DashboardQuery, DashboardResult, DecimalAmount,
|
||||
DiagnosticDimension, DiagnosticsItem, DiagnosticsResult, Granularity, HealthStatus,
|
||||
HealthTimeline, HealthTimelinePoint, OpsErrorPage, OpsErrorQuery, ProviderBillingInput,
|
||||
RequestMetricPoint, RequestMetrics, TimeRange, Trend, TrendKind, TrendPoint,
|
||||
TrendSummary, UsageBilling, UsageBillingProjection, UsageCalculatedBillingFact,
|
||||
UsageDetail, UsageFilter, UsageInsights, UsageInsightsCost, UsageInsightsCostPoint,
|
||||
UsageInsightsHealth, UsageInsightsHealthPoint, UsageInsightsPerformance,
|
||||
UsageInsightsPerformancePoint, UsageOverview, UsagePage, UsageQuery, UsageSummary,
|
||||
},
|
||||
provider_credentials::ProviderQuotaRequest,
|
||||
},
|
||||
ports::{
|
||||
provider::{ProviderAdminErrorKind, ProviderAdminRegistry},
|
||||
store::{AccountStore, ObservabilityStore, SettingsStore, UsageCalculatedBillingStream},
|
||||
store::{AccountStore, ObservabilityStore, SettingsStore, UsageBillingProjector},
|
||||
},
|
||||
};
|
||||
|
||||
use super::{map_provider_error, map_store_error};
|
||||
|
||||
const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15;
|
||||
const HEALTH_TIMELINE_SLOTS: i64 = 24 * 4;
|
||||
const HEALTH_TIMELINE_MIN_SAMPLE_SIZE: u64 = 10;
|
||||
const HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD: u64 = 3;
|
||||
const HEALTH_TIMELINE_STABLE_RELIABILITY: f64 = 99.0;
|
||||
const DASHBOARD_QUERY_BUCKET_SECONDS: i64 = 2;
|
||||
const DASHBOARD_QUERY_CACHE_RETENTION: StdDuration = StdDuration::from_secs(2);
|
||||
const HEALTH_TIMELINE_STABLE_RELIABILITY: f64 = 95.0;
|
||||
mod store_cache;
|
||||
pub(crate) use store_cache::CachedObservabilityStore;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
struct DashboardQueryKey {
|
||||
start: DateTime<Utc>,
|
||||
end_bucket: i64,
|
||||
}
|
||||
|
||||
impl DashboardQueryKey {
|
||||
fn new(range: TimeRange) -> Self {
|
||||
Self {
|
||||
start: range.start,
|
||||
end_bucket: range
|
||||
.end
|
||||
.timestamp()
|
||||
.div_euclid(DASHBOARD_QUERY_BUCKET_SECONDS),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DashboardQueryEntry {
|
||||
created_at: Instant,
|
||||
result: Arc<tokio::sync::OnceCell<Result<DashboardResult, AdminError>>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DashboardQueryCache {
|
||||
entries: tokio::sync::Mutex<HashMap<DashboardQueryKey, DashboardQueryEntry>>,
|
||||
}
|
||||
|
||||
impl DashboardQueryCache {
|
||||
async fn result_cell(
|
||||
&self,
|
||||
key: DashboardQueryKey,
|
||||
) -> Arc<tokio::sync::OnceCell<Result<DashboardResult, AdminError>>> {
|
||||
let now = Instant::now();
|
||||
let mut entries = self.entries.lock().await;
|
||||
entries.retain(|_, entry| {
|
||||
now.duration_since(entry.created_at) < DASHBOARD_QUERY_CACHE_RETENTION
|
||||
});
|
||||
Arc::clone(
|
||||
&entries
|
||||
.entry(key)
|
||||
.or_insert_with(|| DashboardQueryEntry {
|
||||
created_at: now,
|
||||
result: Arc::new(tokio::sync::OnceCell::new()),
|
||||
})
|
||||
.result,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// API 消费的观测控制面服务。
|
||||
/// API 消费的观测控制面服务
|
||||
#[async_trait]
|
||||
pub trait ObservabilityService: Send + Sync {
|
||||
async fn dashboard_summary(
|
||||
@@ -126,11 +73,11 @@ pub trait ObservabilityService: Send + Sync {
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultObservabilityService {
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
store: Arc<dyn ObservabilityStore>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
dashboard_queries: DashboardQueryCache,
|
||||
}
|
||||
|
||||
impl DefaultObservabilityService {
|
||||
@@ -140,13 +87,14 @@ impl DefaultObservabilityService {
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> Self {
|
||||
Self {
|
||||
timezone,
|
||||
store,
|
||||
accounts,
|
||||
settings,
|
||||
providers,
|
||||
dashboard_queries: DashboardQueryCache::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,12 +103,13 @@ impl DefaultObservabilityService {
|
||||
range: TimeRange,
|
||||
) -> Result<DashboardResult, AdminError> {
|
||||
let observed_at = Utc::now();
|
||||
let (mut observation, settings, runtime_slots) = futures::try_join!(
|
||||
self.store.dashboard_summary(range, observed_at),
|
||||
let (mut observation, settings) = futures::try_join!(
|
||||
self.store
|
||||
.dashboard_summary(DashboardQuery::new(range), observed_at),
|
||||
self.settings.load_runtime_settings(),
|
||||
self.store.dashboard_runtime_slots(observed_at),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "dashboard"))?;
|
||||
let runtime_slots = observation.runtime_slots;
|
||||
self.enrich_list_records(&mut observation.recent_requests);
|
||||
self.enrich_dashboard_quotas(&mut observation.account_usage)
|
||||
.await;
|
||||
@@ -169,14 +118,20 @@ impl DefaultObservabilityService {
|
||||
.providers
|
||||
.plan_type_display(&account.provider_kind, account.plan_type.as_deref());
|
||||
}
|
||||
let today_start = china_day_start(observation.range.end);
|
||||
let yesterday_start = today_start - Duration::days(1);
|
||||
let today_start = self
|
||||
.timezone
|
||||
.day_start(observation.range.end)
|
||||
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?;
|
||||
let yesterday_start = self
|
||||
.timezone
|
||||
.days_before(observation.range.end, 1)
|
||||
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?;
|
||||
let today =
|
||||
dashboard_period_metrics(&observation.trend, today_start, observation.range.end);
|
||||
let yesterday = dashboard_period_metrics(&observation.trend, yesterday_start, today_start);
|
||||
let total_billing_usd = observation.totals.billing_usd.clone();
|
||||
let total_cached_token_rate = rate_or_zero(
|
||||
observation.totals.cached_tokens,
|
||||
observation.totals.cached_input_tokens,
|
||||
observation.totals.input_tokens,
|
||||
);
|
||||
let (first_token_latency_sum_ms, first_token_latency_count) = observation
|
||||
@@ -190,12 +145,17 @@ impl DefaultObservabilityService {
|
||||
});
|
||||
let average_first_token_latency_ms =
|
||||
average(first_token_latency_sum_ms, first_token_latency_count);
|
||||
let trend = trend(TrendKind::Usage, observation.trend.clone())?;
|
||||
let health_timeline = health_timeline_at(&observation.trend, Utc::now());
|
||||
let trend = trend(
|
||||
TrendKind::Usage,
|
||||
observation.trend.clone(),
|
||||
observation.analytics.clone(),
|
||||
)?;
|
||||
let health_timeline =
|
||||
health_timeline_at(&observation.trend, observation.range.end, self.timezone)?;
|
||||
let wire_profiles = self
|
||||
.providers
|
||||
.dashboard_wire_profiles(&settings.request_profiles);
|
||||
let max_concurrent_per_account = u64::from(settings.max_concurrent_per_account);
|
||||
let max_concurrent_per_account = u64::from(settings.values.max_concurrent_per_account);
|
||||
let (inherited_accounts, overridden_slots) = runtime_slots
|
||||
.as_ref()
|
||||
.map_or((observation.provider_accounts.normal, 0), |slots| {
|
||||
@@ -237,15 +197,12 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
range: TimeRange,
|
||||
kind: TrendKind,
|
||||
) -> Result<DashboardResult, AdminError> {
|
||||
let cell = self
|
||||
.dashboard_queries
|
||||
.result_cell(DashboardQueryKey::new(range))
|
||||
.await;
|
||||
let mut result = cell
|
||||
.get_or_init(|| self.load_dashboard_summary(range))
|
||||
.await
|
||||
.clone()?;
|
||||
result.trend = trend(kind, result.observation.trend.clone())?;
|
||||
let mut result = self.load_dashboard_summary(range).await?;
|
||||
result.trend = trend(
|
||||
kind,
|
||||
result.observation.trend.clone(),
|
||||
result.observation.analytics.clone(),
|
||||
)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -254,12 +211,16 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
range: TimeRange,
|
||||
kind: TrendKind,
|
||||
) -> Result<Trend, AdminError> {
|
||||
let points = self
|
||||
let observation = self
|
||||
.store
|
||||
.dashboard_trend(range)
|
||||
.usage_insights_observation(
|
||||
range,
|
||||
UsageFilter::default(),
|
||||
Granularity::for_range(range),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "dashboard trend"))?;
|
||||
trend(kind, points)
|
||||
trend(kind, observation.trend, observation.overview.analytics)
|
||||
}
|
||||
|
||||
async fn usage_records(&self, query: UsageQuery) -> Result<UsagePage, AdminError> {
|
||||
@@ -310,21 +271,23 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> Result<UsageInsights, AdminError> {
|
||||
let aggregates = async {
|
||||
futures::try_join!(
|
||||
self.store.usage_summary(range, filter.clone()),
|
||||
self.store.usage_trend(range, filter.clone()),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "usage insights"))
|
||||
};
|
||||
let ((overview, trend), standard_costs) = futures::try_join!(
|
||||
aggregates,
|
||||
recover_standard_costs(
|
||||
&self.providers,
|
||||
self.store
|
||||
.usage_calculated_billing_facts(range, filter.clone()),
|
||||
),
|
||||
)?;
|
||||
let granularity = Granularity::for_range(range);
|
||||
let observation = self
|
||||
.store
|
||||
.usage_insights_observation(range, filter, granularity)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage insights"))?;
|
||||
let mut standard_costs = UsageCostScenarios::default();
|
||||
for point in observation.costs {
|
||||
if let Some(amount) = point.standard_amount {
|
||||
standard_costs.standard.add(point.bucket_start, amount)?;
|
||||
}
|
||||
if let Some(amount) = point.no_cache_amount {
|
||||
standard_costs.no_cache.add(point.bucket_start, amount)?;
|
||||
}
|
||||
}
|
||||
let overview = observation.overview;
|
||||
let trend = observation.trend;
|
||||
build_usage_insights(overview, trend, standard_costs)
|
||||
}
|
||||
|
||||
@@ -336,7 +299,7 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
) -> Result<DiagnosticsResult, AdminError> {
|
||||
let observation = self
|
||||
.store
|
||||
.usage_diagnostics(range, filter, dimension)
|
||||
.usage_diagnostics(range, filter, dimension, 100)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage diagnostics"))?;
|
||||
let items = observation
|
||||
@@ -378,7 +341,11 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(DiagnosticsResult { dimension, items })
|
||||
Ok(DiagnosticsResult {
|
||||
analytics: observation.analytics,
|
||||
dimension,
|
||||
items,
|
||||
})
|
||||
}
|
||||
|
||||
async fn ops_errors(&self, query: OpsErrorQuery) -> Result<OpsErrorPage, AdminError> {
|
||||
@@ -412,8 +379,9 @@ fn build_usage_insights(
|
||||
}
|
||||
|
||||
let requests = &overview.requests;
|
||||
let failed_requests = service_failure_count(requests);
|
||||
let health_requests = requests.success_count.saturating_add(failed_requests);
|
||||
let health_window = HealthWindow::from_metrics(requests);
|
||||
let failed_requests = health_window.failed_requests;
|
||||
let health_requests = health_window.eligible_requests();
|
||||
let health = UsageInsightsHealth {
|
||||
total_requests: requests.request_count,
|
||||
success_requests: requests.success_count,
|
||||
@@ -431,17 +399,19 @@ fn build_usage_insights(
|
||||
points: trend
|
||||
.iter()
|
||||
.map(|point| {
|
||||
let failed_requests = service_failure_count(&point.metrics);
|
||||
let health_requests = point.metrics.success_count.saturating_add(failed_requests);
|
||||
let health_window = HealthWindow::from_metrics(&point.metrics);
|
||||
UsageInsightsHealthPoint {
|
||||
total_requests: point.metrics.request_count,
|
||||
bucket_start: point.bucket_start,
|
||||
success_requests: point.metrics.success_count,
|
||||
failed_requests,
|
||||
failed_requests: health_window.failed_requests,
|
||||
cancelled_requests: point.metrics.cancelled_count,
|
||||
incomplete_requests: point.metrics.incomplete_count,
|
||||
caller_error_requests: point.metrics.caller_error_count,
|
||||
error_rate: rate_or_zero(failed_requests, health_requests),
|
||||
error_rate: rate_or_zero(
|
||||
health_window.non_success_requests(),
|
||||
health_window.eligible_requests(),
|
||||
),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
@@ -522,14 +492,14 @@ fn build_usage_insights(
|
||||
tier_premium,
|
||||
tokens_per_request: rate_or_zero(requests.total_tokens, requests.request_count),
|
||||
cost_per_successful_request,
|
||||
cached_token_rate: rate_or_zero(requests.cached_tokens, requests.input_tokens),
|
||||
cached_token_rate: rate_or_zero(requests.cached_input_tokens, requests.input_tokens),
|
||||
cache_hit_request_rate: ratio(
|
||||
requests.cache_hit_request_count,
|
||||
requests.cache_eligible_request_count,
|
||||
),
|
||||
input_tokens: requests.input_tokens,
|
||||
output_tokens: requests.output_tokens,
|
||||
cached_tokens: requests.cached_tokens,
|
||||
cached_input_tokens: requests.cached_input_tokens,
|
||||
total_tokens: requests.total_tokens,
|
||||
points: trend
|
||||
.iter()
|
||||
@@ -537,7 +507,7 @@ fn build_usage_insights(
|
||||
bucket_start: point.bucket_start,
|
||||
input_tokens: point.metrics.input_tokens,
|
||||
output_tokens: point.metrics.output_tokens,
|
||||
cached_tokens: point.metrics.cached_tokens,
|
||||
cached_input_tokens: point.metrics.cached_input_tokens,
|
||||
total_tokens: point.metrics.total_tokens,
|
||||
estimated_cost: usd_cost(&point.costs),
|
||||
standard_cost: standard_costs_by_bucket.get(&point.bucket_start).cloned(),
|
||||
@@ -547,7 +517,7 @@ fn build_usage_insights(
|
||||
usd_cost(&point.costs).as_ref(),
|
||||
),
|
||||
cached_token_rate: rate_or_zero(
|
||||
point.metrics.cached_tokens,
|
||||
point.metrics.cached_input_tokens,
|
||||
point.metrics.input_tokens,
|
||||
),
|
||||
cache_hit_request_rate: ratio(
|
||||
@@ -560,6 +530,7 @@ fn build_usage_insights(
|
||||
coverage: overview.attempts.cost_coverage.clone(),
|
||||
};
|
||||
Ok(UsageInsights {
|
||||
analytics: overview.analytics,
|
||||
granularity,
|
||||
health,
|
||||
performance,
|
||||
@@ -609,58 +580,65 @@ struct UsageCostScenarios {
|
||||
no_cache: ScenarioCosts,
|
||||
}
|
||||
|
||||
async fn recover_standard_costs(
|
||||
providers: &ProviderAdminRegistry,
|
||||
mut facts: UsageCalculatedBillingStream<'_>,
|
||||
) -> Result<UsageCostScenarios, AdminError> {
|
||||
let mut scenarios = UsageCostScenarios::default();
|
||||
while let Some(mut fact) = facts
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage billing facts"))?
|
||||
{
|
||||
/// 在后台投影时恢复费用场景,查询不再逐请求读取计费快照
|
||||
pub struct ProviderUsageBillingProjector {
|
||||
providers: ProviderAdminRegistry,
|
||||
}
|
||||
|
||||
impl ProviderUsageBillingProjector {
|
||||
#[must_use]
|
||||
pub fn new(providers: ProviderAdminRegistry) -> Self {
|
||||
Self { providers }
|
||||
}
|
||||
}
|
||||
|
||||
impl UsageBillingProjector for ProviderUsageBillingProjector {
|
||||
fn project(
|
||||
&self,
|
||||
fact: &UsageCalculatedBillingFact,
|
||||
) -> Result<UsageBillingProjection, AdminError> {
|
||||
let Ok(provider_kind) = ProviderKind::new(fact.provider_kind.clone()) else {
|
||||
continue;
|
||||
return Ok(UsageBillingProjection::default());
|
||||
};
|
||||
let input = ProviderBillingInput {
|
||||
upstream_model_id: fact.upstream_model_id.clone(),
|
||||
service_tier: fact.service_tier.clone(),
|
||||
input_tokens: fact.input_tokens,
|
||||
output_tokens: fact.output_tokens,
|
||||
cached_tokens: fact.cached_tokens,
|
||||
cache_write_tokens: fact.cache_write_tokens,
|
||||
cached_input_tokens: fact.cached_input_tokens,
|
||||
cache_write_input_tokens: fact.cache_write_input_tokens,
|
||||
total: fact.total.clone(),
|
||||
};
|
||||
let breakdown = match fact
|
||||
.breakdown
|
||||
.take()
|
||||
.filter(|b| b.total_amount == input.total)
|
||||
.as_ref()
|
||||
.filter(|breakdown| breakdown.total_amount == input.total)
|
||||
.cloned()
|
||||
.map_or_else(
|
||||
|| providers.calculated_billing(&provider_kind, &input),
|
||||
|b| Ok(Some(b)),
|
||||
|| self.providers.calculated_billing(&provider_kind, &input),
|
||||
|breakdown| Ok(Some(breakdown)),
|
||||
) {
|
||||
Ok(breakdown) => breakdown,
|
||||
Err(error) if error.kind() == ProviderAdminErrorKind::Unsupported => continue,
|
||||
Err(error) if error.kind() == ProviderAdminErrorKind::Unsupported => {
|
||||
return Ok(UsageBillingProjection::default());
|
||||
}
|
||||
Err(error) => return Err(map_provider_error(error, "usage billing")),
|
||||
};
|
||||
let Some(breakdown) = breakdown else {
|
||||
continue;
|
||||
return Ok(UsageBillingProjection::default());
|
||||
};
|
||||
if !breakdown
|
||||
.standard_amount
|
||||
.currency
|
||||
.eq_ignore_ascii_case("USD")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
scenarios
|
||||
.standard
|
||||
.add(fact.bucket_start, breakdown.standard_amount.amount.clone())?;
|
||||
if let Some(no_cache_cost) = no_cache_cost(&fact, &breakdown) {
|
||||
scenarios.no_cache.add(fact.bucket_start, no_cache_cost)?;
|
||||
return Ok(UsageBillingProjection::default());
|
||||
}
|
||||
Ok(UsageBillingProjection {
|
||||
standard_amount: Some(breakdown.standard_amount.amount.clone()),
|
||||
no_cache_amount: no_cache_cost(fact, &breakdown),
|
||||
})
|
||||
}
|
||||
Ok(scenarios)
|
||||
}
|
||||
|
||||
fn no_cache_cost(
|
||||
@@ -671,19 +649,21 @@ fn no_cache_cost(
|
||||
let input_tokens = fact
|
||||
.input_tokens?
|
||||
.checked_sub(image.map_or(0, |i| i.input_tokens))?;
|
||||
let cached_tokens = fact
|
||||
.cached_tokens
|
||||
let cached_input_tokens = fact
|
||||
.cached_input_tokens
|
||||
.unwrap_or_default()
|
||||
.checked_sub(image.map_or(0, |i| i.cached_tokens))?
|
||||
.checked_sub(image.map_or(0, |i| i.cached_input_tokens))?
|
||||
.min(input_tokens);
|
||||
let cache_write_tokens = fact
|
||||
.cache_write_tokens
|
||||
let cache_write_input_tokens = fact
|
||||
.cache_write_input_tokens
|
||||
.unwrap_or_default()
|
||||
.min(input_tokens.saturating_sub(cached_tokens));
|
||||
.min(input_tokens.saturating_sub(cached_input_tokens));
|
||||
let input_rate = decimal(&breakdown.input_price_per_million.amount)?;
|
||||
let replaced_input_amount = input_rate
|
||||
.scaled()
|
||||
.checked_mul(u128::from(cached_tokens.saturating_add(cache_write_tokens)))?
|
||||
.checked_mul(u128::from(
|
||||
cached_input_tokens.saturating_add(cache_write_input_tokens),
|
||||
))?
|
||||
.checked_div(1_000_000)?;
|
||||
let mut total = decimal(&breakdown.total_amount.amount)?
|
||||
.scaled()
|
||||
@@ -693,7 +673,7 @@ fn no_cache_cost(
|
||||
if let Some(image) = image {
|
||||
let image_no_cache = decimal(&image.input_price_per_million.amount)?
|
||||
.scaled()
|
||||
.checked_mul(u128::from(image.cached_tokens))?
|
||||
.checked_mul(u128::from(image.cached_input_tokens))?
|
||||
.checked_div(1_000_000)?;
|
||||
total = total
|
||||
.checked_sub(decimal(&image.cache_read_amount.amount)?.scaled())?
|
||||
@@ -790,7 +770,7 @@ impl DefaultObservabilityService {
|
||||
}
|
||||
|
||||
/// 补充当前套餐展示名称,逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
|
||||
/// 不支持的来源或费用规则失败)只保留该条已存的总额,不影响整页返回。
|
||||
/// 不支持的来源或费用规则失败)只保留该条已存的总额,不影响整页返回
|
||||
fn enrich_list_records(&self, records: &mut [crate::model::observability::UsageListRecord]) {
|
||||
for record in records {
|
||||
record.provider_account_plan_type_display =
|
||||
@@ -818,8 +798,8 @@ impl DefaultObservabilityService {
|
||||
service_tier: record.service_tier.clone(),
|
||||
input_tokens: record.input_tokens,
|
||||
output_tokens: record.output_tokens,
|
||||
cached_tokens: record.cached_tokens,
|
||||
cache_write_tokens: record.cache_write_tokens,
|
||||
cached_input_tokens: record.cached_input_tokens,
|
||||
cache_write_input_tokens: record.cache_write_input_tokens,
|
||||
total: total.clone(),
|
||||
};
|
||||
if let Ok(Some(breakdown)) = self.providers.calculated_billing(&provider_kind, &input) {
|
||||
@@ -850,8 +830,8 @@ impl DefaultObservabilityService {
|
||||
service_tier: record.service_tier.clone(),
|
||||
input_tokens: record.input_tokens,
|
||||
output_tokens: record.output_tokens,
|
||||
cached_tokens: record.cached_tokens,
|
||||
cache_write_tokens: record.cache_write_tokens,
|
||||
cached_input_tokens: record.cached_input_tokens,
|
||||
cache_write_input_tokens: record.cache_write_input_tokens,
|
||||
total: total.clone(),
|
||||
};
|
||||
if let Ok(Some(breakdown)) = self.providers.calculated_billing(&provider_kind, &input) {
|
||||
@@ -861,15 +841,28 @@ impl DefaultObservabilityService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 按指定时刻计算中国自然日的 96 个 15 分钟健康桶。
|
||||
#[must_use]
|
||||
/// 按部署时区的自然日计算 15 分钟健康桶,桶数随夏令时变化
|
||||
pub(super) fn health_timeline_at(
|
||||
records: &[RequestMetricPoint],
|
||||
now: DateTime<Utc>,
|
||||
) -> HealthTimeline {
|
||||
let current_slot = quarter_hour_start(now);
|
||||
let start = china_day_start(now);
|
||||
let mut buckets = (0..HEALTH_TIMELINE_SLOTS)
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> Result<HealthTimeline, AdminError> {
|
||||
let start = timezone
|
||||
.day_start(now)
|
||||
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?;
|
||||
let end = timezone
|
||||
.days_after(now, 1)
|
||||
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?;
|
||||
let current_slot = start
|
||||
+ Duration::minutes(
|
||||
(now - start)
|
||||
.num_minutes()
|
||||
.div_euclid(HEALTH_TIMELINE_SLOT_MINUTES)
|
||||
* HEALTH_TIMELINE_SLOT_MINUTES,
|
||||
);
|
||||
let slots = ((end - start).num_minutes() + HEALTH_TIMELINE_SLOT_MINUTES - 1)
|
||||
/ HEALTH_TIMELINE_SLOT_MINUTES;
|
||||
let mut buckets = (0..slots)
|
||||
.map(|index| {
|
||||
(
|
||||
start + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * index),
|
||||
@@ -881,7 +874,13 @@ pub(super) fn health_timeline_at(
|
||||
if record.bucket_start < start || record.bucket_start > now {
|
||||
continue;
|
||||
}
|
||||
let record_slot = quarter_hour_start(record.bucket_start);
|
||||
let record_slot = start
|
||||
+ Duration::minutes(
|
||||
(record.bucket_start - start)
|
||||
.num_minutes()
|
||||
.div_euclid(HEALTH_TIMELINE_SLOT_MINUTES)
|
||||
* HEALTH_TIMELINE_SLOT_MINUTES,
|
||||
);
|
||||
if let Some((_, bucket)) = buckets
|
||||
.iter_mut()
|
||||
.find(|(bucket_start, _)| *bucket_start == record_slot)
|
||||
@@ -897,7 +896,7 @@ pub(super) fn health_timeline_at(
|
||||
totals.add_window(*bucket);
|
||||
totals
|
||||
});
|
||||
HealthTimeline {
|
||||
Ok(HealthTimeline {
|
||||
reliability_percent: health_reliability(totals),
|
||||
status: health_status(totals, false),
|
||||
success_requests: totals.success_requests,
|
||||
@@ -918,10 +917,14 @@ pub(super) fn health_timeline_at(
|
||||
caller_error_requests: bucket.caller_error_requests,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn trend(kind: TrendKind, points: Vec<RequestMetricPoint>) -> Result<Trend, AdminError> {
|
||||
fn trend(
|
||||
kind: TrendKind,
|
||||
points: Vec<RequestMetricPoint>,
|
||||
analytics: crate::model::analytics::AnalyticsMetadata,
|
||||
) -> Result<Trend, AdminError> {
|
||||
let mut metrics = RequestMetrics::default();
|
||||
let mut coverage = CostCoverage::default();
|
||||
let mut costs = BTreeMap::<String, DecimalAmount>::new();
|
||||
@@ -956,7 +959,7 @@ fn trend(kind: TrendKind, points: Vec<RequestMetricPoint>) -> Result<Trend, Admi
|
||||
let service_failure_count = service_failure_count(&metrics);
|
||||
let success_rate = ratio(
|
||||
metrics.success_count,
|
||||
metrics.success_count.saturating_add(service_failure_count),
|
||||
HealthWindow::from_metrics(&metrics).eligible_requests(),
|
||||
);
|
||||
let cache_hit_request_rate = ratio(
|
||||
metrics.cache_hit_request_count,
|
||||
@@ -977,13 +980,14 @@ fn trend(kind: TrendKind, points: Vec<RequestMetricPoint>) -> Result<Trend, Admi
|
||||
.filter_map(|point| point.metrics.output_throughput_p50)
|
||||
.min();
|
||||
Ok(Trend {
|
||||
analytics,
|
||||
kind,
|
||||
points: points.into_iter().map(trend_point).collect(),
|
||||
summary: TrendSummary {
|
||||
request_count: metrics.request_count,
|
||||
input_tokens: metrics.input_tokens,
|
||||
output_tokens: metrics.output_tokens,
|
||||
cached_tokens: metrics.cached_tokens,
|
||||
cached_input_tokens: metrics.cached_input_tokens,
|
||||
total_tokens: metrics.total_tokens,
|
||||
service_failure_count,
|
||||
average_latency_ms,
|
||||
@@ -1014,12 +1018,18 @@ fn trend_point(point: RequestMetricPoint) -> TrendPoint {
|
||||
point.metrics.first_token_latency_sum_ms,
|
||||
point.metrics.first_token_latency_count,
|
||||
),
|
||||
cached_token_rate: rate_or_zero(point.metrics.cached_tokens, point.metrics.input_tokens),
|
||||
cached_token_rate: rate_or_zero(
|
||||
point.metrics.cached_input_tokens,
|
||||
point.metrics.input_tokens,
|
||||
),
|
||||
cache_hit_request_rate: ratio(
|
||||
point.metrics.cache_hit_request_count,
|
||||
point.metrics.cache_eligible_request_count,
|
||||
),
|
||||
success_rate: ratio(point.metrics.success_count, point.metrics.request_count),
|
||||
success_rate: ratio(
|
||||
point.metrics.success_count,
|
||||
HealthWindow::from_metrics(&point.metrics).eligible_requests(),
|
||||
),
|
||||
metrics: point.metrics,
|
||||
cost_coverage: point.cost_coverage,
|
||||
costs: point.costs,
|
||||
@@ -1040,11 +1050,13 @@ fn dashboard_period_metrics(
|
||||
result.request_count = result.request_count.saturating_add(metrics.request_count);
|
||||
result.input_tokens = result.input_tokens.saturating_add(metrics.input_tokens);
|
||||
result.output_tokens = result.output_tokens.saturating_add(metrics.output_tokens);
|
||||
result.cached_tokens = result.cached_tokens.saturating_add(metrics.cached_tokens);
|
||||
result.cached_input_tokens = result
|
||||
.cached_input_tokens
|
||||
.saturating_add(metrics.cached_input_tokens);
|
||||
result.total_tokens = result.total_tokens.saturating_add(metrics.total_tokens);
|
||||
}
|
||||
result.cached_token_rate = rate_or_zero(result.cached_tokens, result.input_tokens);
|
||||
result.observed_cached_token_rate = ratio(result.cached_tokens, result.input_tokens);
|
||||
result.cached_token_rate = rate_or_zero(result.cached_input_tokens, result.input_tokens);
|
||||
result.observed_cached_token_rate = ratio(result.cached_input_tokens, result.input_tokens);
|
||||
result
|
||||
}
|
||||
|
||||
@@ -1061,13 +1073,15 @@ fn add_request_metrics(total: &mut RequestMetrics, value: &RequestMetrics) {
|
||||
.saturating_add(value.caller_error_count);
|
||||
total.input_tokens = total.input_tokens.saturating_add(value.input_tokens);
|
||||
total.output_tokens = total.output_tokens.saturating_add(value.output_tokens);
|
||||
total.cached_tokens = total.cached_tokens.saturating_add(value.cached_tokens);
|
||||
total.cache_write_tokens = total
|
||||
.cache_write_tokens
|
||||
.saturating_add(value.cache_write_tokens);
|
||||
total.reasoning_tokens = total
|
||||
.reasoning_tokens
|
||||
.saturating_add(value.reasoning_tokens);
|
||||
total.cached_input_tokens = total
|
||||
.cached_input_tokens
|
||||
.saturating_add(value.cached_input_tokens);
|
||||
total.cache_write_input_tokens = total
|
||||
.cache_write_input_tokens
|
||||
.saturating_add(value.cache_write_input_tokens);
|
||||
total.reasoning_output_tokens = total
|
||||
.reasoning_output_tokens
|
||||
.saturating_add(value.reasoning_output_tokens);
|
||||
total.total_tokens = total.total_tokens.saturating_add(value.total_tokens);
|
||||
total.first_token_latency_sum_ms = total
|
||||
.first_token_latency_sum_ms
|
||||
@@ -1115,20 +1129,29 @@ struct HealthWindow {
|
||||
}
|
||||
|
||||
impl HealthWindow {
|
||||
fn from_metrics(metrics: &RequestMetrics) -> Self {
|
||||
Self {
|
||||
success_requests: metrics.success_count,
|
||||
failed_requests: service_failure_count(metrics),
|
||||
cancelled_requests: metrics.cancelled_count,
|
||||
incomplete_requests: metrics.incomplete_count,
|
||||
caller_error_requests: metrics.caller_error_count,
|
||||
}
|
||||
}
|
||||
|
||||
// 未完成保留交付终态,但上游失败、超时和进程中断都不能视为服务成功
|
||||
fn non_success_requests(self) -> u64 {
|
||||
self.failed_requests
|
||||
.saturating_add(self.incomplete_requests)
|
||||
}
|
||||
|
||||
fn eligible_requests(self) -> u64 {
|
||||
self.success_requests
|
||||
.saturating_add(self.non_success_requests())
|
||||
}
|
||||
|
||||
fn add_metrics(&mut self, metrics: &RequestMetrics) {
|
||||
self.success_requests = self.success_requests.saturating_add(metrics.success_count);
|
||||
self.failed_requests = self
|
||||
.failed_requests
|
||||
.saturating_add(service_failure_count(metrics));
|
||||
self.cancelled_requests = self
|
||||
.cancelled_requests
|
||||
.saturating_add(metrics.cancelled_count);
|
||||
self.incomplete_requests = self
|
||||
.incomplete_requests
|
||||
.saturating_add(metrics.incomplete_count);
|
||||
self.caller_error_requests = self
|
||||
.caller_error_requests
|
||||
.saturating_add(metrics.caller_error_count);
|
||||
self.add_window(Self::from_metrics(metrics));
|
||||
}
|
||||
|
||||
fn add_window(&mut self, other: Self) {
|
||||
@@ -1147,15 +1170,13 @@ impl HealthWindow {
|
||||
}
|
||||
|
||||
fn health_status(bucket: HealthWindow, is_future: bool) -> HealthStatus {
|
||||
let eligible_requests = bucket
|
||||
.success_requests
|
||||
.saturating_add(bucket.failed_requests);
|
||||
let eligible_requests = bucket.eligible_requests();
|
||||
if is_future {
|
||||
HealthStatus::Future
|
||||
} else if eligible_requests == 0 {
|
||||
HealthStatus::NoData
|
||||
} else if bucket.success_requests == 0
|
||||
&& bucket.failed_requests >= HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD
|
||||
&& bucket.non_success_requests() >= HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD
|
||||
{
|
||||
HealthStatus::Unavailable
|
||||
} else if eligible_requests < HEALTH_TIMELINE_MIN_SAMPLE_SIZE {
|
||||
@@ -1170,9 +1191,7 @@ fn health_status(bucket: HealthWindow, is_future: bool) -> HealthStatus {
|
||||
}
|
||||
|
||||
fn health_reliability(bucket: HealthWindow) -> Option<f64> {
|
||||
let eligible_requests = bucket
|
||||
.success_requests
|
||||
.saturating_add(bucket.failed_requests);
|
||||
let eligible_requests = bucket.eligible_requests();
|
||||
(eligible_requests > 0)
|
||||
.then(|| bucket.success_requests as f64 / eligible_requests as f64 * 100.0)
|
||||
}
|
||||
@@ -1182,10 +1201,3 @@ fn service_failure_count(metrics: &RequestMetrics) -> u64 {
|
||||
.failure_count
|
||||
.saturating_sub(metrics.caller_error_count)
|
||||
}
|
||||
|
||||
fn quarter_hour_start(value: DateTime<Utc>) -> DateTime<Utc> {
|
||||
let elapsed = value
|
||||
.timestamp()
|
||||
.rem_euclid(HEALTH_TIMELINE_SLOT_MINUTES * 60);
|
||||
value - Duration::seconds(elapsed) - Duration::nanoseconds(i64::from(value.nanosecond()))
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! 有界统计缓存:身份和完整筛选参与键,删除版本阻止旧数据复活
|
||||
|
||||
use crate::model::observability::*;
|
||||
use crate::ports::store::{
|
||||
AdminStoreError, AdminStoreErrorKind, AdminStoreResult, ObservabilityStore,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::{collections::HashMap, future::Future, hash::Hash, sync::Arc, time::Duration};
|
||||
use tokio::{sync::Mutex, time::Instant};
|
||||
|
||||
const CAPACITY: usize = 64;
|
||||
const TTL: Duration = Duration::from_secs(15);
|
||||
const MAX_STALE: Duration = Duration::from_secs(60);
|
||||
|
||||
type Slot<T> = Arc<Mutex<Option<Cached<T>>>>;
|
||||
struct Cached<T> {
|
||||
value: T,
|
||||
created: Instant,
|
||||
}
|
||||
struct Entry<T> {
|
||||
slot: Slot<T>,
|
||||
touched: Instant,
|
||||
}
|
||||
struct Cache<K, T> {
|
||||
entries: Mutex<HashMap<K, Entry<T>>>,
|
||||
}
|
||||
impl<K, T> Default for Cache<K, T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait AnalyticsValue: Clone {
|
||||
fn metadata(&mut self) -> &mut AnalyticsMetadata;
|
||||
}
|
||||
impl AnalyticsValue for DashboardObservation {
|
||||
fn metadata(&mut self) -> &mut AnalyticsMetadata {
|
||||
&mut self.analytics
|
||||
}
|
||||
}
|
||||
impl AnalyticsValue for UsageOverview {
|
||||
fn metadata(&mut self) -> &mut AnalyticsMetadata {
|
||||
&mut self.analytics
|
||||
}
|
||||
}
|
||||
impl AnalyticsValue for UsageInsightsObservation {
|
||||
fn metadata(&mut self) -> &mut AnalyticsMetadata {
|
||||
&mut self.overview.analytics
|
||||
}
|
||||
}
|
||||
impl AnalyticsValue for DiagnosticsObservation {
|
||||
fn metadata(&mut self) -> &mut AnalyticsMetadata {
|
||||
&mut self.analytics
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Eq + Hash, T: AnalyticsValue> Cache<K, T> {
|
||||
async fn get<F, Fut>(
|
||||
&self,
|
||||
key: K,
|
||||
store: &dyn ObservabilityStore,
|
||||
ttl: Duration,
|
||||
load: F,
|
||||
) -> AdminStoreResult<T>
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = AdminStoreResult<T>>,
|
||||
{
|
||||
// 刷新失败后的同键等待也计入预算,避免串行重试把延迟放大为队列长度倍数
|
||||
tokio::time::timeout(
|
||||
store.query_timeout(),
|
||||
self.get_within_budget(key, store, ttl, load),
|
||||
)
|
||||
.await
|
||||
.map_err(|source| {
|
||||
AdminStoreError::new(
|
||||
AdminStoreErrorKind::Unavailable,
|
||||
"observability cache",
|
||||
"observability cache execution budget is exhausted",
|
||||
)
|
||||
.with_source(source)
|
||||
})?
|
||||
}
|
||||
|
||||
async fn get_within_budget<F, Fut>(
|
||||
&self,
|
||||
key: K,
|
||||
store: &dyn ObservabilityStore,
|
||||
ttl: Duration,
|
||||
load: F,
|
||||
) -> AdminStoreResult<T>
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = AdminStoreResult<T>>,
|
||||
{
|
||||
let slot = {
|
||||
let mut entries = self.entries.lock().await;
|
||||
if !entries.contains_key(&key) && entries.len() >= CAPACITY {
|
||||
let oldest = entries
|
||||
.iter()
|
||||
.filter(|(_, entry)| Arc::strong_count(&entry.slot) == 1)
|
||||
.min_by_key(|(_, entry)| entry.touched)
|
||||
.map(|(_, entry)| entry.touched);
|
||||
if let Some(oldest) = oldest {
|
||||
entries.retain(|_, entry| entry.touched != oldest);
|
||||
}
|
||||
}
|
||||
if !entries.contains_key(&key) && entries.len() >= CAPACITY {
|
||||
drop(entries);
|
||||
return load().await;
|
||||
}
|
||||
let entry = entries.entry(key).or_insert_with(|| Entry {
|
||||
slot: Arc::new(Mutex::new(None)),
|
||||
touched: Instant::now(),
|
||||
});
|
||||
entry.touched = Instant::now();
|
||||
entry.slot.clone()
|
||||
};
|
||||
// 同键刷新只执行一次;失败不会缓存成成功,也不延长旧结果寿命
|
||||
let mut cached = slot.lock().await;
|
||||
// 等锁期间代次可能推进;取得同键锁后再核对,失败时也不能回用已删除事实
|
||||
let generation = store.analytics_cache_generation().await?;
|
||||
if let Some(entry) = cached.as_mut()
|
||||
&& entry.value.metadata().generation == generation
|
||||
&& entry.created.elapsed() < ttl
|
||||
{
|
||||
return Ok(with_age(entry.value.clone(), false));
|
||||
}
|
||||
match load().await {
|
||||
Ok(value) => {
|
||||
*cached = Some(Cached {
|
||||
value: value.clone(),
|
||||
created: Instant::now(),
|
||||
});
|
||||
Ok(value)
|
||||
}
|
||||
Err(error) => {
|
||||
if ttl == TTL
|
||||
&& error.kind() == AdminStoreErrorKind::Unavailable
|
||||
&& let Some(entry) = cached.as_mut()
|
||||
&& entry.created.elapsed() <= MAX_STALE
|
||||
&& Utc::now()
|
||||
.signed_duration_since(entry.value.metadata().generated_at)
|
||||
.to_std()
|
||||
.unwrap_or_default()
|
||||
<= MAX_STALE
|
||||
&& entry.value.metadata().state != AnalyticsState::Building
|
||||
&& entry.value.metadata().generation
|
||||
== store.analytics_cache_generation().await?
|
||||
{
|
||||
return Ok(with_age(entry.value.clone(), true));
|
||||
}
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn with_age<T: AnalyticsValue>(mut value: T, stale: bool) -> T {
|
||||
let metadata = value.metadata();
|
||||
metadata.lag_seconds = metadata.lag_seconds.saturating_add(
|
||||
Utc::now()
|
||||
.signed_duration_since(metadata.generated_at)
|
||||
.num_seconds()
|
||||
.max(0) as u64,
|
||||
);
|
||||
if stale {
|
||||
metadata.state = AnalyticsState::Stale;
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
type UsageKey = (TimeRange, UsageFilter);
|
||||
pub(crate) struct CachedObservabilityStore {
|
||||
inner: Arc<dyn ObservabilityStore>,
|
||||
dashboard: Cache<DashboardQuery, DashboardObservation>,
|
||||
summaries: Cache<UsageKey, UsageOverview>,
|
||||
insights: Cache<(UsageKey, Granularity), UsageInsightsObservation>,
|
||||
diagnostics: Cache<(UsageKey, DiagnosticDimension, u16), DiagnosticsObservation>,
|
||||
}
|
||||
impl CachedObservabilityStore {
|
||||
pub(crate) fn new(inner: Arc<dyn ObservabilityStore>) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
dashboard: Cache::default(),
|
||||
summaries: Cache::default(),
|
||||
insights: Cache::default(),
|
||||
diagnostics: Cache::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ObservabilityStore for CachedObservabilityStore {
|
||||
fn query_timeout(&self) -> Duration {
|
||||
self.inner.query_timeout()
|
||||
}
|
||||
|
||||
async fn analytics_cache_generation(&self) -> AdminStoreResult<u64> {
|
||||
self.inner.analytics_cache_generation().await
|
||||
}
|
||||
async fn dashboard_summary(
|
||||
&self,
|
||||
query: DashboardQuery,
|
||||
observed_at: DateTime<Utc>,
|
||||
) -> AdminStoreResult<DashboardObservation> {
|
||||
// 复合概览还包含账号容量和最近请求,保持原有两秒实时刷新上限
|
||||
self.dashboard
|
||||
.get(
|
||||
query.clone(),
|
||||
self.inner.as_ref(),
|
||||
Duration::from_secs(2),
|
||||
|| self.inner.dashboard_summary(query, observed_at),
|
||||
)
|
||||
.await
|
||||
}
|
||||
async fn dashboard_trend(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
granularity: Granularity,
|
||||
) -> AdminStoreResult<Vec<RequestMetricPoint>> {
|
||||
self.inner.dashboard_trend(range, granularity).await
|
||||
}
|
||||
async fn usage_trend(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
granularity: Granularity,
|
||||
) -> AdminStoreResult<Vec<RequestMetricPoint>> {
|
||||
self.inner.usage_trend(range, filter, granularity).await
|
||||
}
|
||||
async fn usage_insights_observation(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
granularity: Granularity,
|
||||
) -> AdminStoreResult<UsageInsightsObservation> {
|
||||
self.insights
|
||||
.get(
|
||||
((range, filter.clone()), granularity),
|
||||
self.inner.as_ref(),
|
||||
TTL,
|
||||
|| {
|
||||
self.inner
|
||||
.usage_insights_observation(range, filter, granularity)
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
async fn usage_summary(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> AdminStoreResult<UsageOverview> {
|
||||
self.summaries
|
||||
.get((range, filter.clone()), self.inner.as_ref(), TTL, || {
|
||||
self.inner.usage_summary(range, filter)
|
||||
})
|
||||
.await
|
||||
}
|
||||
async fn usage_diagnostics(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
dimension: DiagnosticDimension,
|
||||
limit: u16,
|
||||
) -> AdminStoreResult<DiagnosticsObservation> {
|
||||
self.diagnostics
|
||||
.get(
|
||||
((range, filter.clone()), dimension, limit),
|
||||
self.inner.as_ref(),
|
||||
TTL,
|
||||
|| {
|
||||
self.inner
|
||||
.usage_diagnostics(range, filter, dimension, limit)
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage> {
|
||||
self.inner.list_usage_records(query).await
|
||||
}
|
||||
async fn usage_record_detail(&self, request_id: &str) -> AdminStoreResult<UsageDetail> {
|
||||
self.inner.usage_record_detail(request_id).await
|
||||
}
|
||||
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage> {
|
||||
self.inner.list_ops_errors(query).await
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 插件宿主账号回调;复用凭据事务,不建立第二套账号权威。
|
||||
//! 插件宿主账号回调;复用凭据事务,不建立第二套账号权威
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 插件 Client Key 管理;复用管理服务,只开放非秘密目录与预算操作。
|
||||
//! 插件 Client Key 管理;复用管理服务,只开放非秘密目录与预算操作
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! 插件自有分组、Key 与成员关系的写入及配置发布编排
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext,
|
||||
@@ -54,7 +56,7 @@ impl PluginResourceAccess for DefaultPluginResourceAccess {
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
color: command.color,
|
||||
disable_fast: command.disable_fast,
|
||||
fast_mode: command.fast_mode,
|
||||
},
|
||||
context,
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user