Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d697146941 | |||
| d1f3f4e93d | |||
| df97633300 | |||
| 3d54c7d529 | |||
| 55b3d4d99a | |||
| 59106e6a96 | |||
| aaba6f152e | |||
| f3d2d50f9a | |||
| 16162095f7 | |||
| 6629d9d0a3 | |||
| 3c6f061709 | |||
| d7d14e077e | |||
| 3b3292b934 | |||
| f7916a42e5 | |||
| f725de28bd | |||
| 97113964c8 | |||
| b429695cba | |||
| d6bf7352a0 | |||
| d800ed1f9f | |||
| 4855db3fc8 | |||
| 6cd0a8caca | |||
| 44dc89a0dd | |||
| 823ac3c25d | |||
| 5ecea182bc | |||
| cd843465b2 | |||
| ef0f91e683 | |||
| 9c1e5ae293 | |||
| 00b3365f5a | |||
| d5babf5f3f | |||
| 4b757b8250 | |||
| def8d7a204 | |||
| 25c6fdbd6c | |||
| 47202e6848 | |||
| 1b2880739a | |||
| 188e457e7f | |||
| 13756adc9d | |||
| f026582008 | |||
| b3eb86d5ea | |||
| 301d086cb6 | |||
| 023384b14b | |||
| d315f0ffe2 | |||
| f0385c9c8c | |||
| 339f91d014 | |||
| 0d231ec467 | |||
| be1fc1df48 | |||
| dd874bae30 | |||
| 45aa741d9c | |||
| 49c398db5e | |||
| 4d748dd938 | |||
| b40c522749 | |||
| d467e27fd8 | |||
| 5ef017c54e | |||
| 03046152fd | |||
| d75daf72d6 | |||
| d39100b56d | |||
| 2e4c5801b6 | |||
| 870b2a2da3 | |||
| 4448567368 | |||
| fae0f607aa | |||
| e4630c4cb6 | |||
| e72e323313 | |||
| 2b836b3e1e | |||
| 598b2f48d6 | |||
| 3f22d7b1ab | |||
| 5ce5ea1687 | |||
| f8741b544e | |||
| c316a78511 | |||
| 68ae9acb27 | |||
| 13c7d8aa4e | |||
| fb5fc9f1ae | |||
| 5765ea48b0 | |||
| 55bd3f41f8 | |||
| 938597b766 | |||
| fe0e3faa22 | |||
| d0f26118ff | |||
| c98d9a2c6d | |||
| 1293ebae68 | |||
| b29c7ffa7a | |||
| 056e9fb651 | |||
| fa5a2419d8 | |||
| 7b34153235 | |||
| 04792bd3f7 | |||
| bb31118792 | |||
| 5027767ca2 | |||
| fd930757f9 | |||
| eb6ed11cff | |||
| 931bcf3fb9 | |||
| e0bd9b4194 | |||
| 0f9fe4a81d | |||
| 6869504119 | |||
| 3f9f9f7665 |
+6
-5
@@ -1,18 +1,19 @@
|
|||||||
# Cargo build acceleration.
|
# Cargo build acceleration.
|
||||||
#
|
#
|
||||||
# The biggest single speedup is the `mold` linker (~3-5x faster link
|
# The biggest single speedup is the `mold` linker (~3-5x faster link
|
||||||
# time on debug builds). It isn't installed by default on this host
|
# time on debug builds). `build-and-run.sh` enables mold/lld automatically
|
||||||
# (no apt-get, see build-and-run.sh notes), so the mold block below is
|
# when available, so the block below is kept as an optional manual setup for
|
||||||
# commented out — uncomment it after running:
|
# direct `cargo build` / `cargo run` usage:
|
||||||
#
|
#
|
||||||
# sudo apt-get install -y mold
|
# sudo apt-get install -y mold
|
||||||
#
|
#
|
||||||
# (or build it from source: https://github.com/rui314/mold)
|
# (or build it from source: https://github.com/rui314/mold)
|
||||||
#
|
#
|
||||||
# Without mold, the [profile.dev] tweaks in Cargo.toml still give a
|
# Without mold/lld, the [profile.dev] tweaks in Cargo.toml still give a
|
||||||
# meaningful speedup (line-tables-only debuginfo + bumped codegen-units).
|
# meaningful speedup (line-tables-only debuginfo + bumped codegen-units).
|
||||||
|
|
||||||
# Uncomment once mold is on PATH:
|
# Optional: uncomment once mold is on PATH if you want direct cargo commands
|
||||||
|
# to use it without going through build-and-run.sh.
|
||||||
# [target.x86_64-unknown-linux-gnu]
|
# [target.x86_64-unknown-linux-gnu]
|
||||||
# linker = "clang"
|
# linker = "clang"
|
||||||
# rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
# rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||||
|
|||||||
@@ -2,3 +2,19 @@ comfy_source/venv
|
|||||||
comfy_source/.git
|
comfy_source/.git
|
||||||
comfy_source/__pycache__
|
comfy_source/__pycache__
|
||||||
**/__pycache__
|
**/__pycache__
|
||||||
|
target/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
release/
|
||||||
|
data/
|
||||||
|
logs/
|
||||||
|
tmp/
|
||||||
|
.agents/
|
||||||
|
.cache/
|
||||||
|
.codex/
|
||||||
|
.claude/
|
||||||
|
.comfyui/
|
||||||
|
boosapi
|
||||||
|
bossai
|
||||||
|
boosapi.db
|
||||||
|
docker/*.tar
|
||||||
|
|||||||
+11
-1
@@ -1,13 +1,15 @@
|
|||||||
/target
|
/target
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
Cargo.lock
|
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
data/
|
data/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
website/.next/
|
||||||
|
website/out/
|
||||||
dist/
|
dist/
|
||||||
release/
|
release/
|
||||||
*.log
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
logs/
|
logs/
|
||||||
tmp/
|
tmp/
|
||||||
!tmp/.gitkeep
|
!tmp/.gitkeep
|
||||||
@@ -19,5 +21,13 @@ docker/.env
|
|||||||
.comfyui/
|
.comfyui/
|
||||||
.claude/
|
.claude/
|
||||||
.codex/
|
.codex/
|
||||||
|
.agents/
|
||||||
|
.cache/
|
||||||
openspec/
|
openspec/
|
||||||
boosapi
|
boosapi
|
||||||
|
bossai
|
||||||
|
docker/*.tar
|
||||||
|
boosapi.db-shm
|
||||||
|
boosapi.db-wal
|
||||||
|
.tours/
|
||||||
|
bun.lock
|
||||||
|
|||||||
Generated
+4177
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ tower-http = { version = "0.6", features = ["cors", "trace", "fs", "compression-
|
|||||||
# Async runtime
|
# Async runtime
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
sqlx = { version = "0.8", features = [
|
sqlx = { version = "0.8", features = [
|
||||||
@@ -69,6 +70,9 @@ once_cell = "1"
|
|||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
mime = "0.3"
|
mime = "0.3"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
hmac = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
include_dir = "0.7"
|
include_dir = "0.7"
|
||||||
mime_guess = "2"
|
mime_guess = "2"
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,31 @@
|
|||||||
|
|
||||||
## 3. 生产环境 (Production)
|
## 3. 生产环境 (Production)
|
||||||
|
|
||||||
|
### 单二进制生产包
|
||||||
|
|
||||||
|
推荐用项目脚本生成生产包:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build-and-run.sh --production
|
||||||
|
```
|
||||||
|
|
||||||
|
生产模式会构建主前端和 Workflow UI,并把静态资产嵌入 Rust 二进制。部署目录通常只需要:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
boosapi
|
||||||
|
config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
部署目录只需要运行产物和配置。仓库里的 `.agents/`、`.cache/`、本地二进制、Docker tar 包、运行日志等均为本地缓存或构建产物,不应进入发布包。`Cargo.lock` 应保留在仓库中,用于保证生产构建依赖可复现。
|
||||||
|
|
||||||
|
启动前检查配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./boosapi --check-config
|
||||||
|
./boosapi --migrate-only
|
||||||
|
./boosapi --config config.toml
|
||||||
|
```
|
||||||
|
|
||||||
### 推荐:Docker Compose 部署
|
### 推荐:Docker Compose 部署
|
||||||
|
|
||||||
1. **准备 docker-compose.yml**:
|
1. **准备 docker-compose.yml**:
|
||||||
@@ -161,8 +186,54 @@
|
|||||||
| `auth.auth_token` | `BOOSAPI_AUTH__AUTH_TOKEN` | 管理员登录 Token (静态,必填) |
|
| `auth.auth_token` | `BOOSAPI_AUTH__AUTH_TOKEN` | 管理员登录 Token (静态,必填) |
|
||||||
| `auth.jwt_secret` | `BOOSAPI_AUTH__JWT_SECRET` | JWT 签名密钥 (普通用户,必填) |
|
| `auth.jwt_secret` | `BOOSAPI_AUTH__JWT_SECRET` | JWT 签名密钥 (普通用户,必填) |
|
||||||
| `db.url` | `BOOSAPI_DB__URL` | SQLite 数据库路径 (如 `sqlite:data/boosapi.db`) |
|
| `db.url` | `BOOSAPI_DB__URL` | SQLite 数据库路径 (如 `sqlite:data/boosapi.db`) |
|
||||||
|
| `comfyui.backend` | `BOOSAPI_COMFYUI__BACKEND` | Workflow 后端:`cloud` / `runninghub` / `external` / `boosapi` / `docker` |
|
||||||
|
| `comfyui.base_url` | `BOOSAPI_COMFYUI__BASE_URL` | Comfy Cloud、RunningHub 或外部 ComfyUI API 地址 |
|
||||||
|
| `comfyui.api_key` | `BOOSAPI_COMFYUI__API_KEY` | Workflow 后端 API Key,按后端需要填写 |
|
||||||
| `web_dir` | `BOOSAPI_WEB_DIR` | 前端静态资源目录 (默认 `dist/web`) |
|
| `web_dir` | `BOOSAPI_WEB_DIR` | 前端静态资源目录 (默认 `dist/web`) |
|
||||||
|
|
||||||
|
### Workflow 后端配置
|
||||||
|
|
||||||
|
BoosAPI 的 `/workflows` 页面使用内嵌 ComfyUI 前端,后端由 `[comfyui]` 选择。
|
||||||
|
|
||||||
|
Comfy Cloud:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[comfyui]
|
||||||
|
backend = "cloud"
|
||||||
|
base_url = "https://cloud.comfy.org"
|
||||||
|
api_key = "your-comfy-cloud-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
RunningHub:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[comfyui]
|
||||||
|
backend = "runninghub"
|
||||||
|
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
|
||||||
|
api_key = "your-runninghub-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
外部完整版 ComfyUI:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[comfyui]
|
||||||
|
backend = "external"
|
||||||
|
base_url = "http://127.0.0.1:8188"
|
||||||
|
```
|
||||||
|
|
||||||
|
BoosAPI API-native 执行器:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[comfyui]
|
||||||
|
backend = "boosapi"
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `cloud`、`runninghub`、`external` 和 `boosapi` 模式不要求 BoosAPI 机器有 GPU。
|
||||||
|
- `docker` / 本地 ComfyUI 模式需要本机有完整模型目录和匹配的 NVIDIA GPU。
|
||||||
|
- 生产构建应包含 `dist/workflow-ui`、`dist/workflow-ui-cloud`、`dist/workflow-ui-external` 中可用的 Workflow UI 资产;脚本会尽量自动安装与构建。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 运维操作 (Ops)
|
## 5. 运维操作 (Ops)
|
||||||
@@ -179,6 +250,8 @@ BoosAPI 使用 SQLite,只需备份数据库文件即可:
|
|||||||
cp data/boosapi.db data/boosapi.db.bak
|
cp data/boosapi.db data/boosapi.db.bak
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如使用 PostgreSQL,请按数据库自身的 `pg_dump` / `pg_restore` 流程备份恢复。
|
||||||
|
|
||||||
### 查看日志
|
### 查看日志
|
||||||
如果使用 Systemd:
|
如果使用 Systemd:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<br>
|
<br>
|
||||||
汇聚成 <strong>一个 API Key、一个入口</strong>,自动发现模型、智能路由、成本最优。
|
汇聚成 <strong>一个 API Key、一个入口</strong>,自动发现模型、智能路由、成本最优。
|
||||||
<br>
|
<br>
|
||||||
<strong>Workflow Studio / 视频助手</strong> — 用 API-native 工作流和自然语言助手组合图像、视频、语音与文本能力。
|
<strong>Workflow Studio / 视频助手</strong> — 用内嵌 ComfyUI 前端、多 API 后端和自然语言助手组合图像、视频、语音与文本能力。
|
||||||
<br>
|
<br>
|
||||||
<strong>SaaS 多租户架构</strong>,组织管理、成员邀请、套餐限制,开箱即用。
|
<strong>SaaS 多租户架构</strong>,组织管理、成员邀请、套餐限制,开箱即用。
|
||||||
</p>
|
</p>
|
||||||
@@ -57,15 +57,17 @@
|
|||||||
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
|
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
|
||||||
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
|
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
|
||||||
|
|
||||||
## 🖼️ 界面预览
|
## 2026-06 更新重点
|
||||||
|
|
||||||

|
- **后台登录收口**:管理员后台只允许使用 `BOOSAPI_AUTH__AUTH_TOKEN` 这类 API Token 登录;普通用户仍使用账号密码登录用户工作台。
|
||||||
|
- **日志边界清晰**:管理员「使用日志」保留在 `/logs`,系统/程序日志保留在 `/events`;`/api/events` 现在只接受管理员 API Token,普通用户不会再看到运行日志。
|
||||||

|
- **通知隔离**:普通用户顶部通知只读取用户公告;管理员通知面板继续聚合签到、余额、令牌、代理、状态与公告事件。
|
||||||
|
- **后台信息架构**:侧栏按接入管理、创作应用、系统管理、我的账户精简;外部监控入口已移除,系统日志入口保留在系统管理分组。
|
||||||

|
- **运维与 IP 管理**:新增运维监控视图与 IP 管理页面,支持代理质量检测、连接测试、批量操作与更清晰的状态展示。
|
||||||
|
- **用户自助工作台**:API Key、计费、账户充值、使用记录、工作空间和模型测试统一到用户侧;工作空间支持分页、一键选择和默认预览。
|
||||||
---
|
- **Comfy Checkout 充值**:用户先在系统内创建充值记录,再跳转到配置的 Comfy Checkout 链接付款;充值记录会保留用于后续对账。
|
||||||
|
- **Workflow UI 更新**:`npm run workflow-ui:install:cloud` 会安装当前 Comfy Cloud 前端资产并写入构建元数据,Rust 服务会对 JS/CSS 走 no-cache,避免继续显示旧版本。
|
||||||
|
- **本地化时间**:前端展示时间优先使用浏览器/UA 时区,后端无时区时间按 UTC 解析后再转成本地显示。
|
||||||
|
|
||||||
## ✨ 核心功能
|
## ✨ 核心功能
|
||||||
|
|
||||||
@@ -75,7 +77,8 @@
|
|||||||
- **自动生成四视图**:每个角色正/背/左/右 4 角度,调用 gpt-image-2 生成图片
|
- **自动生成四视图**:每个角色正/背/左/右 4 角度,调用 gpt-image-2 生成图片
|
||||||
- **场景生成**:自动为每个场景生成配图
|
- **场景生成**:自动为每个场景生成配图
|
||||||
- **TTS 语音合成**:对话台词自动转语音
|
- **TTS 语音合成**:对话台词自动转语音
|
||||||
- **Workflow Studio**:API-native 工作流入口,不依赖 Docker、GPU 或本地 ComfyUI
|
- **Workflow Studio**:内嵌 ComfyUI 前端,可切换 Comfy Cloud、RunningHub、外部 ComfyUI、BoosAPI API-native 执行器或本地 Docker 完整版 ComfyUI
|
||||||
|
- **远程执行优先**:使用 Comfy Cloud、RunningHub、外部 API 或 BoosAPI 上游路由时,BoosAPI 服务器不需要 GPU
|
||||||
- **外部 ComfyUI 可选代理**:已有独立 ComfyUI 服务时,可通过 `comfyui.base_url` 接入 `/api/comfyui/proxy/*`
|
- **外部 ComfyUI 可选代理**:已有独立 ComfyUI 服务时,可通过 `comfyui.base_url` 接入 `/api/comfyui/proxy/*`
|
||||||
- **Rust 后端**(boosapi-rs):高性能 SSE 流式响应,零 Node.js 运行时依赖
|
- **Rust 后端**(boosapi-rs):高性能 SSE 流式响应,零 Node.js 运行时依赖
|
||||||
- 侧边栏「视频助手」入口,与聚合网关同一界面
|
- 侧边栏「视频助手」入口,与聚合网关同一界面
|
||||||
@@ -186,6 +189,80 @@ BoosAPI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时
|
|||||||
|
|
||||||
如果图像、视频、语音能力走 BoosAPI 上游路由、Comfy Cloud、RunningHub 或其他远程 API,本机只负责网关、数据库、Workflow UI 代理、日志和 Workspace 归档,上面这档配置通常足够。只有把 ComfyUI 模型和视频生成放在本机运行时,才需要高显存 NVIDIA GPU。
|
如果图像、视频、语音能力走 BoosAPI 上游路由、Comfy Cloud、RunningHub 或其他远程 API,本机只负责网关、数据库、Workflow UI 代理、日志和 Workspace 归档,上面这档配置通常足够。只有把 ComfyUI 模型和视频生成放在本机运行时,才需要高显存 NVIDIA GPU。
|
||||||
|
|
||||||
|
## 🎛️ Workflow API 后端模式
|
||||||
|
|
||||||
|
BoosAPI 的 Workflow 使用内嵌 ComfyUI 前端,后端可按配置切换:
|
||||||
|
|
||||||
|
| backend | 用途 |
|
||||||
|
|---------|------|
|
||||||
|
| `cloud` | 代理 Comfy Cloud API,适合默认创作、官方订阅模型和稳定执行 |
|
||||||
|
| `runninghub` | 代理 RunningHub 的 ComfyUI 兼容 API,适合使用 RunningHub 专属插件、工作流和模型 |
|
||||||
|
| `external` | 代理你自己部署的完整版 ComfyUI |
|
||||||
|
| `boosapi` | 使用 BoosAPI 自带 Rust Workflow/API 节点执行器 |
|
||||||
|
| `docker` | 本地完整版 ComfyUI 模式,适合有本地 GPU 和完整模型目录的环境 |
|
||||||
|
|
||||||
|
推荐配置是默认 `cloud`,需要 RunningHub 专属插件或模型时切到 `runninghub`。RunningHub 模式会复用 external 兼容前端,并对 `/object_info`、模型库、`/system_stats` 做短期缓存,减少每次打开工作流时的卡顿。
|
||||||
|
|
||||||
|
配置示例:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[comfyui]
|
||||||
|
backend = "cloud"
|
||||||
|
base_url = "https://cloud.comfy.org"
|
||||||
|
api_key = "your-comfy-cloud-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[comfyui]
|
||||||
|
backend = "runninghub"
|
||||||
|
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
|
||||||
|
api_key = "your-runninghub-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[comfyui]
|
||||||
|
backend = "external"
|
||||||
|
base_url = "http://127.0.0.1:8188"
|
||||||
|
```
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[comfyui]
|
||||||
|
backend = "boosapi"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 将 Comfy 视频节点包装为 BoosAPI `/v1/videos`
|
||||||
|
|
||||||
|
ComfyUI/Comfy Cloud 没有 OpenAI 兼容 `/v1/videos`,但 BoosAPI 可以把一个已保存的视频工作流模板包装成统一视频模型:
|
||||||
|
|
||||||
|
1. 在工作流页面保存一个真实 Comfy 视频模板,模板需要能被 `POST /prompt` 执行。
|
||||||
|
2. 模板图里最好保存节点映射:
|
||||||
|
- `prompt`:提示词节点和字段
|
||||||
|
- `negativePrompt`:反向提示词节点和字段,可选
|
||||||
|
- `ratio` / `resolution` / `duration`:画幅、分辨率和时长节点字段,可选
|
||||||
|
3. 在模型路由里新增一个模型,例如 `comfy-wan-video`。
|
||||||
|
4. 让该路由的上游地址或实际模型映射指向模板:
|
||||||
|
- 上游地址:`comfy://<workflow_public_id>`
|
||||||
|
- 或模型映射:`comfy:<workflow_public_id>`
|
||||||
|
|
||||||
|
之后用户可以直接调用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:4000/v1/videos \
|
||||||
|
-H "Authorization: Bearer <BoosAPI downstream key>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"model": "comfy-wan-video",
|
||||||
|
"prompt": "cinematic product video, smooth camera movement",
|
||||||
|
"duration": 5,
|
||||||
|
"ratio": "9:16",
|
||||||
|
"resolution": "1080p"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
返回的 `id` 是 Comfy `prompt_id`,可用 `GET /v1/videos/{id}?model=comfy-wan-video` 查询 Comfy history。
|
||||||
|
|
||||||
|
> Workflow UI 有 cloud 与 external/runninghub 两套前端资产。开发环境可用 `npm run workflow-ui:install:cloud`、`npm run workflow-ui:install:external` 单独安装;安装脚本会写入 `boosapi-workflow-ui.json` 构建元数据,生产构建会把可用资产嵌入 Rust 二进制。若浏览器「关于」仍显示旧前端版本,请重启 Rust 服务并强刷浏览器缓存。
|
||||||
|
|
||||||
## 🚀 快速开始
|
## 🚀 快速开始
|
||||||
|
|
||||||
### Docker Compose(推荐)
|
### Docker Compose(推荐)
|
||||||
@@ -210,14 +287,14 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# AUTH_TOKEN = 管理后台登录令牌
|
# AUTH_TOKEN = 管理后台登录令牌(管理员只支持 API Token 登录)
|
||||||
# PROXY_TOKEN = 下游客户端调用 /v1/* 的 Token
|
# PROXY_TOKEN = 下游客户端调用 /v1/* 的 Token
|
||||||
export AUTH_TOKEN=your-admin-token
|
export AUTH_TOKEN=your-admin-token
|
||||||
export PROXY_TOKEN=sk-your-proxy-token
|
export PROXY_TOKEN=sk-your-proxy-token
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
启动后访问 `http://localhost:4000`,用 `AUTH_TOKEN` 登录即可。
|
启动后访问 `http://localhost:4000/boos`,用 `AUTH_TOKEN` 登录管理员后台;普通用户从用户登录页使用账号密码进入用户工作台。
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。
|
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。
|
||||||
@@ -260,15 +337,16 @@ docker compose up -d
|
|||||||
│ ├── billing/ # 计费逻辑
|
│ ├── billing/ # 计费逻辑
|
||||||
│ └── notification/ # 通知推送
|
│ └── notification/ # 通知推送
|
||||||
├── migrations/ # SQLite 迁移
|
├── migrations/ # SQLite 迁移
|
||||||
├── src/web/ # 前端源码(React 19 + Vite)
|
├── src/web/ # 管理台前端源码(React + Vite)
|
||||||
│ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶)
|
│ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶)
|
||||||
│ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage)
|
│ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage)
|
||||||
│ ├── pages/
|
│ ├── pages/
|
||||||
│ │ ├── auth/ # 登录页拆分:LoginShell + AdminLoginPage + UserLoginPage(OAuthSlot 占位,行为由后续 change 接入)
|
│ │ ├── auth/ # 登录页拆分:LoginShell + AdminLoginPage(token-only) + UserLoginPage(OAuthSlot 占位)
|
||||||
│ │ ├── ComfyUIAgent.tsx # 视频/工作流助手聊天界面
|
│ │ ├── ComfyUIAgent.tsx # 视频/工作流助手聊天界面
|
||||||
│ │ ├── Workflows.tsx # Workflow Studio 入口
|
│ │ ├── Workflows.tsx # Workflow Studio 入口
|
||||||
│ │ └── ...
|
│ │ └── ...
|
||||||
│ └── components/
|
│ └── components/
|
||||||
|
├── website/ # 独立官网(Next.js 16 App Router + React 19)
|
||||||
└── dist/web/ # 前端构建产物(由 Rust serve)
|
└── dist/web/ # 前端构建产物(由 Rust serve)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -306,6 +384,11 @@ BOOSAPI_UPSTREAM_KEY=sk-your-key
|
|||||||
|
|
||||||
# 前端静态目录(默认 dist/web)
|
# 前端静态目录(默认 dist/web)
|
||||||
BOOSAPI_WEB_DIR=dist/web
|
BOOSAPI_WEB_DIR=dist/web
|
||||||
|
|
||||||
|
# Workflow 后端
|
||||||
|
BOOSAPI_COMFYUI__BACKEND=cloud
|
||||||
|
BOOSAPI_COMFYUI__BASE_URL=https://cloud.comfy.org
|
||||||
|
BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🏗️ 技术栈
|
## 🏗️ 技术栈
|
||||||
@@ -319,9 +402,10 @@ BOOSAPI_WEB_DIR=dist/web
|
|||||||
| 认证鉴权 | **JWT** + API Key 双模式 |
|
| 认证鉴权 | **JWT** + API Key 双模式 |
|
||||||
| 代理引擎 | 自定义 Token Router + Proxy Conductor |
|
| 代理引擎 | 自定义 Token Router + Proxy Conductor |
|
||||||
| 协议转换 | OpenAI / Claude / Gemini 全兼容 |
|
| 协议转换 | OpenAI / Claude / Gemini 全兼容 |
|
||||||
| Workflow Studio | API-native 工作流入口,后续承接节点画布 |
|
| Workflow Studio | 内嵌 ComfyUI 前端 + Cloud / RunningHub / External / BoosAPI 多后端 |
|
||||||
| 视频助手 | SSE 流式 + gpt-image-2 + TTS 自动生成 |
|
| 视频助手 | SSE 流式 + gpt-image-2 + TTS 自动生成 |
|
||||||
| 前端框架 | React 19 + Vite(仅构建工具) |
|
| 管理台前端 | React + Vite(仅构建工具) |
|
||||||
|
| 独立官网 | Next.js 16 App Router + React 19 + TypeScript |
|
||||||
| 样式 | Tailwind CSS v4 |
|
| 样式 | Tailwind CSS v4 |
|
||||||
| 数据可视化 | VChart |
|
| 数据可视化 | VChart |
|
||||||
| 容器化 | Docker + Docker Compose + K3s |
|
| 容器化 | Docker + Docker Compose + K3s |
|
||||||
@@ -329,6 +413,8 @@ BOOSAPI_WEB_DIR=dist/web
|
|||||||
|
|
||||||
> **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。
|
> **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。
|
||||||
|
|
||||||
|
> 独立官网位于 `website/`。默认构建会把官网静态导出到 `dist/website/`,Rust 后端会让根路径 `/` 显示官网;管理台仍由 `dist/web/` 提供,管理员入口是 `/boos`,登录后进入 `/dashboard`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ 本地开发
|
## 🛠️ 本地开发
|
||||||
@@ -336,11 +422,20 @@ BOOSAPI_WEB_DIR=dist/web
|
|||||||
### 后端(Rust)
|
### 后端(Rust)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 编译并运行
|
# 最快验证:只检查 Rust 后端,不构建前端、不启动服务
|
||||||
cargo run --release
|
./build-and-run.sh --check
|
||||||
|
|
||||||
# 仅检查编译
|
# 日常启动:复用前端缓存,后端 dev profile 编译后启动
|
||||||
cargo check
|
./build-and-run.sh
|
||||||
|
|
||||||
|
# 只重启/编译后端,不碰 Vite/Next
|
||||||
|
./build-and-run.sh --backend-only
|
||||||
|
|
||||||
|
# 接近生产性能但比 release 快
|
||||||
|
./build-and-run.sh --fast-release
|
||||||
|
|
||||||
|
# 完整生产包:构建并嵌入前端/Workflow UI,只输出 boosapi + config.toml
|
||||||
|
./build-and-run.sh --production
|
||||||
|
|
||||||
# 运行测试
|
# 运行测试
|
||||||
cargo test
|
cargo test
|
||||||
@@ -351,9 +446,15 @@ export BOOSAPI_SERVER__PORT=4000
|
|||||||
export BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
|
export BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
|
||||||
export BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
|
export BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
|
||||||
export BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1
|
export BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1
|
||||||
export BOOSAPI_UPSTREAM_KEY=sk-boos4721
|
export BOOSAPI_UPSTREAM_KEY=sk-your-upstream-key
|
||||||
```
|
```
|
||||||
|
|
||||||
|
构建加速说明:
|
||||||
|
|
||||||
|
- 脚本会在 Linux 下自动使用 `mold` / `lld` 快速链接器;可用 `BOOSAPI_FAST_LINKER=off` 关闭。
|
||||||
|
- `BOOSAPI_CARGO_JOBS=8 ./build-and-run.sh --check` 可限制 Cargo 并发,避免把开发机打满。
|
||||||
|
- 生产性能验证优先用 `--fast-release`,真正发布再用 `--production` 或 `--release`。
|
||||||
|
|
||||||
### 前端(React SPA)
|
### 前端(React SPA)
|
||||||
|
|
||||||
前端使用 Vite 构建,产物由 Rust 后端静态 serve:
|
前端使用 Vite 构建,产物由 Rust 后端静态 serve:
|
||||||
@@ -365,14 +466,76 @@ npm install
|
|||||||
# 开发模式(热更新,需要 Node.js)
|
# 开发模式(热更新,需要 Node.js)
|
||||||
cd src/web && npx vite --host 0.0.0.0
|
cd src/web && npx vite --host 0.0.0.0
|
||||||
|
|
||||||
# 构建生产版本
|
# 类型检查与构建生产版本
|
||||||
npm run build
|
npm run typecheck:web
|
||||||
|
npm run build:web
|
||||||
# 输出到 dist/web/,Rust 后端自动 serve
|
# 输出到 dist/web/,Rust 后端自动 serve
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Workflow UI 前端资产单独更新:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Comfy Cloud 前端
|
||||||
|
npm run workflow-ui:install:cloud
|
||||||
|
|
||||||
|
# External / RunningHub 前端
|
||||||
|
npm run workflow-ui:install:external
|
||||||
|
```
|
||||||
|
|
||||||
|
可选使用 Bun 加速主前端构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 一次性使用 Bun 构建主前端
|
||||||
|
./build-and-run.sh --bun
|
||||||
|
|
||||||
|
# 或设置环境变量,auto 会优先使用可用的 bun,缺省回退 npm/npx
|
||||||
|
BOOSAPI_FRONTEND_PM=bun ./build-and-run.sh --no-run
|
||||||
|
BOOSAPI_FRONTEND_PM=auto ./build-and-run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
> Workflow UI 子模块仍按官方 `pnpm-lock.yaml` 使用 pnpm 构建,避免 ComfyUI 前端依赖兼容问题。
|
||||||
|
|
||||||
> 生产环境不需要 Node.js 进程。`npm run build` 后,Rust 后端直接 serve 静态文件。
|
> 生产环境不需要 Node.js 进程。`npm run build` 后,Rust 后端直接 serve 静态文件。
|
||||||
> 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。
|
> 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。
|
||||||
|
|
||||||
|
### 独立官网(Next.js)
|
||||||
|
|
||||||
|
官网已经从管理台中隔离,使用 Next.js 16 App Router、React 19、TypeScript、Tailwind CSS 4 和自定义 SVG/CSS motion system:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 首次安装
|
||||||
|
npm --prefix website install
|
||||||
|
|
||||||
|
# 本地开发官网
|
||||||
|
npm run website:dev
|
||||||
|
|
||||||
|
# 类型检查与生产构建
|
||||||
|
npm run website:typecheck
|
||||||
|
npm run website:build
|
||||||
|
```
|
||||||
|
|
||||||
|
`./build-and-run.sh` 会同时构建管理台 `dist/web/` 和官网 `dist/website/`。打开主域名 `/` 显示官网;后台路径 `/boos`、`/dashboard`、`/user/*`、`/api/*`、`/v1/*` 等仍由 BoosAPI Rust 服务处理。
|
||||||
|
|
||||||
|
### 生产单二进制构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build-and-run.sh --production
|
||||||
|
```
|
||||||
|
|
||||||
|
生产模式会构建主前端与 Workflow UI 资产,并将它们嵌入 Rust 二进制。部署目录通常只需要:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
boosapi
|
||||||
|
config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
仓库卫生说明:
|
||||||
|
|
||||||
|
- `Cargo.lock` 应随代码提交,保证 Rust 应用在 CI、生产构建和本地排障时依赖版本一致。
|
||||||
|
- `boosapi.db`、`config.toml` 是本地运行态文件;实际部署时不要提交带真实密钥、Comfy Cloud Key 或生产数据的改动。
|
||||||
|
- `.agents/`、`.cache/`、`boosapi` / `bossai` 本地二进制、`docker/*.tar` 等构建缓存与大文件已加入忽略规则。
|
||||||
|
- 旧的 `boosapi-comfyui-multi-tenant-*.tar` 不再作为当前 Workflow 路线的必需产物,生产包应优先使用单二进制 + 配置文件。
|
||||||
|
|
||||||
### Docker 部署
|
### Docker 部署
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+140
-151
@@ -2,206 +2,195 @@
|
|||||||
|
|
||||||
# BoosAPI
|
# BoosAPI
|
||||||
|
|
||||||
**AI Aggregation Gateway + Built-in ComfyUI — Unify scattered relay stations into one entry point**
|
**AI Aggregation Gateway + Workflow Studio — one endpoint for many AI relay stations**
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Aggregate your New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API stations
|
Aggregate New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API stations
|
||||||
<br>
|
<br>
|
||||||
into <strong>one API Key, one endpoint</strong> — with auto model discovery, smart routing, and cost optimization.
|
into <strong>one API key and one compatible endpoint</strong>, with model discovery, smart routing, logs, billing, and failover.
|
||||||
<br>
|
<br>
|
||||||
<strong>Built-in ComfyUI chat assistant</strong> — generate images/videos from natural language, ready out of the box.
|
Workflow Studio embeds the ComfyUI frontend and can run against Comfy Cloud, RunningHub, your own ComfyUI, or BoosAPI's API-native executor.
|
||||||
<br>
|
|
||||||
<strong>SaaS multi-tenant architecture</strong> — organization management, member invitations, plan limits.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="Node.js" src="https://img.shields.io/badge/Node.js-22.15%2B-339933?logo=node.js&style=flat">
|
<img alt="Rust" src="https://img.shields.io/badge/Rust-1.85%2B-000000?logo=rust&style=flat">
|
||||||
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript&style=flat">
|
<img alt="Axum" src="https://img.shields.io/badge/Axum-0.8-3182F6?style=flat">
|
||||||
|
<img alt="SQLx" src="https://img.shields.io/badge/SQLx-0.8-3E6E93?style=flat">
|
||||||
|
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?logo=react&style=flat">
|
||||||
<img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat">
|
<img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="README.md">中文</a> |
|
<a href="README.md">中文</a> |
|
||||||
<a href="README_EN.md"><strong>English</strong></a>
|
<a href="README_EN.md"><strong>English</strong></a> |
|
||||||
|
<a href="DEPLOY.md">Deployment</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📖 Introduction
|
## Introduction
|
||||||
|
|
||||||
With the growing ecosystem of AI relay stations (New API / One API based), managing balances, models, and API keys across multiple stations is tedious and time-consuming.
|
BoosAPI is a self-hosted Rust gateway for AI relay stations. It centralizes upstream sites, accounts, downstream API keys, model routes, usage logs, image/video generation, user dashboards, and Workflow Studio in one service.
|
||||||
|
|
||||||
**BoosAPI** acts as a **meta-aggregation layer** above these stations, unifying them under **a single entry point** — all downstream tools connect seamlessly without configuration changes.
|
Supported upstreams:
|
||||||
|
|
||||||
### Supported Upstreams
|
- **Relay panels**: New API, One API, OneHub, DoneHub, Veloera, AnyRouter, Sub2API
|
||||||
|
- **Compatible APIs**: OpenAI / Claude / Gemini compatible, cliproxyapi / CPA
|
||||||
|
- **Preset providers**: Aliyun, Zhipu, Doubao Coding Plan, DeepSeek, Moonshot, MiniMax, ModelScope
|
||||||
|
- **OAuth connections**: Codex, Claude, Gemini CLI, Antigravity
|
||||||
|
|
||||||
- **Panels**: New API, One API, OneHub, DoneHub, Veloera, AnyRouter, Sub2API
|
## Key Features
|
||||||
- **Generic**: OpenAI / Claude / Gemini compatible, cliproxyapi / CPA
|
|
||||||
- **Official**: Aliyun, Zhipu, Doubao Coding Plan, DeepSeek, Moonshot, MiniMax, ModelScope
|
|
||||||
- **OAuth**: Codex, Claude, Gemini CLI, Antigravity
|
|
||||||
|
|
||||||
| Pain Point | BoosAPI Solution |
|
### Unified API Gateway
|
||||||
|------------|-----------------|
|
|
||||||
| 🔑 One key per station, messy configs | **Unified proxy entry + multi-key strategy** |
|
|
||||||
| 💸 Which station is cheapest? | **Smart routing** by cost, balance, usage |
|
|
||||||
| 🔄 Manual failover | **Auto failover** with cooldown and retry |
|
|
||||||
| 📊 Scattered balances | **Central dashboard** with alerts |
|
|
||||||
| ✅ Daily check-ins | **Auto check-in** with reward tracking |
|
|
||||||
| 🤷 What models are available? | **Auto model discovery**, zero config |
|
|
||||||
| 🎨 ComfyUI workflows too complex? | **Chat assistant**, describe in natural language |
|
|
||||||
| 👥 Need multi-user access? | **Organizations**, member invites, role-based access |
|
|
||||||
| 📋 Different teams need quotas? | **Plan system**, Free/Pro/Enterprise tiers |
|
|
||||||
|
|
||||||
---
|
- OpenAI-compatible `/v1/chat/completions`, `/v1/responses`, `/v1/images/generations`, `/v1/models`, and files endpoints
|
||||||
|
- Claude-compatible `/v1/messages`, including request/response and streaming event conversion
|
||||||
|
- Smart token routing, channel cooldown, failover, model discovery, and usage accounting
|
||||||
|
- Admin and user dashboards with API key management and model visibility controls
|
||||||
|
|
||||||
## ✨ Features
|
### Workflow Studio
|
||||||
|
|
||||||
### 🎨 Built-in ComfyUI Chat Assistant
|
Workflow Studio uses an embedded ComfyUI frontend. The execution backend is selected by `comfyui.backend`:
|
||||||
|
|
||||||
- **Natural language to workflow**: describe what you want, AI builds the ComfyUI pipeline
|
| backend | Purpose |
|
||||||
- **Multi-view generation**: front/back/left/right directional image generation
|
|---------|---------|
|
||||||
- **Scene/video generation**: text description directly generates video content
|
| `cloud` | Proxy Comfy Cloud API for cloud execution and subscription models |
|
||||||
- **SSE real-time streaming**: generation progress feedback
|
| `runninghub` | Proxy RunningHub's ComfyUI-compatible API for its plugins, models, and workflows |
|
||||||
- Sidebar "Chat Assistant" entry, same UI as the aggregation gateway
|
| `external` | Proxy a full ComfyUI instance you deploy yourself |
|
||||||
- Auto-connects to ComfyUI API (`:8188`), zero configuration required
|
| `boosapi` | Use BoosAPI's Rust API-native workflow executor and configured model routes |
|
||||||
|
| `docker` | Local full ComfyUI mode for GPU machines and local model directories |
|
||||||
|
|
||||||
### 🌐 Unified Proxy Gateway
|
Cloud, RunningHub, external API, and BoosAPI executor modes do not require a GPU on the BoosAPI server. A GPU is only needed when you run local ComfyUI inference on the same machine.
|
||||||
|
|
||||||
- Compatible with **OpenAI** and **Claude** downstream formats
|
### Assistants
|
||||||
- Responses / Chat Completions / Messages / Completions / Embeddings / Images / Models / Files
|
|
||||||
- SSE streaming with automatic format conversion
|
|
||||||
|
|
||||||
### 🧠 Smart Routing Engine
|
- Video / workflow assistant with Markdown chat, reasoning sections, attachments, and model route selection
|
||||||
|
- Picture assistant using `/v1/images/generations`, background tasks, progress display, cancellation, and Workspace archive
|
||||||
|
- Volcengine video task templates for create-task and query-task flows
|
||||||
|
|
||||||
- Auto-discovers models from all upstream stations
|
## Hardware Guide
|
||||||
- Four-tier cost signals: measured → configured → catalog → fallback
|
|
||||||
- Multi-channel weighted distribution (cost 40%, balance 30%, usage 30%)
|
|
||||||
- Auto cooldown on failure (default 10 min), seamless channel switching
|
|
||||||
|
|
||||||
### 📡 Multi-Platform Management
|
| Mode | CPU | Memory | Disk | GPU |
|
||||||
|
|------|-----|--------|------|-----|
|
||||||
|
| Gateway, admin UI, `/v1/*` proxy only | 2 cores minimum, 4 recommended | 2GB minimum, 4GB recommended | 10GB+ | Not required |
|
||||||
|
| BoosAPI + Workflow UI + remote APIs / Comfy Cloud / RunningHub | 4 cores recommended | 4GB minimum, 8GB recommended | 20-50GB | Not required |
|
||||||
|
| External ComfyUI proxy | 4 cores recommended | 8GB recommended | 50GB+ | Not on BoosAPI; required by the ComfyUI machine as needed |
|
||||||
|
| Local Docker / local ComfyUI image generation | 4-8 cores | 16GB+ | 100GB+ | NVIDIA 8GB VRAM minimum, 12GB+ recommended |
|
||||||
|
| Local video / digital human / large workflows | 8 cores+ | 32GB minimum, 64GB recommended | 200GB-1TB | NVIDIA 24GB VRAM minimum, 48GB+ recommended |
|
||||||
|
|
||||||
| Platform | Adapter |
|
Recommended SaaS gateway server:
|
||||||
|----------|---------|
|
|
||||||
| New API | `new-api` |
|
|
||||||
| One API | `one-api` |
|
|
||||||
| OneHub | `onehub` |
|
|
||||||
| DoneHub | `done-hub` |
|
|
||||||
| Veloera | `veloera` |
|
|
||||||
| AnyRouter | `anyrouter` |
|
|
||||||
| Sub2API | `sub2api` |
|
|
||||||
|
|
||||||
### 👥 Account & Token Management
|
```txt
|
||||||
|
4 CPU cores
|
||||||
- Multi-station, multi-account with health tracking (healthy / unhealthy / degraded / disabled)
|
8GB RAM
|
||||||
- Encrypted credential storage, auto-renewal, cascading disable
|
50GB SSD
|
||||||
|
No GPU required
|
||||||
### 💰 Balance Management
|
|
||||||
|
|
||||||
- Scheduled refresh (default hourly), income/expense trends
|
|
||||||
- Fallback estimation when API is unavailable
|
|
||||||
|
|
||||||
### 👥 SaaS Multi-Tenant
|
|
||||||
|
|
||||||
- **Organizations**: Create orgs, each with independent members and quotas
|
|
||||||
- **Member Invitations**: Email-based invites with admin/member roles
|
|
||||||
- **Three Plans**: Free (3 members) / Pro (10) / Enterprise (100)
|
|
||||||
- **Role Control**: Owner / Admin / Member with granular permissions
|
|
||||||
- **Plan Enforcement**: Auto-limit members, API keys, daily requests
|
|
||||||
- **Invite Links**: Token-based, 48h expiry
|
|
||||||
|
|
||||||
### 🔔 Alerts & Notifications
|
|
||||||
|
|
||||||
Five channels: Webhook / Bark / ServerChan / Telegram Bot / SMTP
|
|
||||||
|
|
||||||
Scenarios: low balance, station failure, check-in failure, proxy errors, token expiry, daily digest
|
|
||||||
|
|
||||||
### 🔌 REST API Overview
|
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
|
||||||
|----------|--------|-------------|
|
|
||||||
| `/api/organizations` | GET/POST | List / Create org |
|
|
||||||
| `/api/organizations/plans` | GET | List available plans |
|
|
||||||
| `/api/organizations/:id` | GET/PATCH | Org detail / Update |
|
|
||||||
| `/api/organizations/:id/members` | GET | List members |
|
|
||||||
| `/api/organizations/:id/members/:uid` | PATCH/DELETE | Change role / Remove |
|
|
||||||
| `/api/organizations/:id/invitations` | GET/POST | List / Create invite |
|
|
||||||
| `/api/organizations/:id/invitations/:invId` | DELETE | Cancel invite |
|
|
||||||
| `/api/invitations/:token` | GET | Public invite lookup |
|
|
||||||
| `/api/invitations/:token/accept` | POST | Accept invitation |
|
|
||||||
| `/api/users/register` | POST | Register new user |
|
|
||||||
| `/api/users/login` | POST | Login (returns JWT) |
|
|
||||||
| `/api/users/me` | GET/PATCH | Profile / Update |
|
|
||||||
| `/api/user-api-keys` | GET/POST | List / Create API key |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
### Docker Compose (Recommended)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir boosapi && cd boosapi
|
|
||||||
|
|
||||||
cat > docker-compose.yml << 'EOF'
|
|
||||||
services:
|
|
||||||
boosapi:
|
|
||||||
image: 1467078763/boosapi:latest
|
|
||||||
ports:
|
|
||||||
- "4000:4000"
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
environment:
|
|
||||||
AUTH_TOKEN: ${AUTH_TOKEN:?AUTH_TOKEN is required}
|
|
||||||
PROXY_TOKEN: ${PROXY_TOKEN:?PROXY_TOKEN is required}
|
|
||||||
CHECKIN_CRON: "0 8 * * *"
|
|
||||||
BALANCE_REFRESH_CRON: "0 * * * *"
|
|
||||||
PORT: ${PORT:-4000}
|
|
||||||
DATA_DIR: /app/data
|
|
||||||
TZ: ${TZ:-Asia/Shanghai}
|
|
||||||
restart: unless-stopped
|
|
||||||
EOF
|
|
||||||
|
|
||||||
export AUTH_TOKEN=your-admin-token
|
|
||||||
export PROXY_TOKEN=your-proxy-sk-token
|
|
||||||
docker compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:4000` and log in with `AUTH_TOKEN`.
|
## Quick Start
|
||||||
|
|
||||||
---
|
```bash
|
||||||
|
cp config.toml.example config.toml
|
||||||
|
./build-and-run.sh
|
||||||
|
```
|
||||||
|
|
||||||
## 🏗️ Tech Stack
|
For a production bundle with embedded web assets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build-and-run.sh --production
|
||||||
|
```
|
||||||
|
|
||||||
|
The production output is intended to run with only:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
boosapi
|
||||||
|
config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Web assets and Workflow UI assets are embedded in the Rust binary when built for production.
|
||||||
|
|
||||||
|
## Workflow Backend Configuration
|
||||||
|
|
||||||
|
Comfy Cloud:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[comfyui]
|
||||||
|
backend = "cloud"
|
||||||
|
base_url = "https://cloud.comfy.org"
|
||||||
|
api_key = "your-comfy-cloud-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
RunningHub:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[comfyui]
|
||||||
|
backend = "runninghub"
|
||||||
|
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
|
||||||
|
api_key = "your-runninghub-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
External ComfyUI:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[comfyui]
|
||||||
|
backend = "external"
|
||||||
|
base_url = "http://127.0.0.1:8188"
|
||||||
|
```
|
||||||
|
|
||||||
|
BoosAPI API-native executor:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[comfyui]
|
||||||
|
backend = "boosapi"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Configuration can be overridden with `BOOSAPI_<SECTION>__<KEY>`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BOOSAPI_SERVER__HOST=0.0.0.0
|
||||||
|
BOOSAPI_SERVER__PORT=4000
|
||||||
|
BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
|
||||||
|
BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
|
||||||
|
BOOSAPI_AUTH__JWT_SECRET=your-jwt-secret
|
||||||
|
BOOSAPI_DB__URL=sqlite:data/boosapi.db?mode=rwc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
| Layer | Tech |
|
| Layer | Tech |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
| Backend | Fastify |
|
| Backend | Rust, Axum 0.8, Tokio |
|
||||||
| Frontend | React 18 + Vite |
|
| Database | SQLx, SQLite runtime path, PostgreSQL schema/migration preparation |
|
||||||
| Language | TypeScript |
|
| Proxy engine | Token Router + Proxy Conductor |
|
||||||
| Styling | Tailwind CSS v4 |
|
| Protocols | OpenAI / Claude / Gemini compatible transformers |
|
||||||
| Database | SQLite / MySQL / PostgreSQL + Drizzle ORM |
|
| Frontend | React 19 + Vite |
|
||||||
| Visualization | VChart |
|
| Workflow UI | Embedded ComfyUI frontend variants for cloud/external-compatible backends |
|
||||||
| Container | Docker + Docker Compose |
|
| Static serving | Rust binary serves `dist/web` and Workflow UI assets |
|
||||||
| Test | Vitest |
|
| Container | Docker / Docker Compose |
|
||||||
|
|
||||||
---
|
Node.js is only a build-time tool for the frontend. Production does not require a Node.js process.
|
||||||
|
|
||||||
## 🛠️ Local Development
|
## Local Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
cargo check
|
||||||
npm run db:migrate
|
cargo run
|
||||||
npm run dev
|
pnpm install
|
||||||
|
pnpm run dev:web
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Build checks:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build # Build web + server
|
cargo check
|
||||||
npm test # Run tests
|
pnpm run typecheck:web
|
||||||
|
npx vite build
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## License
|
||||||
|
|
||||||
## 📜 License
|
|
||||||
|
|
||||||
[MIT](LICENSE)
|
[MIT](LICENSE)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
+368
-22
@@ -17,10 +17,17 @@ fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
|
|||||||
BUILD_PROFILE="${BOOSAPI_BUILD_PROFILE:-dev}"
|
BUILD_PROFILE="${BOOSAPI_BUILD_PROFILE:-dev}"
|
||||||
FRONTEND_ONLY=0
|
FRONTEND_ONLY=0
|
||||||
BACKEND_ONLY=0
|
BACKEND_ONLY=0
|
||||||
|
CHECK_ONLY=0
|
||||||
NO_RUN=0
|
NO_RUN=0
|
||||||
FORCE_FRONTEND=0
|
FORCE_FRONTEND=0
|
||||||
SKIP_FRONTEND=0
|
SKIP_FRONTEND=0
|
||||||
|
PRODUCTION_BUNDLE=0
|
||||||
|
PRODUCTION_DIR="${BOOSAPI_PRODUCTION_DIR:-$ROOT_DIR/dist/production}"
|
||||||
CARGO_FEATURES="${BOOSAPI_CARGO_FEATURES:-}"
|
CARGO_FEATURES="${BOOSAPI_CARGO_FEATURES:-}"
|
||||||
|
CARGO_JOBS="${BOOSAPI_CARGO_JOBS:-}"
|
||||||
|
FAST_LINKER="${BOOSAPI_FAST_LINKER:-auto}"
|
||||||
|
FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}"
|
||||||
|
BUILD_TIMINGS=()
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
@@ -31,18 +38,33 @@ usage() {
|
|||||||
--release 使用 release 模式编译后端(更慢但性能好)
|
--release 使用 release 模式编译后端(更慢但性能好)
|
||||||
--fast-release 使用 fast-release 模式编译后端(比 release 快,性能接近生产)
|
--fast-release 使用 fast-release 模式编译后端(比 release 快,性能接近生产)
|
||||||
--profile <name> 指定构建 profile:dev / release / fast-release
|
--profile <name> 指定构建 profile:dev / release / fast-release
|
||||||
|
--production 生产编译:先构建前端并嵌入二进制,再输出 dist/production/{boosapi,config.toml}
|
||||||
|
--prod 同 --production
|
||||||
|
--production-dir <dir>
|
||||||
|
指定生产产物目录(默认:dist/production)
|
||||||
|
--check 只执行 cargo check,不构建前端、不启动服务(最快验证)
|
||||||
|
--backend-check 同 --check
|
||||||
--frontend-only 只构建前端
|
--frontend-only 只构建前端
|
||||||
--backend-only 只构建后端
|
--backend-only 只构建后端
|
||||||
--force-frontend 强制重建前端,忽略缓存
|
--force-frontend 强制重建前端,忽略缓存
|
||||||
--skip-frontend 跳过前端构建(沿用现有 dist/web)
|
--skip-frontend 跳过前端构建(沿用现有 dist/web)
|
||||||
|
--bun 主前端使用 Bun 执行 Vite 构建(Workflow UI 子模块会自动准备 pnpm)
|
||||||
--no-run 构建后不启动服务
|
--no-run 构建后不启动服务
|
||||||
BOOSAPI_BUILD_PROFILE 可设置默认 profile(dev / release / fast-release)
|
BOOSAPI_BUILD_PROFILE 可设置默认 profile(dev / release / fast-release)
|
||||||
BOOSAPI_CARGO_FEATURES 可覆盖后端 Cargo features(默认:空)
|
BOOSAPI_CARGO_FEATURES 可覆盖后端 Cargo features(默认:空)
|
||||||
|
BOOSAPI_CARGO_JOBS 可设置 cargo 并发任务数,例如 8
|
||||||
|
BOOSAPI_FAST_LINKER 可设置 auto / off / mold / lld(默认:auto,Linux 下优先 mold)
|
||||||
|
BOOSAPI_FRONTEND_PM 可设置主前端构建器:auto / npm / bun(默认:auto)
|
||||||
|
BOOSAPI_PRODUCTION_DIR 可设置 --production 默认产物目录
|
||||||
-h, --help 显示帮助
|
-h, --help 显示帮助
|
||||||
|
|
||||||
示例:
|
示例:
|
||||||
./build-and-run.sh --release
|
./build-and-run.sh --release
|
||||||
./build-and-run.sh --fast-release
|
./build-and-run.sh --fast-release
|
||||||
|
./build-and-run.sh --check
|
||||||
|
./build-and-run.sh --bun
|
||||||
|
./build-and-run.sh --production
|
||||||
|
./build-and-run.sh --production --production-dir /opt/boosapi-release
|
||||||
BOOSAPI_BUILD_PROFILE=release ./build-and-run.sh
|
BOOSAPI_BUILD_PROFILE=release ./build-and-run.sh
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
@@ -52,6 +74,21 @@ while [ $# -gt 0 ]; do
|
|||||||
--dev) BUILD_PROFILE="dev" ;;
|
--dev) BUILD_PROFILE="dev" ;;
|
||||||
--release) BUILD_PROFILE="release" ;;
|
--release) BUILD_PROFILE="release" ;;
|
||||||
--fast-release) BUILD_PROFILE="fast-release" ;;
|
--fast-release) BUILD_PROFILE="fast-release" ;;
|
||||||
|
--production|--prod)
|
||||||
|
PRODUCTION_BUNDLE=1
|
||||||
|
BUILD_PROFILE="release"
|
||||||
|
NO_RUN=1
|
||||||
|
;;
|
||||||
|
--production-dir)
|
||||||
|
shift
|
||||||
|
[ $# -gt 0 ] || fail "--production-dir 需要目录参数"
|
||||||
|
PRODUCTION_DIR="$1"
|
||||||
|
;;
|
||||||
|
--check|--backend-check)
|
||||||
|
CHECK_ONLY=1
|
||||||
|
BACKEND_ONLY=1
|
||||||
|
NO_RUN=1
|
||||||
|
;;
|
||||||
--profile)
|
--profile)
|
||||||
shift
|
shift
|
||||||
[ $# -gt 0 ] || fail "--profile 需要参数:dev / release / fast-release"
|
[ $# -gt 0 ] || fail "--profile 需要参数:dev / release / fast-release"
|
||||||
@@ -61,6 +98,7 @@ while [ $# -gt 0 ]; do
|
|||||||
--backend-only) BACKEND_ONLY=1 ;;
|
--backend-only) BACKEND_ONLY=1 ;;
|
||||||
--force-frontend) FORCE_FRONTEND=1 ;;
|
--force-frontend) FORCE_FRONTEND=1 ;;
|
||||||
--skip-frontend) SKIP_FRONTEND=1 ;;
|
--skip-frontend) SKIP_FRONTEND=1 ;;
|
||||||
|
--bun) FRONTEND_PM="bun" ;;
|
||||||
--no-run) NO_RUN=1 ;;
|
--no-run) NO_RUN=1 ;;
|
||||||
--rebuild-image) log "--rebuild-image 已废弃:内置 Docker ComfyUI 工作台已移除,忽略该参数" ;;
|
--rebuild-image) log "--rebuild-image 已废弃:内置 Docker ComfyUI 工作台已移除,忽略该参数" ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
@@ -73,12 +111,122 @@ if [ "$FRONTEND_ONLY" -eq 1 ] && [ "$BACKEND_ONLY" -eq 1 ]; then
|
|||||||
fail "--frontend-only 和 --backend-only 不能同时使用"
|
fail "--frontend-only 和 --backend-only 不能同时使用"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$FRONTEND_ONLY" -eq 1 ]; then
|
||||||
|
fail "--production 不能和 --frontend-only 同时使用"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$BACKEND_ONLY" -eq 1 ]; then
|
||||||
|
fail "--production 需要先构建前端以嵌入 web,不能和 --backend-only 同时使用"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$CHECK_ONLY" -eq 1 ]; then
|
||||||
|
fail "--production 不能和 --check 同时使用"
|
||||||
|
fi
|
||||||
|
|
||||||
case "$BUILD_PROFILE" in
|
case "$BUILD_PROFILE" in
|
||||||
dev|debug) BUILD_PROFILE="dev" ;;
|
dev|debug) BUILD_PROFILE="dev" ;;
|
||||||
release|fast-release) ;;
|
release|fast-release) ;;
|
||||||
*) fail "未知构建 profile: $BUILD_PROFILE(可选:dev / release / fast-release)" ;;
|
*) fail "未知构建 profile: $BUILD_PROFILE(可选:dev / release / fast-release)" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
case "$FAST_LINKER" in
|
||||||
|
auto|off|none|mold|lld) ;;
|
||||||
|
*) fail "未知 BOOSAPI_FAST_LINKER: $FAST_LINKER(可选:auto / off / mold / lld)" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
timer_start() {
|
||||||
|
date +%s
|
||||||
|
}
|
||||||
|
|
||||||
|
record_timing() {
|
||||||
|
local label="$1"
|
||||||
|
local started="$2"
|
||||||
|
local ended
|
||||||
|
ended="$(date +%s)"
|
||||||
|
BUILD_TIMINGS+=("${label}: $((ended - started))s")
|
||||||
|
}
|
||||||
|
|
||||||
|
print_timing_summary() {
|
||||||
|
[ "${#BUILD_TIMINGS[@]}" -gt 0 ] || return 0
|
||||||
|
log "构建耗时:"
|
||||||
|
local item
|
||||||
|
for item in "${BUILD_TIMINGS[@]}"; do
|
||||||
|
log " - $item"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
append_rustflags() {
|
||||||
|
local flags="$1"
|
||||||
|
if [[ " ${RUSTFLAGS:-} " != *" $flags "* ]]; then
|
||||||
|
export RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }$flags"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
build_cargo_args() {
|
||||||
|
CARGO_BASE_ARGS=()
|
||||||
|
CARGO_FEATURE_ARGS=()
|
||||||
|
|
||||||
|
if [ -n "$CARGO_JOBS" ]; then
|
||||||
|
CARGO_BASE_ARGS+=(--jobs "$CARGO_JOBS")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$CARGO_FEATURES" ]; then
|
||||||
|
CARGO_FEATURE_ARGS=(--features "$CARGO_FEATURES")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_fast_linker() {
|
||||||
|
local mode="$FAST_LINKER"
|
||||||
|
|
||||||
|
case "$(uname -s 2>/dev/null || true)" in
|
||||||
|
Linux) ;;
|
||||||
|
*)
|
||||||
|
[ "$mode" = "auto" ] && return 0
|
||||||
|
fail "BOOSAPI_FAST_LINKER=$mode 目前只在 Linux 构建环境自动配置"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$mode" = "off" ] || [ "$mode" = "none" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$mode" = "auto" ]; then
|
||||||
|
if command -v mold >/dev/null 2>&1; then
|
||||||
|
mode="mold"
|
||||||
|
elif command -v ld.lld >/dev/null 2>&1 || command -v lld >/dev/null 2>&1; then
|
||||||
|
mode="lld"
|
||||||
|
else
|
||||||
|
log "提示: 安装 mold 或 lld 可显著缩短 Rust 链接时间;当前使用系统默认链接器"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
mold)
|
||||||
|
command -v mold >/dev/null 2>&1 || fail "BOOSAPI_FAST_LINKER=mold 但未找到 mold"
|
||||||
|
if command -v clang >/dev/null 2>&1; then
|
||||||
|
append_rustflags "-C linker=clang"
|
||||||
|
elif ! command -v gcc >/dev/null 2>&1 && [ -z "${RUSTC_LINKER:-}" ]; then
|
||||||
|
fail "启用 mold 需要 clang 或 gcc 作为 linker driver"
|
||||||
|
fi
|
||||||
|
append_rustflags "-C link-arg=-fuse-ld=mold"
|
||||||
|
log "Rust 快速链接器: mold(BOOSAPI_FAST_LINKER=$FAST_LINKER)"
|
||||||
|
;;
|
||||||
|
lld)
|
||||||
|
if ! command -v ld.lld >/dev/null 2>&1 && ! command -v lld >/dev/null 2>&1; then
|
||||||
|
fail "BOOSAPI_FAST_LINKER=lld 但未找到 ld.lld/lld"
|
||||||
|
fi
|
||||||
|
if command -v clang >/dev/null 2>&1; then
|
||||||
|
append_rustflags "-C linker=clang"
|
||||||
|
elif ! command -v gcc >/dev/null 2>&1 && [ -z "${RUSTC_LINKER:-}" ]; then
|
||||||
|
fail "启用 lld 需要 clang 或 gcc 作为 linker driver"
|
||||||
|
fi
|
||||||
|
append_rustflags "-C link-arg=-fuse-ld=lld"
|
||||||
|
log "Rust 快速链接器: lld(BOOSAPI_FAST_LINKER=$FAST_LINKER)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
server_port() {
|
server_port() {
|
||||||
if [ -n "${BOOSAPI_SERVER__PORT:-}" ]; then
|
if [ -n "${BOOSAPI_SERVER__PORT:-}" ]; then
|
||||||
printf '%s\n' "$BOOSAPI_SERVER__PORT"
|
printf '%s\n' "$BOOSAPI_SERVER__PORT"
|
||||||
@@ -161,25 +309,73 @@ stop_previous_server() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
build_frontend() {
|
ensure_pnpm() {
|
||||||
if [ "$SKIP_FRONTEND" -eq 1 ]; then
|
if command -v pnpm >/dev/null 2>&1; then
|
||||||
log "跳过前端构建(--skip-frontend)"
|
return 0
|
||||||
|
fi
|
||||||
|
command -v corepack >/dev/null 2>&1 || fail "未找到 pnpm/corepack,无法自动准备 pnpm。请安装 Node.js 22+ 或 pnpm"
|
||||||
|
log "未找到 pnpm,尝试通过 corepack 自动启用..."
|
||||||
|
corepack enable
|
||||||
|
corepack prepare pnpm@latest --activate
|
||||||
|
command -v pnpm >/dev/null 2>&1 || fail "pnpm 自动准备失败,请手动安装 pnpm"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_frontend_dependencies() {
|
||||||
|
local frontend_pm="$1"
|
||||||
|
if [ "$frontend_pm" = "bun" ]; then
|
||||||
|
command -v bun >/dev/null 2>&1 || fail "未找到 bun,请安装 Bun 或移除 --bun"
|
||||||
|
if [ ! -d "$ROOT_DIR/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
|
||||||
|
log "安装主前端依赖 (bun install)..."
|
||||||
|
bun install
|
||||||
|
fi
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v npx >/dev/null 2>&1; then
|
command -v node >/dev/null 2>&1 || fail "未找到 node,请先安装 Node.js"
|
||||||
fail "未找到 npx,请先安装 Node.js"
|
ensure_pnpm
|
||||||
|
if [ ! -d "$ROOT_DIR/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
|
||||||
|
log "安装主前端依赖 (pnpm install)..."
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
fi
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
build_frontend() {
|
||||||
|
local started
|
||||||
|
started="$(timer_start)"
|
||||||
|
|
||||||
|
if [ "$SKIP_FRONTEND" -eq 1 ]; then
|
||||||
|
log "跳过前端构建(--skip-frontend)"
|
||||||
|
record_timing "前端检查" "$started"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local frontend_pm="$FRONTEND_PM"
|
||||||
|
case "$frontend_pm" in
|
||||||
|
auto)
|
||||||
|
if command -v bun >/dev/null 2>&1; then
|
||||||
|
frontend_pm="bun"
|
||||||
|
else
|
||||||
|
frontend_pm="npm"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
npm|bun) ;;
|
||||||
|
*) fail "未知 BOOSAPI_FRONTEND_PM: $frontend_pm(可选:auto / npm / bun)" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
install_frontend_dependencies "$frontend_pm"
|
||||||
|
|
||||||
local fingerprint_file="$ROOT_DIR/dist/web/.boosapi-build-fingerprint"
|
local fingerprint_file="$ROOT_DIR/dist/web/.boosapi-build-fingerprint"
|
||||||
local fingerprint
|
local fingerprint
|
||||||
fingerprint="$(
|
fingerprint="$(
|
||||||
{
|
{
|
||||||
printf 'build-script-v2\n'
|
printf 'build-script-v2\n'
|
||||||
|
printf 'frontend_pm=%s\n' "$frontend_pm"
|
||||||
printf 'node='
|
printf 'node='
|
||||||
node --version 2>/dev/null || true
|
node --version 2>/dev/null || true
|
||||||
printf 'npx='
|
printf 'npx='
|
||||||
npx --version 2>/dev/null || true
|
npx --version 2>/dev/null || true
|
||||||
|
printf 'bun='
|
||||||
|
bun --version 2>/dev/null || true
|
||||||
for path in package.json pnpm-lock.yaml package-lock.json yarn.lock vite.config.ts tsconfig.json tsconfig.web.json tsconfig.transpile.json src/web src/shared; do
|
for path in package.json pnpm-lock.yaml package-lock.json yarn.lock vite.config.ts tsconfig.json tsconfig.web.json tsconfig.transpile.json src/web src/shared; do
|
||||||
[ -e "$ROOT_DIR/$path" ] && find "$ROOT_DIR/$path" -type f -print
|
[ -e "$ROOT_DIR/$path" ] && find "$ROOT_DIR/$path" -type f -print
|
||||||
done | sort | xargs -r sha256sum
|
done | sort | xargs -r sha256sum
|
||||||
@@ -191,49 +387,179 @@ build_frontend() {
|
|||||||
&& [ -f "$ROOT_DIR/dist/web/index.html" ] \
|
&& [ -f "$ROOT_DIR/dist/web/index.html" ] \
|
||||||
&& [ "$(cat "$fingerprint_file" 2>/dev/null || true)" = "$fingerprint" ]; then
|
&& [ "$(cat "$fingerprint_file" 2>/dev/null || true)" = "$fingerprint" ]; then
|
||||||
ok "前端无变化,跳过 Vite 构建 → dist/web/"
|
ok "前端无变化,跳过 Vite 构建 → dist/web/"
|
||||||
|
record_timing "前端检查" "$started"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "构建前端 (Vite)..."
|
log "构建前端 (Vite via ${frontend_pm})..."
|
||||||
# 直接调用 vite build,跳过 desktop:icons(脚本文件不存在)
|
# 直接调用 vite build;桌面端构建链路已移除。
|
||||||
npx vite build
|
if [ "$frontend_pm" = "bun" ]; then
|
||||||
|
bunx --bun vite build
|
||||||
|
else
|
||||||
|
pnpm exec vite build
|
||||||
|
fi
|
||||||
mkdir -p "$ROOT_DIR/dist/web"
|
mkdir -p "$ROOT_DIR/dist/web"
|
||||||
printf '%s\n' "$fingerprint" > "$fingerprint_file"
|
printf '%s\n' "$fingerprint" > "$fingerprint_file"
|
||||||
ok "前端构建完成 → dist/web/"
|
ok "前端构建完成 → dist/web/"
|
||||||
|
record_timing "前端构建" "$started"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_website_dependencies() {
|
||||||
|
command -v node >/dev/null 2>&1 || fail "未找到 node,请先安装 Node.js"
|
||||||
|
if [ ! -d "$ROOT_DIR/website/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
|
||||||
|
log "安装官网依赖 (npm --prefix website install)..."
|
||||||
|
npm --prefix "$ROOT_DIR/website" install
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
build_public_website() {
|
||||||
|
local started
|
||||||
|
started="$(timer_start)"
|
||||||
|
|
||||||
|
if [ "$SKIP_FRONTEND" -eq 1 ]; then
|
||||||
|
log "跳过官网构建(--skip-frontend)"
|
||||||
|
record_timing "官网检查" "$started"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -f "$ROOT_DIR/website/package.json" ] || fail "未找到 website/package.json,无法构建官网"
|
||||||
|
install_website_dependencies
|
||||||
|
|
||||||
|
local fingerprint_file="$ROOT_DIR/dist/website/.bossai-website-build-fingerprint"
|
||||||
|
local fingerprint
|
||||||
|
fingerprint="$(
|
||||||
|
{
|
||||||
|
printf 'website-build-v1\n'
|
||||||
|
printf 'node='
|
||||||
|
node --version 2>/dev/null || true
|
||||||
|
printf 'npm='
|
||||||
|
npm --version 2>/dev/null || true
|
||||||
|
for path in website/package.json website/package-lock.json website/next.config.ts website/postcss.config.mjs website/tsconfig.json website/app website/public; do
|
||||||
|
[ -e "$ROOT_DIR/$path" ] && find "$ROOT_DIR/$path" -type f -print
|
||||||
|
done | sort | xargs -r sha256sum
|
||||||
|
} | sha256sum | awk '{print $1}'
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [ "$FORCE_FRONTEND" -eq 0 ] \
|
||||||
|
&& [ -f "$fingerprint_file" ] \
|
||||||
|
&& [ -f "$ROOT_DIR/dist/website/index.html" ] \
|
||||||
|
&& [ "$(cat "$fingerprint_file" 2>/dev/null || true)" = "$fingerprint" ]; then
|
||||||
|
ok "官网无变化,跳过 Next 构建 → dist/website/"
|
||||||
|
record_timing "官网检查" "$started"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "构建官网 (Next.js static export)..."
|
||||||
|
npm --prefix "$ROOT_DIR/website" run build
|
||||||
|
rm -rf "$ROOT_DIR/dist/website"
|
||||||
|
mkdir -p "$ROOT_DIR/dist/website"
|
||||||
|
cp -R "$ROOT_DIR/website/out/." "$ROOT_DIR/dist/website/"
|
||||||
|
printf '%s\n' "$fingerprint" > "$fingerprint_file"
|
||||||
|
ok "官网构建完成 → dist/website/"
|
||||||
|
record_timing "官网构建" "$started"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_workflow_ui_for_embedding() {
|
||||||
|
local started
|
||||||
|
started="$(timer_start)"
|
||||||
|
|
||||||
|
if [ "$SKIP_FRONTEND" -eq 1 ]; then
|
||||||
|
log "跳过 Workflow UI 构建检查(--skip-frontend)"
|
||||||
|
record_timing "Workflow UI 检查" "$started"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$ROOT_DIR/dist/workflow-ui-external/index.html" ] \
|
||||||
|
&& [ -f "$ROOT_DIR/dist/workflow-ui-cloud/index.html" ] \
|
||||||
|
&& [ -f "$ROOT_DIR/dist/workflow-ui/index.html" ] \
|
||||||
|
&& [ "$FORCE_FRONTEND" -eq 0 ]; then
|
||||||
|
ok "Workflow UI 已存在,跳过构建 → dist/workflow-ui-{external,cloud}/"
|
||||||
|
record_timing "Workflow UI 检查" "$started"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
command -v git >/dev/null 2>&1 || fail "未找到 git,无法构建 Workflow UI"
|
||||||
|
command -v node >/dev/null 2>&1 || fail "未找到 node,无法构建 Workflow UI"
|
||||||
|
ensure_pnpm
|
||||||
|
|
||||||
|
log "构建 Workflow UI 双前端(external + cloud,用于嵌入二进制)..."
|
||||||
|
pnpm run workflow-ui:install
|
||||||
|
ok "Workflow UI 构建完成 → dist/workflow-ui-external/ dist/workflow-ui-cloud/"
|
||||||
|
record_timing "Workflow UI 构建" "$started"
|
||||||
}
|
}
|
||||||
|
|
||||||
build_backend() {
|
build_backend() {
|
||||||
|
local started
|
||||||
|
started="$(timer_start)"
|
||||||
|
|
||||||
if ! command -v cargo >/dev/null 2>&1; then
|
if ! command -v cargo >/dev/null 2>&1; then
|
||||||
fail "未找到 cargo,请先安装 Rust 工具链"
|
fail "未找到 cargo,请先安装 Rust 工具链"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 提示一次:如果链接器走默认 ld 而 mold 不在 PATH,给出一条性能建议。
|
configure_fast_linker
|
||||||
# mold 安装后还需在 .cargo/config.toml 取消 `linker = "clang"` 那段注释。
|
build_cargo_args
|
||||||
if ! command -v mold >/dev/null 2>&1; then
|
|
||||||
log "提示: 安装 mold 后取消 .cargo/config.toml 中的相应注释,链接将快 3-5x"
|
|
||||||
fi
|
|
||||||
|
|
||||||
CARGO_FEATURE_ARGS=()
|
if [ "$CHECK_ONLY" -eq 1 ]; then
|
||||||
if [ -n "$CARGO_FEATURES" ]; then
|
if [ "$BUILD_PROFILE" = "release" ]; then
|
||||||
CARGO_FEATURE_ARGS=(--features "$CARGO_FEATURES")
|
log "检查后端 (cargo check --release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
|
||||||
|
cargo check "${CARGO_BASE_ARGS[@]}" --release "${CARGO_FEATURE_ARGS[@]}"
|
||||||
|
elif [ "$BUILD_PROFILE" = "fast-release" ]; then
|
||||||
|
log "检查后端 (cargo check --profile fast-release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
|
||||||
|
cargo check "${CARGO_BASE_ARGS[@]}" --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
|
||||||
|
else
|
||||||
|
log "检查后端 (cargo check ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
|
||||||
|
cargo check "${CARGO_BASE_ARGS[@]}" "${CARGO_FEATURE_ARGS[@]}"
|
||||||
|
fi
|
||||||
|
ok "后端检查完成"
|
||||||
|
record_timing "后端检查" "$started"
|
||||||
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$BUILD_PROFILE" = "release" ]; then
|
if [ "$BUILD_PROFILE" = "release" ]; then
|
||||||
log "构建后端 (cargo build --release ${CARGO_FEATURE_ARGS[*]})..."
|
log "构建后端 (cargo build --release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
|
||||||
cargo build --release "${CARGO_FEATURE_ARGS[@]}"
|
cargo build "${CARGO_BASE_ARGS[@]}" --release "${CARGO_FEATURE_ARGS[@]}"
|
||||||
BINARY="$ROOT_DIR/target/release/boosapi"
|
BINARY="$ROOT_DIR/target/release/boosapi"
|
||||||
ok "后端构建完成 → target/release/boosapi"
|
ok "后端构建完成 → target/release/boosapi"
|
||||||
elif [ "$BUILD_PROFILE" = "fast-release" ]; then
|
elif [ "$BUILD_PROFILE" = "fast-release" ]; then
|
||||||
log "构建后端 (cargo build --profile fast-release ${CARGO_FEATURE_ARGS[*]})..."
|
log "构建后端 (cargo build --profile fast-release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
|
||||||
cargo build --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
|
cargo build "${CARGO_BASE_ARGS[@]}" --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
|
||||||
BINARY="$ROOT_DIR/target/fast-release/boosapi"
|
BINARY="$ROOT_DIR/target/fast-release/boosapi"
|
||||||
ok "后端构建完成 → target/fast-release/boosapi"
|
ok "后端构建完成 → target/fast-release/boosapi"
|
||||||
else
|
else
|
||||||
log "构建后端 (cargo build ${CARGO_FEATURE_ARGS[*]})..."
|
log "构建后端 (cargo build ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
|
||||||
cargo build "${CARGO_FEATURE_ARGS[@]}"
|
cargo build "${CARGO_BASE_ARGS[@]}" "${CARGO_FEATURE_ARGS[@]}"
|
||||||
BINARY="$ROOT_DIR/target/debug/boosapi"
|
BINARY="$ROOT_DIR/target/debug/boosapi"
|
||||||
ok "后端构建完成 → target/debug/boosapi"
|
ok "后端构建完成 → target/debug/boosapi"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
record_timing "后端构建" "$started"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_production_bundle() {
|
||||||
|
local started
|
||||||
|
started="$(timer_start)"
|
||||||
|
|
||||||
|
[ -n "${BINARY:-}" ] || fail "生产打包失败:后端二进制路径为空"
|
||||||
|
[ -f "$BINARY" ] || fail "生产打包失败:未找到二进制 $BINARY"
|
||||||
|
[ -f "$ROOT_DIR/config.toml" ] || fail "生产打包失败:未找到 config.toml"
|
||||||
|
|
||||||
|
rm -rf "$PRODUCTION_DIR"
|
||||||
|
mkdir -p "$PRODUCTION_DIR"
|
||||||
|
|
||||||
|
cp "$BINARY" "$PRODUCTION_DIR/boosapi"
|
||||||
|
cp "$ROOT_DIR/config.toml" "$PRODUCTION_DIR/config.toml"
|
||||||
|
chmod +x "$PRODUCTION_DIR/boosapi"
|
||||||
|
|
||||||
|
if command -v strip >/dev/null 2>&1; then
|
||||||
|
log "压缩生产二进制符号表 (strip)..."
|
||||||
|
strip "$PRODUCTION_DIR/boosapi" 2>/dev/null || log "strip 失败,保留未压缩二进制"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ok "生产产物已生成 → $PRODUCTION_DIR"
|
||||||
|
log "产物内容:"
|
||||||
|
(cd "$PRODUCTION_DIR" && ls -lh boosapi config.toml)
|
||||||
|
log "部署时只需要复制该目录内的 boosapi 和 config.toml;主 Web 前端已嵌入二进制。"
|
||||||
|
record_timing "生产打包" "$started"
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup_started_server() {
|
cleanup_started_server() {
|
||||||
@@ -248,21 +574,41 @@ cleanup_started_server() {
|
|||||||
|
|
||||||
if [ "$FRONTEND_ONLY" -eq 1 ]; then
|
if [ "$FRONTEND_ONLY" -eq 1 ]; then
|
||||||
build_frontend
|
build_frontend
|
||||||
|
build_public_website
|
||||||
|
print_timing_summary
|
||||||
log "跳过启动(--frontend-only)"
|
log "跳过启动(--frontend-only)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$BACKEND_ONLY" -ne 1 ]; then
|
if [ "$BACKEND_ONLY" -ne 1 ]; then
|
||||||
build_frontend
|
build_frontend
|
||||||
|
build_public_website
|
||||||
|
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
|
||||||
|
ensure_workflow_ui_for_embedding
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build_backend
|
build_backend
|
||||||
|
|
||||||
|
if [ "$CHECK_ONLY" -eq 1 ]; then
|
||||||
|
print_timing_summary
|
||||||
|
log "跳过启动(--check)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
|
||||||
|
build_production_bundle
|
||||||
|
print_timing_summary
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$NO_RUN" -eq 1 ]; then
|
if [ "$NO_RUN" -eq 1 ]; then
|
||||||
|
print_timing_summary
|
||||||
log "跳过启动(--no-run)"
|
log "跳过启动(--no-run)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
print_timing_summary
|
||||||
stop_previous_server
|
stop_previous_server
|
||||||
|
|
||||||
log "启动服务..."
|
log "启动服务..."
|
||||||
|
|||||||
+39
-9
@@ -25,22 +25,52 @@ upstream_timeout_secs = 300
|
|||||||
first_byte_timeout_secs = 60
|
first_byte_timeout_secs = 60
|
||||||
max_retries = 3
|
max_retries = 3
|
||||||
proxy_log_retention_days = 30
|
proxy_log_retention_days = 30
|
||||||
|
# Optional proxy for external template galleries/workflow template assets.
|
||||||
|
# Supports http://, https:// and socks5:// proxy URLs.
|
||||||
|
# template_proxy_url = "http://127.0.0.1:7890"
|
||||||
|
|
||||||
|
[payment]
|
||||||
|
enabled = false
|
||||||
|
# Public HTTPS origin used to build Stripe success/cancel URLs and webhook docs.
|
||||||
|
# Example: "https://tybossai.com"
|
||||||
|
public_base_url = ""
|
||||||
|
product_name_prefix = "BossAI 账户充值"
|
||||||
|
currency = "usd"
|
||||||
|
min_amount_usd = 1.0
|
||||||
|
max_amount_usd = 100000.0
|
||||||
|
order_timeout_minutes = 30
|
||||||
|
max_pending_orders = 3
|
||||||
|
success_path = "/user/recharge?payment=success&order={ORDER_ID}"
|
||||||
|
cancel_path = "/user/recharge?payment=cancel&order={ORDER_ID}"
|
||||||
|
# Prefer environment variables for secrets:
|
||||||
|
# BOOSAPI_PAYMENT__STRIPE_SECRET_KEY=sk_live_...
|
||||||
|
# BOOSAPI_PAYMENT__STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
stripe_secret_key = ""
|
||||||
|
stripe_webhook_secret = ""
|
||||||
|
|
||||||
[comfyui]
|
[comfyui]
|
||||||
enabled = false
|
enabled = false
|
||||||
# boosapi = use embedded Workflow API bridge; external/cloud = proxy Workflow
|
|
||||||
# API and WebSocket to a full ComfyUI-compatible backend.
|
|
||||||
backend = "cloud"
|
|
||||||
docker_image = "boosapi-comfyui:multi-tenant"
|
|
||||||
container_name = "boosapi-comfyui-workflow"
|
|
||||||
python_bin = "python3"
|
python_bin = "python3"
|
||||||
port = 8188
|
port = 8188
|
||||||
extra_args = []
|
extra_args = []
|
||||||
max_restarts = 5
|
max_restarts = 5
|
||||||
|
# backend modes:
|
||||||
|
# - "boosapi": use BoosAPI's own Rust Workflow/API node executor.
|
||||||
|
# - "cloud": proxy to Comfy Cloud and use native ComfyUI /prompt and /ws.
|
||||||
|
# - "runninghub": proxy to RunningHub's ComfyUI-compatible API with external-compatible frontend.
|
||||||
|
# - "external": proxy to your own full ComfyUI server.
|
||||||
|
# - "docker": legacy local Docker ComfyUI mode; not recommended for the current API Workflow path.
|
||||||
|
backend = "cloud"
|
||||||
|
# Native Comfy task costs are charged against the user's downstream API Key
|
||||||
|
# only after successful output archival. Keep 0 while pricing is not finalized.
|
||||||
|
money_printer_video_cost = 0.0
|
||||||
|
digital_human_video_cost = 0.0
|
||||||
|
# docker_image = "boosapi-comfyui:multi-tenant"
|
||||||
|
# container_name = "boosapi-comfyui-workflow"
|
||||||
|
# base_url = "https://www.runninghub.cn/proxy-plus/94d1330ea62d4ae980ffb018ecdf82a2"
|
||||||
|
# For RunningHub, set backend = "runninghub" and use your RunningHub proxy/API URL.
|
||||||
|
# RunningHub proxy URL already contains the API key. Keep this empty unless
|
||||||
|
# you switch to a backend that requires Authorization / X-API-Key headers.
|
||||||
base_url = "https://cloud.comfy.org"
|
base_url = "https://cloud.comfy.org"
|
||||||
api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f"
|
api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f"
|
||||||
# For Comfy Cloud:
|
|
||||||
# backend = "cloud"
|
|
||||||
# base_url = "https://cloud.comfy.org"
|
|
||||||
# api_key = "your-comfy-cloud-api-key"
|
|
||||||
agent_model = "gpt-5.5"
|
agent_model = "gpt-5.5"
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ RUN find /workspace/ComfyUI -mindepth 1 \
|
|||||||
&& mkdir -p /workspace/ComfyUI/custom_nodes/boosapi_nodes
|
&& mkdir -p /workspace/ComfyUI/custom_nodes/boosapi_nodes
|
||||||
|
|
||||||
COPY comfy/boosapi_comfy_nodes.py /workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py
|
COPY comfy/boosapi_comfy_nodes.py /workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py
|
||||||
|
COPY scripts/comfyui/install-runninghub-template-nodes.sh /tmp/install-runninghub-template-nodes.sh
|
||||||
|
|
||||||
|
RUN bash /tmp/install-runninghub-template-nodes.sh \
|
||||||
|
&& rm -f /tmp/install-runninghub-template-nodes.sh
|
||||||
|
|
||||||
RUN python - <<'PY'
|
RUN python - <<'PY'
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 99 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 94 KiB |
@@ -1,42 +0,0 @@
|
|||||||
appId: me.cita777.boosapi.desktop
|
|
||||||
productName: Metapi
|
|
||||||
artifactName: ${name}-${version}-${os}-${arch}.${ext}
|
|
||||||
directories:
|
|
||||||
output: release
|
|
||||||
electronDist: node_modules/electron/dist
|
|
||||||
icon: build/desktop-icon.png
|
|
||||||
asar: false
|
|
||||||
files:
|
|
||||||
- dist/**/*
|
|
||||||
- drizzle/**/*
|
|
||||||
- package.json
|
|
||||||
mac:
|
|
||||||
target:
|
|
||||||
- dmg
|
|
||||||
- zip
|
|
||||||
category: public.app-category.developer-tools
|
|
||||||
hardenedRuntime: false
|
|
||||||
gatekeeperAssess: false
|
|
||||||
entitlementsInherit: null
|
|
||||||
win:
|
|
||||||
target:
|
|
||||||
- nsis
|
|
||||||
- zip
|
|
||||||
linux:
|
|
||||||
target:
|
|
||||||
- AppImage
|
|
||||||
- deb
|
|
||||||
- rpm
|
|
||||||
maintainer: cita-777 <cita-777@users.noreply.github.com>
|
|
||||||
publish:
|
|
||||||
provider: github
|
|
||||||
owner: cita-777
|
|
||||||
repo: boosapi
|
|
||||||
releaseType: release
|
|
||||||
publishAutoUpdate: true
|
|
||||||
nsis:
|
|
||||||
oneClick: false
|
|
||||||
perMachine: false
|
|
||||||
allowToChangeInstallationDirectory: true
|
|
||||||
afterPack: scripts/desktop/afterPack.mjs
|
|
||||||
afterSign: scripts/desktop/afterSign.mjs
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE proxy_logs ADD COLUMN content_summary TEXT;
|
||||||
|
ALTER TABLE proxy_logs ADD COLUMN request_content TEXT;
|
||||||
|
ALTER TABLE proxy_logs ADD COLUMN response_content TEXT;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
-- Performance-oriented projections and indexes for logs/workspace views.
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS proxy_logs_key_created_status_idx
|
||||||
|
ON proxy_logs (downstream_api_key_id, created_at DESC, status);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
|
||||||
|
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
|
||||||
|
ON proxy_logs (content_summary);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
|
||||||
|
ON workspace_assets (
|
||||||
|
user_id,
|
||||||
|
status,
|
||||||
|
deleted_at,
|
||||||
|
source,
|
||||||
|
kind,
|
||||||
|
created_at DESC
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS workspace_assets_output_role_created_idx
|
||||||
|
ON workspace_assets (
|
||||||
|
json_extract(metadata, '$.workspaceAsset.role'),
|
||||||
|
created_at DESC
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS workspace_assets_workflow_output_created_idx
|
||||||
|
ON workspace_assets (
|
||||||
|
json_extract(metadata, '$.workflowUi.type'),
|
||||||
|
created_at DESC
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_asset_daily (
|
||||||
|
local_day TEXT NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
asset_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
local_file_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(local_day, user_id, source, kind)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS workspace_asset_daily_user_day_idx
|
||||||
|
ON workspace_asset_daily (user_id, local_day DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS workspace_asset_daily_source_day_idx
|
||||||
|
ON workspace_asset_daily (source, local_day DESC);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS user_recharge_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
public_id TEXT NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
option_id TEXT,
|
||||||
|
label TEXT,
|
||||||
|
title TEXT,
|
||||||
|
amount_usd REAL,
|
||||||
|
provider TEXT NOT NULL DEFAULT 'comfy_checkout',
|
||||||
|
checkout_url TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
note TEXT,
|
||||||
|
metadata TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
completed_at TEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS user_recharge_records_public_id_unique
|
||||||
|
ON user_recharge_records (public_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS user_recharge_records_user_created_idx
|
||||||
|
ON user_recharge_records (user_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS user_recharge_records_user_status_idx
|
||||||
|
ON user_recharge_records (user_id, status);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS payment_orders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
public_id TEXT NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
recharge_record_id INTEGER,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
payment_method TEXT NOT NULL DEFAULT 'stripe',
|
||||||
|
provider_session_id TEXT,
|
||||||
|
provider_payment_id TEXT,
|
||||||
|
amount_usd REAL NOT NULL,
|
||||||
|
amount_minor INTEGER NOT NULL,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'usd',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
checkout_url TEXT,
|
||||||
|
success_url TEXT,
|
||||||
|
cancel_url TEXT,
|
||||||
|
note TEXT,
|
||||||
|
metadata TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
paid_at TEXT,
|
||||||
|
completed_at TEXT,
|
||||||
|
expires_at TEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (recharge_record_id) REFERENCES user_recharge_records(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS payment_orders_public_id_unique
|
||||||
|
ON payment_orders (public_id);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS payment_orders_provider_session_unique
|
||||||
|
ON payment_orders (provider, provider_session_id)
|
||||||
|
WHERE provider_session_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS payment_orders_user_created_idx
|
||||||
|
ON payment_orders (user_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS payment_orders_status_idx
|
||||||
|
ON payment_orders (status);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS content_summary TEXT;
|
||||||
|
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS request_content TEXT;
|
||||||
|
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS response_content TEXT;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- Performance-oriented projections and indexes for logs/workspace views.
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS proxy_logs_key_created_status_idx
|
||||||
|
ON proxy_logs (downstream_api_key_id, created_at DESC, status);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
|
||||||
|
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
|
||||||
|
ON proxy_logs (content_summary);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
|
||||||
|
ON workspace_assets (
|
||||||
|
user_id,
|
||||||
|
status,
|
||||||
|
deleted_at,
|
||||||
|
source,
|
||||||
|
kind,
|
||||||
|
created_at DESC
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS workspace_assets_output_role_created_idx
|
||||||
|
ON workspace_assets (((metadata::jsonb #>> '{workspaceAsset,role}')), created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS workspace_assets_workflow_output_created_idx
|
||||||
|
ON workspace_assets (((metadata::jsonb #>> '{workflowUi,type}')), created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_asset_daily (
|
||||||
|
local_day TEXT NOT NULL,
|
||||||
|
user_id BIGINT NOT NULL DEFAULT 0,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
asset_count BIGINT NOT NULL DEFAULT 0,
|
||||||
|
local_file_count BIGINT NOT NULL DEFAULT 0,
|
||||||
|
total_size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
|
||||||
|
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
|
||||||
|
UNIQUE(local_day, user_id, source, kind)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS workspace_asset_daily_user_day_idx
|
||||||
|
ON workspace_asset_daily (user_id, local_day DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS workspace_asset_daily_source_day_idx
|
||||||
|
ON workspace_asset_daily (source, local_day DESC);
|
||||||
+17
-72
@@ -2,12 +2,12 @@
|
|||||||
"name": "boosapi",
|
"name": "boosapi",
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"description": "Meta-layer management and unified proxy for AI API aggregation platforms",
|
"description": "Meta-layer management and unified proxy for AI API aggregation platforms",
|
||||||
"main": "dist/desktop/main.js",
|
"main": "dist/web/index.html",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ai",
|
"ai",
|
||||||
"api-gateway",
|
"api-gateway",
|
||||||
"proxy",
|
"proxy",
|
||||||
"fastify",
|
"rust",
|
||||||
"openai",
|
"openai",
|
||||||
"claude",
|
"claude",
|
||||||
"aggregator"
|
"aggregator"
|
||||||
@@ -22,98 +22,45 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=25.0.0"
|
"node": ">=22.0.0"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:server": "tsx scripts/dev/run-server.ts --watch",
|
"dev": "vite",
|
||||||
"dev": "concurrently \"npm run dev:server\" \"vite\"",
|
|
||||||
"dev:desktop": "concurrently \"npm run dev:server\" \"vite\" \"tsc -p tsconfig.desktop.json --watch --preserveWatchOutput\" \"wait-on tcp:127.0.0.1:4000 http://127.0.0.1:5173 file:dist/desktop/main.js && cross-env METAPI_DESKTOP_DEV_SERVER_URL=http://127.0.0.1:5173 METAPI_DESKTOP_EXTERNAL_SERVER_URL=http://127.0.0.1:4000 electron dist/desktop/main.js\"",
|
|
||||||
"desktop:icons": "node scripts/desktop/generate-icons.mjs",
|
|
||||||
"build:web": "vite build",
|
"build:web": "vite build",
|
||||||
"build:server": "tsc -p tsconfig.server.json && tsx scripts/dev/copy-runtime-db-generated.ts",
|
"build": "npm run build:web",
|
||||||
"build:desktop": "tsc -p tsconfig.desktop.json",
|
|
||||||
"build": "npm run build:web && npm run build:server && npm run build:desktop",
|
|
||||||
"typecheck:web": "tsc -p tsconfig.web.json",
|
"typecheck:web": "tsc -p tsconfig.web.json",
|
||||||
"typecheck:web:test": "tsc -p tsconfig.web.test.json",
|
"typecheck:web:test": "tsc -p tsconfig.web.test.json",
|
||||||
"typecheck:server": "tsc --noEmit -p tsconfig.server.json",
|
"typecheck": "npm run typecheck:web && npm run typecheck:web:test",
|
||||||
"typecheck:desktop": "tsc --noEmit -p tsconfig.desktop.json",
|
"website:dev": "npm --prefix website run dev",
|
||||||
"typecheck": "npm run typecheck:web && npm run typecheck:web:test && npm run typecheck:server && npm run typecheck:desktop",
|
"website:build": "npm --prefix website run build",
|
||||||
"dist:desktop": "npm run build && electron-builder --config electron-builder.yml --publish never",
|
"website:start": "npm --prefix website run start",
|
||||||
"dist:desktop:mac:intel": "npm run build && electron-builder --config electron-builder.yml --publish never --mac --x64",
|
"website:typecheck": "npm --prefix website run typecheck",
|
||||||
"package:desktop": "npm run dist:desktop",
|
|
||||||
"package:desktop:mac:intel": "npm run dist:desktop:mac:intel",
|
|
||||||
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
|
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
|
||||||
"docs:build": "vitepress build docs",
|
"docs:build": "vitepress build docs",
|
||||||
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
|
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
|
||||||
"start": "node dist/server/index.js",
|
"workflow-ui:install": "node scripts/workflow-ui/install.mjs all",
|
||||||
"start:deploy-helper": "node dist/server/update-helper/index.js",
|
"workflow-ui:install:external": "node scripts/workflow-ui/install.mjs external",
|
||||||
"start:desktop": "electron dist/desktop/main.js",
|
"workflow-ui:install:cloud": "node scripts/workflow-ui/install.mjs cloud",
|
||||||
"comfyui:install": "node scripts/comfyui/install.mjs",
|
"test": "vitest run --root src/web",
|
||||||
"comfyui:start": "node scripts/comfyui/start.mjs",
|
"test:watch": "vitest --root src/web"
|
||||||
"comfyui:stop": "node scripts/comfyui/stop.mjs",
|
|
||||||
"workflow-ui:install": "node scripts/workflow-ui/install.mjs",
|
|
||||||
"db:generate": "drizzle-kit generate",
|
|
||||||
"db:migrate": "tsx src/server/db/migrate.ts",
|
|
||||||
"schema:contract": "tsx scripts/dev/generate-schema-contract.ts",
|
|
||||||
"schema:generate": "npm run db:generate && npm run schema:contract",
|
|
||||||
"repo:drift-check": "tsx scripts/dev/repo-drift-check.ts",
|
|
||||||
"readme:contributors": "tsx scripts/dev/update-readme-contributors.ts",
|
|
||||||
"test:schema:unit": "vitest run --root . src/server/db/schemaContract.test.ts src/server/db/schemaArtifactGenerator.test.ts src/server/db/schemaIntrospection.test.ts src/server/db/schemaParity.test.ts",
|
|
||||||
"test:schema:parity": "vitest run --root . src/server/db/schemaParity.live.test.ts",
|
|
||||||
"test:schema:upgrade": "vitest run --root . src/server/db/schemaUpgrade.live.test.ts",
|
|
||||||
"test:schema:runtime": "vitest run --root . src/server/db/runtimeSchemaBootstrap.live.test.ts",
|
|
||||||
"smoke:db": "tsx scripts/dev/db-smoke.ts",
|
|
||||||
"smoke:db:sqlite": "tsx scripts/dev/db-smoke.ts --db-type sqlite",
|
|
||||||
"smoke:db:mysql": "tsx scripts/dev/db-smoke.ts --db-type mysql",
|
|
||||||
"smoke:db:postgres": "tsx scripts/dev/db-smoke.ts --db-type postgres",
|
|
||||||
"test": "vitest run --root .",
|
|
||||||
"test:watch": "vitest --root ."
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fastify/cors": "^11.2.0",
|
|
||||||
"@fastify/static": "^9.0.0",
|
|
||||||
"@visactor/react-vchart": "^2.0.21",
|
"@visactor/react-vchart": "^2.0.21",
|
||||||
"better-sqlite3": "^12.8.0",
|
"gsap": "^3.15.0",
|
||||||
"dotenv": "^17.4.1",
|
|
||||||
"drizzle-orm": "^0.45.2",
|
|
||||||
"electron-log": "^5.2.4",
|
|
||||||
"electron-updater": "^6.6.2",
|
|
||||||
"fastify": "^5.8.4",
|
|
||||||
"get-port": "^7.2.0",
|
|
||||||
"marked": "^17.0.5",
|
"marked": "^17.0.5",
|
||||||
"minimatch": "^10.2.5",
|
|
||||||
"minimist": "^1.2.8",
|
|
||||||
"mysql2": "^3.20.0",
|
|
||||||
"node-cron": "^4.2.1",
|
|
||||||
"nodemailer": "^8.0.4",
|
|
||||||
"pg": "^8.16.3",
|
|
||||||
"rate-limiter-flexible": "^11.0.0",
|
|
||||||
"socks": "^2.8.7",
|
|
||||||
"undici": "^6.24.1",
|
|
||||||
"ws": "^8.20.0",
|
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron/notarize": "^3.1.0",
|
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@types/better-sqlite3": "^7.6.11",
|
|
||||||
"@types/node": "^22.10.1",
|
"@types/node": "^22.10.1",
|
||||||
"@types/node-cron": "^3.0.11",
|
|
||||||
"@types/nodemailer": "^8.0.0",
|
|
||||||
"@types/pg": "^8.20.0",
|
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@types/react-test-renderer": "^19.1.0",
|
"@types/react-test-renderer": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"concurrently": "^9.1.0",
|
|
||||||
"cross-env": "^7.0.3",
|
|
||||||
"drizzle-kit": "^0.31.10",
|
|
||||||
"electron": "^41.1.1",
|
|
||||||
"electron-builder": "^26.0.12",
|
|
||||||
"jsdom": "^29.0.2",
|
"jsdom": "^29.0.2",
|
||||||
"mermaid": "^11.14.0",
|
"mermaid": "^11.14.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -122,13 +69,11 @@
|
|||||||
"react-test-renderer": "^18.3.1",
|
"react-test-renderer": "^18.3.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"tsx": "^4.19.2",
|
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^6.0.3",
|
"vite": "^6.0.3",
|
||||||
"vitepress": "^1.6.4",
|
"vitepress": "^1.6.4",
|
||||||
"vitepress-plugin-mermaid": "^2.0.17",
|
"vitepress-plugin-mermaid": "^2.0.17",
|
||||||
"vitest": "^2.1.8",
|
"vitest": "^2.1.8"
|
||||||
"wait-on": "^9.0.4"
|
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"minimist": "^1.2.8"
|
"minimist": "^1.2.8"
|
||||||
|
|||||||
Generated
+82
-3768
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
|
const DEFAULT_PACKS = [
|
||||||
|
['ComfyUI-Manager', 'https://github.com/Comfy-Org/ComfyUI-Manager.git'],
|
||||||
|
['christian-byrne/audio-separation-nodes-comfyui', 'https://github.com/christian-byrne/audio-separation-nodes-comfyui.git'],
|
||||||
|
['yvann-ba/ComfyUI_Yvann-Nodes', 'https://github.com/yvann-ba/ComfyUI_Yvann-Nodes.git'],
|
||||||
|
['yolain/ComfyUI-Easy-Use', 'https://github.com/yolain/ComfyUI-Easy-Use.git'],
|
||||||
|
['wildminder/ComfyUI-VoxCPM', 'https://github.com/wildminder/ComfyUI-VoxCPM.git'],
|
||||||
|
['kijai/ComfyUI-WanVideoWrapper', 'https://github.com/kijai/ComfyUI-WanVideoWrapper.git'],
|
||||||
|
['civen-cn/ComfyUI-Whisper-Translator', 'https://github.com/civen-cn/ComfyUI-Whisper-Translator.git'],
|
||||||
|
['kijai/ComfyUI-KJNodes', 'https://github.com/kijai/ComfyUI-KJNodes.git'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const args = new Set(process.argv.slice(2));
|
||||||
|
const configPath = valueArg('--config') || 'config.toml';
|
||||||
|
const baseUrl = trimSlash(
|
||||||
|
process.env.BOOSAPI_COMFYUI__BASE_URL
|
||||||
|
|| process.env.COMFYUI_CLOUD_BASE_URL
|
||||||
|
|| (await readComfyConfig(configPath)).base_url
|
||||||
|
|| 'https://cloud.comfy.org'
|
||||||
|
);
|
||||||
|
const apiKey = process.env.BOOSAPI_COMFYUI__API_KEY
|
||||||
|
|| process.env.COMFYUI_CLOUD_API_KEY
|
||||||
|
|| (await readComfyConfig(configPath)).api_key
|
||||||
|
|| '';
|
||||||
|
const dryRun = args.has('--dry-run');
|
||||||
|
|
||||||
|
if (!baseUrl) {
|
||||||
|
fail('Missing Cloud base URL. Set BOOSAPI_COMFYUI__BASE_URL or [comfyui].base_url.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
fail('Missing Cloud API key. Set BOOSAPI_COMFYUI__API_KEY or [comfyui].api_key.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Cloud backend: ${baseUrl}`);
|
||||||
|
console.log(`Packs: ${DEFAULT_PACKS.length}`);
|
||||||
|
if (dryRun) {
|
||||||
|
for (const [id, repository] of DEFAULT_PACKS) {
|
||||||
|
console.log(`[dry-run] ${id} <- ${repository}`);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
await request('GET', '/api/manager/queue/start').catch((error) => {
|
||||||
|
console.warn(`manager queue start warning: ${error.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const [id, repository] of DEFAULT_PACKS) {
|
||||||
|
const payload = {
|
||||||
|
id,
|
||||||
|
version: 'nightly',
|
||||||
|
selected_version: 'nightly',
|
||||||
|
repository,
|
||||||
|
mode: 'remote',
|
||||||
|
channel: 'default',
|
||||||
|
skip_post_install: false,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await request('POST', '/api/manager/queue/install', payload);
|
||||||
|
console.log(`queued: ${id}`);
|
||||||
|
results.push({ id, ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`failed: ${id}: ${error.message}`);
|
||||||
|
results.push({ id, ok: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await request('GET', '/api/manager/queue/status')
|
||||||
|
.then((status) => console.log(`queue status: ${JSON.stringify(status)}`))
|
||||||
|
.catch((error) => console.warn(`queue status warning: ${error.message}`));
|
||||||
|
|
||||||
|
const failed = results.filter((item) => !item.ok);
|
||||||
|
if (failed.length > 0) {
|
||||||
|
console.error(`\n${failed.length} pack(s) failed to queue. If Cloud returned 403/404, the hosted backend does not allow Manager installs for this workspace/API key.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nAll install jobs were queued. Restart the Cloud ComfyUI runtime if Manager asks for it.');
|
||||||
|
|
||||||
|
async function request(method, path, body) {
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'X-API-Key': apiKey,
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
const data = text ? safeJson(text) : null;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${response.status} ${response.statusText}: ${text.slice(0, 500)}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJson(text) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return { body: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readComfyConfig(path) {
|
||||||
|
const result = {};
|
||||||
|
let section = '';
|
||||||
|
let text = '';
|
||||||
|
try {
|
||||||
|
text = await fs.readFile(path, 'utf8');
|
||||||
|
} catch {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (const rawLine of text.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith('#')) continue;
|
||||||
|
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
|
||||||
|
if (sectionMatch) {
|
||||||
|
section = sectionMatch[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (section !== 'comfyui') continue;
|
||||||
|
const match = line.match(/^([A-Za-z0-9_]+)\s*=\s*"(.*)"\s*$/);
|
||||||
|
if (match) result[match[1]] = match[2];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimSlash(value) {
|
||||||
|
return String(value || '').trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueArg(name) {
|
||||||
|
const prefix = `${name}=`;
|
||||||
|
const found = process.argv.slice(2).find((arg) => arg.startsWith(prefix));
|
||||||
|
return found ? found.slice(prefix.length) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
console.error(message);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
COMFYUI_DIR="${COMFYUI_DIR:-/workspace/ComfyUI}"
|
||||||
|
CUSTOM_NODES_DIR="${CUSTOM_NODES_DIR:-$COMFYUI_DIR/custom_nodes}"
|
||||||
|
PYTHON_BIN="${PYTHON_BIN:-$COMFYUI_DIR/venv/bin/python}"
|
||||||
|
|
||||||
|
if [ ! -d "$COMFYUI_DIR" ]; then
|
||||||
|
echo "ComfyUI directory not found: $COMFYUI_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -x "$PYTHON_BIN" ]; then
|
||||||
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
|
PYTHON_BIN="$(command -v python3)"
|
||||||
|
else
|
||||||
|
echo "Python not found. Set PYTHON_BIN=/path/to/python" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$CUSTOM_NODES_DIR"
|
||||||
|
|
||||||
|
install_node() {
|
||||||
|
local name="$1"
|
||||||
|
local repo="$2"
|
||||||
|
local ref="${3:-}"
|
||||||
|
local dir="$CUSTOM_NODES_DIR/$name"
|
||||||
|
|
||||||
|
if [ -d "$dir/.git" ]; then
|
||||||
|
echo "==> Updating $name"
|
||||||
|
git -C "$dir" fetch --depth 1 origin "${ref:-HEAD}" || git -C "$dir" fetch origin
|
||||||
|
else
|
||||||
|
echo "==> Installing $name"
|
||||||
|
git clone --depth 1 "$repo" "$dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$ref" ]; then
|
||||||
|
git -C "$dir" checkout --quiet "$ref"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$dir/requirements.txt" ]; then
|
||||||
|
echo "==> Installing Python requirements for $name"
|
||||||
|
if command -v uv >/dev/null 2>&1; then
|
||||||
|
UV_INDEX_URL="${UV_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple}" \
|
||||||
|
uv pip install --python "$PYTHON_BIN" -r "$dir/requirements.txt"
|
||||||
|
else
|
||||||
|
"$PYTHON_BIN" -m pip install -r "$dir/requirements.txt"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$dir/install.py" ]; then
|
||||||
|
echo "==> Running install.py for $name"
|
||||||
|
"$PYTHON_BIN" "$dir/install.py" || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ComfyUI-Manager backend API. The v1.46.x cloud frontend already contains the
|
||||||
|
# Manager UI; this backend node provides /manager/* and /customnode/* endpoints
|
||||||
|
# for local/docker/external ComfyUI instances.
|
||||||
|
install_node "ComfyUI-Manager" "https://github.com/Comfy-Org/ComfyUI-Manager.git"
|
||||||
|
|
||||||
|
# Nodes used by common RunningHub digital-human / video templates.
|
||||||
|
install_node "audio-separation-nodes-comfyui" "https://github.com/christian-byrne/audio-separation-nodes-comfyui.git"
|
||||||
|
install_node "ComfyUI_Yvann-Nodes" "https://github.com/yvann-ba/ComfyUI_Yvann-Nodes.git"
|
||||||
|
install_node "ComfyUI-Easy-Use" "https://github.com/yolain/ComfyUI-Easy-Use.git"
|
||||||
|
install_node "ComfyUI-VoxCPM" "https://github.com/wildminder/ComfyUI-VoxCPM.git"
|
||||||
|
install_node "ComfyUI-WanVideoWrapper" "https://github.com/kijai/ComfyUI-WanVideoWrapper.git"
|
||||||
|
install_node "ComfyUI-Whisper-Translator" "https://github.com/civen-cn/ComfyUI-Whisper-Translator.git"
|
||||||
|
install_node "ComfyUI-KJNodes" "https://github.com/kijai/ComfyUI-KJNodes.git"
|
||||||
|
|
||||||
|
cat <<'EOF'
|
||||||
|
|
||||||
|
RunningHub template nodes installed.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Install system packages such as ffmpeg/libsndfile/rubberband separately if a
|
||||||
|
node requires them at runtime.
|
||||||
|
- The screenshot also references a "soundflow" package. I did not find a stable
|
||||||
|
public repository for that exact package name; install it through
|
||||||
|
ComfyUI-Manager or add its repository manually when you have the source URL.
|
||||||
|
- Missing model files, for example Wan/InfiniteTalk checkpoints, are not custom
|
||||||
|
nodes. Put those models into the matching ComfyUI models folders or use a
|
||||||
|
backend where RunningHub/Cloud already provides them.
|
||||||
|
EOF
|
||||||
Executable
+260
@@ -0,0 +1,260 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the BossAI production binary, package Alpine/musl runtime libraries,
|
||||||
|
# and deploy the runnable bundle to a Debian server.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
log() { echo "[$(date '+%H:%M:%S')] $*"; }
|
||||||
|
ok() { echo "[$(date '+%H:%M:%S')] ✓ $*"; }
|
||||||
|
fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
REMOTE_HOST="${BOSSAI_DEBIAN_HOST:-129.211.30.104}"
|
||||||
|
REMOTE_USER="${BOSSAI_DEBIAN_USER:-root}"
|
||||||
|
REMOTE_DIR="${BOSSAI_DEBIAN_DIR:-/root/BossAI}"
|
||||||
|
SSH_KEY="${BOSSAI_DEBIAN_SSH_KEY:-$HOME/.ssh/id_rsa_mbp}"
|
||||||
|
BOSSAI_SOURCE_DIR="${BOSSAI_SOURCE_DIR:-/root/BossAI-build/main}"
|
||||||
|
BOSSAI_REMOTE="${BOSSAI_REMOTE:-bossai}"
|
||||||
|
BOSSAI_REF="${BOSSAI_REF:-main}"
|
||||||
|
BOSSAI_WORKFLOW_UI_SOURCE="${BOSSAI_WORKFLOW_UI_SOURCE:-$ROOT_DIR/vendor/ComfyUI_frontend}"
|
||||||
|
PRODUCTION_DIR="${BOSSAI_PRODUCTION_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-production}"
|
||||||
|
BUNDLE_DIR="${BOSSAI_DEBIAN_BUNDLE_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle}"
|
||||||
|
BUNDLE_ARCHIVE="${BOSSAI_DEBIAN_BUNDLE_ARCHIVE:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle.tar.gz}"
|
||||||
|
FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}"
|
||||||
|
SKIP_BUILD=0
|
||||||
|
SKIP_VERIFY=0
|
||||||
|
SKIP_SOURCE_UPDATE=0
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: scripts/deploy-bossai-debian.sh [options]
|
||||||
|
|
||||||
|
Build BossAI from the BossAI source tree, bundle the musl runtime libraries
|
||||||
|
needed by the Alpine-built binary, upload it to a Debian host, extract it,
|
||||||
|
and run a config check.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--host <host> Debian host/IP. Default: 129.211.30.104
|
||||||
|
--user <user> SSH user. Default: root
|
||||||
|
--dir <remote-dir> Remote deploy directory. Default: /root/BossAI
|
||||||
|
--key <ssh-key> SSH private key. Default: ~/.ssh/id_rsa_mbp
|
||||||
|
--source-dir <dir> BossAI source worktree. Default: /root/BossAI-build/main
|
||||||
|
--remote <name> Git remote for BossAI. Default: bossai
|
||||||
|
--ref <ref> BossAI branch/ref to build. Default: main
|
||||||
|
--skip-build Reuse dist/bossai-production from a previous build
|
||||||
|
--skip-source-update Do not fetch/reset the BossAI source worktree
|
||||||
|
--skip-verify Skip remote ./run-boosapi.sh --check-config
|
||||||
|
-h, --help Show this help
|
||||||
|
|
||||||
|
Environment overrides:
|
||||||
|
BOSSAI_DEBIAN_HOST
|
||||||
|
BOSSAI_DEBIAN_USER
|
||||||
|
BOSSAI_DEBIAN_DIR
|
||||||
|
BOSSAI_DEBIAN_SSH_KEY
|
||||||
|
BOSSAI_SOURCE_DIR
|
||||||
|
BOSSAI_REMOTE
|
||||||
|
BOSSAI_REF
|
||||||
|
BOSSAI_WORKFLOW_UI_SOURCE
|
||||||
|
BOSSAI_PRODUCTION_DIR
|
||||||
|
BOSSAI_DEBIAN_BUNDLE_DIR
|
||||||
|
BOSSAI_DEBIAN_BUNDLE_ARCHIVE
|
||||||
|
BOOSAPI_FRONTEND_PM auto / bun / npm, forwarded to build-and-run.sh
|
||||||
|
|
||||||
|
Remote start command after deploy:
|
||||||
|
cd /root/BossAI && ./run-boosapi.sh
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--host)
|
||||||
|
shift
|
||||||
|
[ $# -gt 0 ] || fail "--host requires a value"
|
||||||
|
REMOTE_HOST="$1"
|
||||||
|
;;
|
||||||
|
--user)
|
||||||
|
shift
|
||||||
|
[ $# -gt 0 ] || fail "--user requires a value"
|
||||||
|
REMOTE_USER="$1"
|
||||||
|
;;
|
||||||
|
--dir)
|
||||||
|
shift
|
||||||
|
[ $# -gt 0 ] || fail "--dir requires a value"
|
||||||
|
REMOTE_DIR="$1"
|
||||||
|
;;
|
||||||
|
--key)
|
||||||
|
shift
|
||||||
|
[ $# -gt 0 ] || fail "--key requires a value"
|
||||||
|
SSH_KEY="$1"
|
||||||
|
;;
|
||||||
|
--source-dir)
|
||||||
|
shift
|
||||||
|
[ $# -gt 0 ] || fail "--source-dir requires a value"
|
||||||
|
BOSSAI_SOURCE_DIR="$1"
|
||||||
|
PRODUCTION_DIR="${BOSSAI_PRODUCTION_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-production}"
|
||||||
|
BUNDLE_DIR="${BOSSAI_DEBIAN_BUNDLE_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle}"
|
||||||
|
BUNDLE_ARCHIVE="${BOSSAI_DEBIAN_BUNDLE_ARCHIVE:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle.tar.gz}"
|
||||||
|
;;
|
||||||
|
--remote)
|
||||||
|
shift
|
||||||
|
[ $# -gt 0 ] || fail "--remote requires a value"
|
||||||
|
BOSSAI_REMOTE="$1"
|
||||||
|
;;
|
||||||
|
--ref)
|
||||||
|
shift
|
||||||
|
[ $# -gt 0 ] || fail "--ref requires a value"
|
||||||
|
BOSSAI_REF="$1"
|
||||||
|
;;
|
||||||
|
--skip-build)
|
||||||
|
SKIP_BUILD=1
|
||||||
|
;;
|
||||||
|
--skip-source-update)
|
||||||
|
SKIP_SOURCE_UPDATE=1
|
||||||
|
;;
|
||||||
|
--skip-verify)
|
||||||
|
SKIP_VERIFY=1
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
require_file() {
|
||||||
|
local path="$1"
|
||||||
|
[ -f "$path" ] || fail "Missing required file: $path"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_command() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_bossai_source() {
|
||||||
|
require_command git
|
||||||
|
|
||||||
|
if [ "$SKIP_SOURCE_UPDATE" -eq 1 ]; then
|
||||||
|
log "Skipping BossAI source update; using $BOSSAI_SOURCE_DIR"
|
||||||
|
[ -d "$BOSSAI_SOURCE_DIR" ] || fail "BossAI source directory does not exist: $BOSSAI_SOURCE_DIR"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Preparing BossAI source from $BOSSAI_REMOTE/$BOSSAI_REF → $BOSSAI_SOURCE_DIR"
|
||||||
|
git fetch "$BOSSAI_REMOTE" "$BOSSAI_REF"
|
||||||
|
|
||||||
|
if [ -d "$BOSSAI_SOURCE_DIR" ] && git -C "$BOSSAI_SOURCE_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
git -C "$BOSSAI_SOURCE_DIR" reset --hard "$BOSSAI_REMOTE/$BOSSAI_REF"
|
||||||
|
git -C "$BOSSAI_SOURCE_DIR" clean -fd
|
||||||
|
else
|
||||||
|
rm -rf "$BOSSAI_SOURCE_DIR"
|
||||||
|
mkdir -p "$(dirname "$BOSSAI_SOURCE_DIR")"
|
||||||
|
git worktree add "$BOSSAI_SOURCE_DIR" "$BOSSAI_REMOTE/$BOSSAI_REF"
|
||||||
|
fi
|
||||||
|
|
||||||
|
require_file "$BOSSAI_SOURCE_DIR/build-and-run.sh"
|
||||||
|
ok "BossAI source ready: $(git -C "$BOSSAI_SOURCE_DIR" rev-parse --short HEAD)"
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_workflow_ui_source() {
|
||||||
|
if [ -n "${BOOSAPI_WORKFLOW_UI_REPO:-}" ]; then
|
||||||
|
log "Using BOOSAPI_WORKFLOW_UI_REPO=$BOOSAPI_WORKFLOW_UI_REPO"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$BOSSAI_WORKFLOW_UI_SOURCE/.git" ] && [ ! -f "$BOSSAI_WORKFLOW_UI_SOURCE/.git" ]; then
|
||||||
|
log "No local Workflow UI source at $BOSSAI_WORKFLOW_UI_SOURCE; install script may fetch from GitHub"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Using local ComfyUI_frontend source: $BOSSAI_WORKFLOW_UI_SOURCE"
|
||||||
|
export BOOSAPI_WORKFLOW_UI_REPO="$BOSSAI_WORKFLOW_UI_SOURCE"
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime_lib() {
|
||||||
|
local path="$1"
|
||||||
|
require_file "$path"
|
||||||
|
cp "$path" "$BUNDLE_DIR/lib/"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_runner() {
|
||||||
|
cat > "$BUNDLE_DIR/run-boosapi.sh" <<'EOF'
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
exec "$DIR/lib/ld-musl-x86_64.so.1" --library-path "$DIR/lib" "$DIR/boosapi" "$@"
|
||||||
|
EOF
|
||||||
|
chmod +x "$BUNDLE_DIR/run-boosapi.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
package_bundle() {
|
||||||
|
require_file "$PRODUCTION_DIR/boosapi"
|
||||||
|
require_file "$PRODUCTION_DIR/config.toml"
|
||||||
|
|
||||||
|
rm -rf "$BUNDLE_DIR" "$BUNDLE_ARCHIVE"
|
||||||
|
mkdir -p "$BUNDLE_DIR/lib"
|
||||||
|
|
||||||
|
cp "$PRODUCTION_DIR/boosapi" "$BUNDLE_DIR/boosapi"
|
||||||
|
cp "$PRODUCTION_DIR/config.toml" "$BUNDLE_DIR/config.toml"
|
||||||
|
chmod +x "$BUNDLE_DIR/boosapi"
|
||||||
|
|
||||||
|
runtime_lib /lib/ld-musl-x86_64.so.1
|
||||||
|
runtime_lib /usr/lib/libssl.so.3
|
||||||
|
runtime_lib /usr/lib/libcrypto.so.3
|
||||||
|
runtime_lib /usr/lib/libgcc_s.so.1
|
||||||
|
write_runner
|
||||||
|
|
||||||
|
tar -C "$BUNDLE_DIR" -czf "$BUNDLE_ARCHIVE" .
|
||||||
|
ok "Bundle ready: $BUNDLE_ARCHIVE"
|
||||||
|
sha256sum "$BUNDLE_ARCHIVE" "$BUNDLE_DIR/boosapi"
|
||||||
|
ls -lh "$BUNDLE_ARCHIVE"
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy_bundle() {
|
||||||
|
require_file "$SSH_KEY"
|
||||||
|
require_command ssh
|
||||||
|
require_command scp
|
||||||
|
|
||||||
|
local remote="${REMOTE_USER}@${REMOTE_HOST}"
|
||||||
|
local ssh_opts=(-i "$SSH_KEY" -o StrictHostKeyChecking=accept-new)
|
||||||
|
|
||||||
|
log "Preparing remote directory: $remote:$REMOTE_DIR"
|
||||||
|
ssh "${ssh_opts[@]}" "$remote" "mkdir -p '$REMOTE_DIR'"
|
||||||
|
|
||||||
|
log "Uploading bundle..."
|
||||||
|
scp "${ssh_opts[@]}" "$BUNDLE_ARCHIVE" "$remote:$REMOTE_DIR/bossai-debian-bundle.tar.gz"
|
||||||
|
|
||||||
|
log "Extracting bundle on Debian host..."
|
||||||
|
ssh "${ssh_opts[@]}" "$remote" "cd '$REMOTE_DIR' && tar -xzf bossai-debian-bundle.tar.gz && chmod +x boosapi run-boosapi.sh && sha256sum bossai-debian-bundle.tar.gz boosapi"
|
||||||
|
|
||||||
|
if [ "$SKIP_VERIFY" -eq 0 ]; then
|
||||||
|
log "Checking remote config..."
|
||||||
|
ssh "${ssh_opts[@]}" "$remote" "cd '$REMOTE_DIR' && ./run-boosapi.sh --check-config"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ok "Deployed BossAI to $remote:$REMOTE_DIR"
|
||||||
|
log "Start with: cd $REMOTE_DIR && ./run-boosapi.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$SKIP_BUILD" -eq 0 ]; then
|
||||||
|
ensure_bossai_source
|
||||||
|
prepare_workflow_ui_source
|
||||||
|
log "Building BossAI production bundle..."
|
||||||
|
BOOSAPI_FRONTEND_PM="$FRONTEND_PM" "$BOSSAI_SOURCE_DIR/build-and-run.sh" \
|
||||||
|
--production \
|
||||||
|
--production-dir "$PRODUCTION_DIR"
|
||||||
|
else
|
||||||
|
[ -d "$BOSSAI_SOURCE_DIR" ] || fail "BossAI source directory does not exist: $BOSSAI_SOURCE_DIR"
|
||||||
|
log "Skipping build; using existing $PRODUCTION_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
package_bundle
|
||||||
|
deploy_bundle
|
||||||
+110
-19
@@ -4,16 +4,49 @@ import { spawn } from 'child_process';
|
|||||||
|
|
||||||
const ROOT = process.cwd();
|
const ROOT = process.cwd();
|
||||||
const REPO = process.env.BOOSAPI_WORKFLOW_UI_REPO || '';
|
const REPO = process.env.BOOSAPI_WORKFLOW_UI_REPO || '';
|
||||||
const REF = process.env.BOOSAPI_WORKFLOW_UI_REF || 'v1.46.1';
|
const VARIANT = (process.env.BOOSAPI_WORKFLOW_UI_VARIANT || process.argv[2] || 'all').toLowerCase();
|
||||||
|
const DEFAULT_EXTERNAL_REF = 'v1.14.6';
|
||||||
|
const DEFAULT_CLOUD_REF = 'v1.46.2';
|
||||||
const SUBMODULE_DIR = path.join(ROOT, 'vendor', 'ComfyUI_frontend');
|
const SUBMODULE_DIR = path.join(ROOT, 'vendor', 'ComfyUI_frontend');
|
||||||
const CACHE_DIR = path.join(ROOT, '.cache', 'comfyui-frontend');
|
const CACHE_DIR = path.join(ROOT, '.cache', 'comfyui-frontend');
|
||||||
const DIST = path.join(ROOT, 'dist', 'workflow-ui');
|
const DEFAULT_DIST = path.join(ROOT, 'dist', 'workflow-ui');
|
||||||
|
|
||||||
|
// External/RunningHub currently targets ComfyUI 0.3.27, where v1.14.x is the
|
||||||
|
// most stable match. Cloud tracks the newer ComfyUI_frontend release line.
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
external: {
|
||||||
|
ref: process.env.BOOSAPI_WORKFLOW_UI_EXTERNAL_REF
|
||||||
|
|| process.env.BOOSAPI_WORKFLOW_UI_REF
|
||||||
|
|| DEFAULT_EXTERNAL_REF,
|
||||||
|
dist: process.env.BOOSAPI_WORKFLOW_UI_EXTERNAL_DIST
|
||||||
|
|| path.join(ROOT, 'dist', 'workflow-ui-external'),
|
||||||
|
description: 'External/RunningHub compatible frontend',
|
||||||
|
},
|
||||||
|
cloud: {
|
||||||
|
ref: process.env.BOOSAPI_WORKFLOW_UI_CLOUD_REF
|
||||||
|
|| process.env.BOOSAPI_WORKFLOW_UI_REF
|
||||||
|
|| DEFAULT_CLOUD_REF,
|
||||||
|
dist: process.env.BOOSAPI_WORKFLOW_UI_CLOUD_DIST
|
||||||
|
|| path.join(ROOT, 'dist', 'workflow-ui-cloud'),
|
||||||
|
description: 'Comfy Cloud frontend',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function run(command, args, options = {}) {
|
function run(command, args, options = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
const env = { ...process.env, ...(options.env || {}) };
|
||||||
|
if (options.cleanPackageEnv) {
|
||||||
|
Object.keys(env)
|
||||||
|
.filter((key) => key.startsWith('npm_package_'))
|
||||||
|
.forEach((key) => {
|
||||||
|
delete env[key];
|
||||||
|
});
|
||||||
|
Object.assign(env, options.env || {});
|
||||||
|
}
|
||||||
const child = spawn(command, args, {
|
const child = spawn(command, args, {
|
||||||
cwd: options.cwd || ROOT,
|
cwd: options.cwd || ROOT,
|
||||||
env: { ...process.env, ...(options.env || {}) },
|
env,
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
shell: process.platform === 'win32',
|
shell: process.platform === 'win32',
|
||||||
});
|
});
|
||||||
@@ -25,23 +58,81 @@ function run(command, args, options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasSubmodule = await fs.access(path.join(SUBMODULE_DIR, 'package.json')).then(() => true, () => false);
|
function selectedVariants() {
|
||||||
const DIR = REPO ? CACHE_DIR : SUBMODULE_DIR;
|
if (VARIANT === 'all') return Object.entries(variants);
|
||||||
if (REPO) {
|
if (variants[VARIANT]) return [[VARIANT, variants[VARIANT]]];
|
||||||
|
throw new Error(`Unknown workflow-ui variant: ${VARIANT}. Use external, cloud, or all.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPackageInfo(dir) {
|
||||||
|
const raw = await fs.readFile(path.join(dir, 'package.json'), 'utf8');
|
||||||
|
return JSON.parse(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildVariant(name, config) {
|
||||||
|
const ref = config.ref;
|
||||||
|
const dist = config.dist;
|
||||||
|
const dir = path.join(CACHE_DIR, `${name}-${ref.replace(/[^a-zA-Z0-9._-]/g, '_')}`);
|
||||||
|
|
||||||
|
console.log(`\n==> Building workflow-ui:${name} (${config.description}) from ${ref}`);
|
||||||
await fs.mkdir(path.dirname(CACHE_DIR), { recursive: true });
|
await fs.mkdir(path.dirname(CACHE_DIR), { recursive: true });
|
||||||
await fs.rm(CACHE_DIR, { recursive: true, force: true });
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
await run('git', ['clone', '--depth', '1', '--branch', REF, REPO, CACHE_DIR]);
|
|
||||||
} else if (!hasSubmodule) {
|
if (REPO) {
|
||||||
|
await run('git', ['clone', '--depth', '1', '--branch', ref, REPO, dir]);
|
||||||
|
} else {
|
||||||
|
const hasSubmodule = await fs.access(path.join(SUBMODULE_DIR, 'package.json')).then(() => true, () => false);
|
||||||
|
if (!hasSubmodule) {
|
||||||
await run('git', ['submodule', 'update', '--init', '--recursive', 'vendor/ComfyUI_frontend']);
|
await run('git', ['submodule', 'update', '--init', '--recursive', 'vendor/ComfyUI_frontend']);
|
||||||
}
|
}
|
||||||
const hasPnpmLock = await fs.access(path.join(DIR, 'pnpm-lock.yaml')).then(() => true, () => false);
|
await run('git', ['fetch', '--tags', 'origin', ref], { cwd: SUBMODULE_DIR });
|
||||||
if (hasPnpmLock) {
|
await run('git', ['clone', '--shared', SUBMODULE_DIR, dir]);
|
||||||
await run('pnpm', ['install', '--frozen-lockfile'], { cwd: DIR });
|
await run('git', ['-c', 'advice.detachedHead=false', 'checkout', '--quiet', ref], { cwd: dir });
|
||||||
await run('pnpm', ['exec', 'vite', 'build', '--config', 'vite.config.mts'], { cwd: DIR });
|
await run('git', ['submodule', 'update', '--init', '--recursive'], { cwd: dir });
|
||||||
} else {
|
}
|
||||||
await run('npm', ['ci'], { cwd: DIR });
|
|
||||||
await run('npm', ['run', 'build'], { cwd: DIR });
|
const packageInfo = await readPackageInfo(dir);
|
||||||
|
const packageEnv = {
|
||||||
|
npm_package_name: packageInfo.name || '',
|
||||||
|
npm_package_version: packageInfo.version || ref.replace(/^v/, ''),
|
||||||
|
};
|
||||||
|
const hasPnpmLock = await fs.access(path.join(dir, 'pnpm-lock.yaml')).then(() => true, () => false);
|
||||||
|
if (hasPnpmLock) {
|
||||||
|
await run('pnpm', ['install', '--frozen-lockfile'], { cwd: dir });
|
||||||
|
await run('pnpm', ['exec', 'vite', 'build', '--config', 'vite.config.mts'], {
|
||||||
|
cwd: dir,
|
||||||
|
cleanPackageEnv: true,
|
||||||
|
env: packageEnv,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await run('npm', ['ci'], { cwd: dir });
|
||||||
|
await run('npm', ['run', 'build'], {
|
||||||
|
cwd: dir,
|
||||||
|
cleanPackageEnv: true,
|
||||||
|
env: packageEnv,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.rm(dist, { recursive: true, force: true });
|
||||||
|
await fs.cp(path.join(dir, 'dist'), dist, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(dist, 'boosapi-workflow-ui.json'),
|
||||||
|
`${JSON.stringify({
|
||||||
|
variant: name,
|
||||||
|
ref,
|
||||||
|
packageName: packageInfo.name || null,
|
||||||
|
packageVersion: packageInfo.version || null,
|
||||||
|
}, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
console.log(`workflow-ui:${name} installed to ${dist}`);
|
||||||
|
|
||||||
|
if (name === 'cloud') {
|
||||||
|
await fs.rm(DEFAULT_DIST, { recursive: true, force: true });
|
||||||
|
await fs.cp(dist, DEFAULT_DIST, { recursive: true });
|
||||||
|
console.log(`workflow-ui default compatibility copy installed to ${DEFAULT_DIST}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, config] of selectedVariants()) {
|
||||||
|
await buildVariant(name, config);
|
||||||
}
|
}
|
||||||
await fs.rm(DIST, { recursive: true, force: true });
|
|
||||||
await fs.cp(path.join(DIR, 'dist'), DIST, { recursive: true });
|
|
||||||
console.log(`workflow-ui installed to ${DIST}`);
|
|
||||||
|
|||||||
+2471
File diff suppressed because it is too large
Load Diff
+56
-1
@@ -13,6 +13,8 @@ pub struct AppConfig {
|
|||||||
pub proxy: ProxyConfig,
|
pub proxy: ProxyConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub comfyui: ComfyUIConfig,
|
pub comfyui: ComfyUIConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub payment: PaymentConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
@@ -47,6 +49,8 @@ pub struct ProxyConfig {
|
|||||||
pub first_byte_timeout_secs: u64,
|
pub first_byte_timeout_secs: u64,
|
||||||
pub max_retries: u32,
|
pub max_retries: u32,
|
||||||
pub proxy_log_retention_days: u32,
|
pub proxy_log_retention_days: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub template_proxy_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
@@ -54,6 +58,10 @@ pub struct ComfyUIConfig {
|
|||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub backend: ComfyUIBackend,
|
pub backend: ComfyUIBackend,
|
||||||
|
#[serde(default)]
|
||||||
|
pub money_printer_video_cost: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub digital_human_video_cost: f64,
|
||||||
#[serde(default = "default_comfyui_docker_image")]
|
#[serde(default = "default_comfyui_docker_image")]
|
||||||
pub docker_image: String,
|
pub docker_image: String,
|
||||||
#[serde(default = "default_comfyui_container_name")]
|
#[serde(default = "default_comfyui_container_name")]
|
||||||
@@ -69,6 +77,23 @@ pub struct ComfyUIConfig {
|
|||||||
pub agent_model: String,
|
pub agent_model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct PaymentConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub public_base_url: Option<String>,
|
||||||
|
pub product_name_prefix: String,
|
||||||
|
pub currency: String,
|
||||||
|
pub min_amount_usd: f64,
|
||||||
|
pub max_amount_usd: Option<f64>,
|
||||||
|
pub daily_limit_usd: Option<f64>,
|
||||||
|
pub order_timeout_minutes: i64,
|
||||||
|
pub max_pending_orders: i64,
|
||||||
|
pub stripe_secret_key: Option<String>,
|
||||||
|
pub stripe_webhook_secret: Option<String>,
|
||||||
|
pub success_path: String,
|
||||||
|
pub cancel_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone, Default)]
|
#[derive(Debug, Deserialize, Clone, Default)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ComfyUIBackend {
|
pub enum ComfyUIBackend {
|
||||||
@@ -76,6 +101,7 @@ pub enum ComfyUIBackend {
|
|||||||
Boosapi,
|
Boosapi,
|
||||||
External,
|
External,
|
||||||
Cloud,
|
Cloud,
|
||||||
|
Runninghub,
|
||||||
Docker,
|
Docker,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +118,8 @@ impl Default for ComfyUIConfig {
|
|||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
backend: ComfyUIBackend::Boosapi,
|
backend: ComfyUIBackend::Boosapi,
|
||||||
|
money_printer_video_cost: 0.0,
|
||||||
|
digital_human_video_cost: 0.0,
|
||||||
docker_image: default_comfyui_docker_image(),
|
docker_image: default_comfyui_docker_image(),
|
||||||
container_name: default_comfyui_container_name(),
|
container_name: default_comfyui_container_name(),
|
||||||
install_dir: None,
|
install_dir: None,
|
||||||
@@ -107,15 +135,42 @@ impl Default for ComfyUIConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for PaymentConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
public_base_url: None,
|
||||||
|
product_name_prefix: "BossAI 账户充值".to_string(),
|
||||||
|
currency: "usd".to_string(),
|
||||||
|
min_amount_usd: 1.0,
|
||||||
|
max_amount_usd: Some(100_000.0),
|
||||||
|
daily_limit_usd: None,
|
||||||
|
order_timeout_minutes: 30,
|
||||||
|
max_pending_orders: 3,
|
||||||
|
stripe_secret_key: None,
|
||||||
|
stripe_webhook_secret: None,
|
||||||
|
success_path: "/user/recharge?payment=success&order={ORDER_ID}".to_string(),
|
||||||
|
cancel_path: "/user/recharge?payment=cancel&order={ORDER_ID}".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ComfyUIConfig {
|
impl ComfyUIConfig {
|
||||||
pub fn uses_native_backend(&self) -> bool {
|
pub fn uses_native_backend(&self) -> bool {
|
||||||
matches!(self.backend, ComfyUIBackend::External | ComfyUIBackend::Cloud)
|
matches!(
|
||||||
|
self.backend,
|
||||||
|
ComfyUIBackend::External | ComfyUIBackend::Cloud | ComfyUIBackend::Runninghub
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn uses_cloud_backend(&self) -> bool {
|
pub fn uses_cloud_backend(&self) -> bool {
|
||||||
matches!(self.backend, ComfyUIBackend::Cloud)
|
matches!(self.backend, ComfyUIBackend::Cloud)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn uses_runninghub_backend(&self) -> bool {
|
||||||
|
matches!(self.backend, ComfyUIBackend::Runninghub)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn uses_docker_backend(&self) -> bool {
|
pub fn uses_docker_backend(&self) -> bool {
|
||||||
matches!(self.backend, ComfyUIBackend::Docker)
|
matches!(self.backend, ComfyUIBackend::Docker)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use sqlx::{
|
|||||||
|
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod queries;
|
pub mod queries;
|
||||||
|
pub mod schema_compat;
|
||||||
pub mod sqlite_to_postgres;
|
pub mod sqlite_to_postgres;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
@@ -45,6 +46,23 @@ pub async fn create_pool(database_url: &str, max_connections: u32) -> anyhow::Re
|
|||||||
}
|
}
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
.max_connections(max_connections)
|
.max_connections(max_connections)
|
||||||
|
.after_connect(|conn, _meta| {
|
||||||
|
Box::pin(async move {
|
||||||
|
sqlx::query("PRAGMA journal_mode = WAL")
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("PRAGMA synchronous = NORMAL")
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("PRAGMA busy_timeout = 5000")
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("PRAGMA foreign_keys = ON")
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
})
|
||||||
.connect(database_url)
|
.connect(database_url)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(pool)
|
Ok(pool)
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
use anyhow::Context;
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
pub async fn ensure_sqlite_upstream_compat(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||||
|
ensure_columns(
|
||||||
|
pool,
|
||||||
|
"sites",
|
||||||
|
&[
|
||||||
|
("post_refresh_probe_enabled", "INTEGER DEFAULT false"),
|
||||||
|
("post_refresh_probe_model", "TEXT DEFAULT ''"),
|
||||||
|
("post_refresh_probe_scope", "TEXT DEFAULT 'single'"),
|
||||||
|
(
|
||||||
|
"post_refresh_probe_latency_threshold_ms",
|
||||||
|
"INTEGER DEFAULT 0",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ensure_columns(
|
||||||
|
pool,
|
||||||
|
"downstream_api_keys",
|
||||||
|
&[
|
||||||
|
("excluded_site_ids", "TEXT"),
|
||||||
|
("excluded_credential_refs", "TEXT"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ensure_columns(
|
||||||
|
pool,
|
||||||
|
"route_channels",
|
||||||
|
&[("oauth_route_unit_id", "INTEGER")],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ensure_columns(
|
||||||
|
pool,
|
||||||
|
"proxy_logs",
|
||||||
|
&[
|
||||||
|
("client_family", "TEXT"),
|
||||||
|
("client_app_id", "TEXT"),
|
||||||
|
("client_app_name", "TEXT"),
|
||||||
|
("client_confidence", "TEXT"),
|
||||||
|
("is_stream", "INTEGER"),
|
||||||
|
("first_byte_latency_ms", "INTEGER"),
|
||||||
|
("billing_details", "TEXT"),
|
||||||
|
("content_summary", "TEXT"),
|
||||||
|
("request_content", "TEXT"),
|
||||||
|
("response_content", "TEXT"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
run_compat_ddl(pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_columns(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
table: &str,
|
||||||
|
columns: &[(&str, &str)],
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let existing = table_columns(pool, table).await?;
|
||||||
|
if existing.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (name, definition) in columns {
|
||||||
|
if existing.contains(*name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let sql = format!("ALTER TABLE {table} ADD {name} {definition}");
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("add column {table}.{name}"))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn table_columns(pool: &SqlitePool, table: &str) -> anyhow::Result<HashSet<String>> {
|
||||||
|
let sql = format!("PRAGMA table_info({table})");
|
||||||
|
let rows = sqlx::query(&sql).fetch_all(pool).await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|row| row.try_get::<String, _>("name").ok())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_compat_ddl(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||||
|
for statement in SQLITE_COMPAT_DDL {
|
||||||
|
sqlx::query(statement)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("run SQLite compat DDL: {statement}"))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
const SQLITE_COMPAT_DDL: &[&str] = &[
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_logs_client_app_id_created_at_idx
|
||||||
|
ON proxy_logs (client_app_id, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_logs_client_family_created_at_idx
|
||||||
|
ON proxy_logs (client_family, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_logs_key_created_status_idx
|
||||||
|
ON proxy_logs (downstream_api_key_id, created_at DESC, status)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
|
||||||
|
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
|
||||||
|
ON proxy_logs (content_summary)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
|
||||||
|
ON workspace_assets (user_id, status, deleted_at, source, kind, created_at DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS workspace_assets_output_role_created_idx
|
||||||
|
ON workspace_assets (json_extract(metadata, '$.workspaceAsset.role'), created_at DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS workspace_assets_workflow_output_created_idx
|
||||||
|
ON workspace_assets (json_extract(metadata, '$.workflowUi.type'), created_at DESC)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS workspace_asset_daily (
|
||||||
|
local_day TEXT NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
asset_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
local_file_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(local_day, user_id, source, kind)
|
||||||
|
)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS workspace_asset_daily_user_day_idx
|
||||||
|
ON workspace_asset_daily (user_id, local_day DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS workspace_asset_daily_source_day_idx
|
||||||
|
ON workspace_asset_daily (source, local_day DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS route_channels_oauth_route_unit_id_idx
|
||||||
|
ON route_channels (oauth_route_unit_id)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS site_api_endpoints (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
enabled INTEGER DEFAULT true,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
cooldown_until TEXT,
|
||||||
|
last_selected_at TEXT,
|
||||||
|
last_failed_at TEXT,
|
||||||
|
last_failure_reason TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
|
||||||
|
)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS site_api_endpoints_site_url_unique
|
||||||
|
ON site_api_endpoints (site_id, url)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS site_api_endpoints_site_enabled_sort_idx
|
||||||
|
ON site_api_endpoints (site_id, enabled, sort_order)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS site_api_endpoints_site_cooldown_idx
|
||||||
|
ON site_api_endpoints (site_id, cooldown_until)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS proxy_debug_traces (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
downstream_path TEXT NOT NULL,
|
||||||
|
client_kind TEXT,
|
||||||
|
session_id TEXT,
|
||||||
|
trace_hint TEXT,
|
||||||
|
requested_model TEXT,
|
||||||
|
downstream_api_key_id INTEGER,
|
||||||
|
request_headers_json TEXT,
|
||||||
|
request_body_json TEXT,
|
||||||
|
sticky_session_key TEXT,
|
||||||
|
sticky_hit_channel_id INTEGER,
|
||||||
|
selected_channel_id INTEGER,
|
||||||
|
selected_route_id INTEGER,
|
||||||
|
selected_account_id INTEGER,
|
||||||
|
selected_site_id INTEGER,
|
||||||
|
selected_site_platform TEXT,
|
||||||
|
endpoint_candidates_json TEXT,
|
||||||
|
endpoint_runtime_state_json TEXT,
|
||||||
|
decision_summary_json TEXT,
|
||||||
|
final_status TEXT,
|
||||||
|
final_http_status INTEGER,
|
||||||
|
final_upstream_path TEXT,
|
||||||
|
final_response_headers_json TEXT,
|
||||||
|
final_response_body_json TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_created_at_idx
|
||||||
|
ON proxy_debug_traces (created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_session_created_at_idx
|
||||||
|
ON proxy_debug_traces (session_id, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_model_created_at_idx
|
||||||
|
ON proxy_debug_traces (requested_model, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_final_status_created_at_idx
|
||||||
|
ON proxy_debug_traces (final_status, created_at)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS proxy_debug_attempts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
trace_id INTEGER NOT NULL,
|
||||||
|
attempt_index INTEGER NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
request_path TEXT NOT NULL,
|
||||||
|
target_url TEXT NOT NULL,
|
||||||
|
runtime_executor TEXT,
|
||||||
|
request_headers_json TEXT,
|
||||||
|
request_body_json TEXT,
|
||||||
|
response_status INTEGER,
|
||||||
|
response_headers_json TEXT,
|
||||||
|
response_body_json TEXT,
|
||||||
|
raw_error_text TEXT,
|
||||||
|
recover_applied INTEGER DEFAULT false,
|
||||||
|
downgrade_decision INTEGER DEFAULT false,
|
||||||
|
downgrade_reason TEXT,
|
||||||
|
memory_write_json TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (trace_id) REFERENCES proxy_debug_traces(id) ON DELETE CASCADE
|
||||||
|
)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS proxy_debug_attempts_trace_attempt_unique
|
||||||
|
ON proxy_debug_attempts (trace_id, attempt_index)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_debug_attempts_trace_created_at_idx
|
||||||
|
ON proxy_debug_attempts (trace_id, created_at)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS oauth_route_units (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
strategy TEXT NOT NULL DEFAULT 'round_robin',
|
||||||
|
enabled INTEGER DEFAULT true,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
|
||||||
|
)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS oauth_route_units_site_provider_idx
|
||||||
|
ON oauth_route_units (site_id, provider)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS oauth_route_units_enabled_idx
|
||||||
|
ON oauth_route_units (enabled)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS oauth_route_unit_members (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
unit_id INTEGER NOT NULL,
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
success_count INTEGER DEFAULT 0,
|
||||||
|
fail_count INTEGER DEFAULT 0,
|
||||||
|
total_latency_ms INTEGER DEFAULT 0,
|
||||||
|
total_cost REAL DEFAULT 0,
|
||||||
|
last_used_at TEXT,
|
||||||
|
last_selected_at TEXT,
|
||||||
|
last_fail_at TEXT,
|
||||||
|
consecutive_fail_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cooldown_level INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cooldown_until TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (unit_id) REFERENCES oauth_route_units(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
|
||||||
|
)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_unit_account_unique
|
||||||
|
ON oauth_route_unit_members (unit_id, account_id)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_account_unique
|
||||||
|
ON oauth_route_unit_members (account_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_sort_idx
|
||||||
|
ON oauth_route_unit_members (unit_id, sort_order)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_cooldown_idx
|
||||||
|
ON oauth_route_unit_members (unit_id, cooldown_until)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS admin_snapshots (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
namespace TEXT NOT NULL,
|
||||||
|
snapshot_key TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
generated_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
stale_until TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS admin_snapshots_namespace_key_unique
|
||||||
|
ON admin_snapshots (namespace, snapshot_key)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS admin_snapshots_expires_at_idx
|
||||||
|
ON admin_snapshots (expires_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS admin_snapshots_stale_until_idx
|
||||||
|
ON admin_snapshots (stale_until)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS analytics_projection_checkpoints (
|
||||||
|
projector_key TEXT PRIMARY KEY NOT NULL,
|
||||||
|
time_zone TEXT NOT NULL DEFAULT 'Local',
|
||||||
|
last_proxy_log_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
watermark_created_at TEXT,
|
||||||
|
recompute_from_id INTEGER,
|
||||||
|
recompute_requested_at TEXT,
|
||||||
|
recompute_reason TEXT,
|
||||||
|
recompute_started_at TEXT,
|
||||||
|
recompute_completed_at TEXT,
|
||||||
|
last_projected_at TEXT,
|
||||||
|
last_successful_at TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
lease_owner TEXT,
|
||||||
|
lease_token TEXT,
|
||||||
|
lease_expires_at TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_recompute_from_id_idx
|
||||||
|
ON analytics_projection_checkpoints (recompute_from_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_lease_expires_at_idx
|
||||||
|
ON analytics_projection_checkpoints (lease_expires_at)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS model_day_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
local_day TEXT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
total_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
latency_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
|
||||||
|
CHECK (
|
||||||
|
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
|
||||||
|
AND total_tokens >= 0 AND total_spend >= 0
|
||||||
|
AND total_latency_ms >= 0 AND latency_count >= 0
|
||||||
|
)
|
||||||
|
)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS model_day_usage_day_site_model_unique
|
||||||
|
ON model_day_usage (local_day, site_id, model)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS model_day_usage_day_idx
|
||||||
|
ON model_day_usage (local_day)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS model_day_usage_site_id_idx
|
||||||
|
ON model_day_usage (site_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS model_day_usage_model_idx
|
||||||
|
ON model_day_usage (model)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS site_day_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
local_day TEXT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
total_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_summary_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_site_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
latency_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
|
||||||
|
CHECK (
|
||||||
|
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
|
||||||
|
AND total_tokens >= 0 AND total_summary_spend >= 0
|
||||||
|
AND total_site_spend >= 0 AND total_latency_ms >= 0
|
||||||
|
AND latency_count >= 0
|
||||||
|
)
|
||||||
|
)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS site_day_usage_day_site_unique
|
||||||
|
ON site_day_usage (local_day, site_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS site_day_usage_day_idx
|
||||||
|
ON site_day_usage (local_day)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS site_day_usage_site_id_idx
|
||||||
|
ON site_day_usage (site_id)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS site_hour_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
bucket_start_utc TEXT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
total_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_summary_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_site_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
latency_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
|
||||||
|
CHECK (
|
||||||
|
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
|
||||||
|
AND total_tokens >= 0 AND total_summary_spend >= 0
|
||||||
|
AND total_site_spend >= 0 AND total_latency_ms >= 0
|
||||||
|
AND latency_count >= 0
|
||||||
|
)
|
||||||
|
)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS site_hour_usage_hour_site_unique
|
||||||
|
ON site_hour_usage (bucket_start_utc, site_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS site_hour_usage_hour_idx
|
||||||
|
ON site_hour_usage (bucket_start_utc)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS site_hour_usage_site_id_idx
|
||||||
|
ON site_hour_usage (site_id)",
|
||||||
|
];
|
||||||
@@ -299,6 +299,200 @@ const TABLES: &[TableSpec] = &[
|
|||||||
col!("read_at", text),
|
col!("read_at", text),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "site_api_endpoints",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("site_id", i64),
|
||||||
|
col!("url", text),
|
||||||
|
col!("enabled", bool),
|
||||||
|
col!("sort_order", i64),
|
||||||
|
col!("cooldown_until", text),
|
||||||
|
col!("last_selected_at", text),
|
||||||
|
col!("last_failed_at", text),
|
||||||
|
col!("last_failure_reason", text),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "oauth_route_units",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("site_id", i64),
|
||||||
|
col!("provider", text),
|
||||||
|
col!("name", text),
|
||||||
|
col!("strategy", text),
|
||||||
|
col!("enabled", bool),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "oauth_route_unit_members",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("unit_id", i64),
|
||||||
|
col!("account_id", i64),
|
||||||
|
col!("sort_order", i64),
|
||||||
|
col!("success_count", i64),
|
||||||
|
col!("fail_count", i64),
|
||||||
|
col!("total_latency_ms", i64),
|
||||||
|
col!("total_cost", f64),
|
||||||
|
col!("last_used_at", text),
|
||||||
|
col!("last_selected_at", text),
|
||||||
|
col!("last_fail_at", text),
|
||||||
|
col!("consecutive_fail_count", i64),
|
||||||
|
col!("cooldown_level", i64),
|
||||||
|
col!("cooldown_until", text),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "proxy_debug_traces",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("downstream_path", text),
|
||||||
|
col!("client_kind", text),
|
||||||
|
col!("session_id", text),
|
||||||
|
col!("trace_hint", text),
|
||||||
|
col!("requested_model", text),
|
||||||
|
col!("downstream_api_key_id", i64),
|
||||||
|
col!("request_headers_json", text),
|
||||||
|
col!("request_body_json", text),
|
||||||
|
col!("sticky_session_key", text),
|
||||||
|
col!("sticky_hit_channel_id", i64),
|
||||||
|
col!("selected_channel_id", i64),
|
||||||
|
col!("selected_route_id", i64),
|
||||||
|
col!("selected_account_id", i64),
|
||||||
|
col!("selected_site_id", i64),
|
||||||
|
col!("selected_site_platform", text),
|
||||||
|
col!("endpoint_candidates_json", text),
|
||||||
|
col!("endpoint_runtime_state_json", text),
|
||||||
|
col!("decision_summary_json", text),
|
||||||
|
col!("final_status", text),
|
||||||
|
col!("final_http_status", i64),
|
||||||
|
col!("final_upstream_path", text),
|
||||||
|
col!("final_response_headers_json", text),
|
||||||
|
col!("final_response_body_json", text),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "proxy_debug_attempts",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("trace_id", i64),
|
||||||
|
col!("attempt_index", i64),
|
||||||
|
col!("endpoint", text),
|
||||||
|
col!("request_path", text),
|
||||||
|
col!("target_url", text),
|
||||||
|
col!("runtime_executor", text),
|
||||||
|
col!("request_headers_json", text),
|
||||||
|
col!("request_body_json", text),
|
||||||
|
col!("response_status", i64),
|
||||||
|
col!("response_headers_json", text),
|
||||||
|
col!("response_body_json", text),
|
||||||
|
col!("raw_error_text", text),
|
||||||
|
col!("recover_applied", bool),
|
||||||
|
col!("downgrade_decision", bool),
|
||||||
|
col!("downgrade_reason", text),
|
||||||
|
col!("memory_write_json", text),
|
||||||
|
col!("created_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "admin_snapshots",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("namespace", text),
|
||||||
|
col!("snapshot_key", text),
|
||||||
|
col!("payload", text),
|
||||||
|
col!("generated_at", text),
|
||||||
|
col!("expires_at", text),
|
||||||
|
col!("stale_until", text),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "analytics_projection_checkpoints",
|
||||||
|
columns: &[
|
||||||
|
col!("projector_key", text),
|
||||||
|
col!("time_zone", text),
|
||||||
|
col!("last_proxy_log_id", i64),
|
||||||
|
col!("watermark_created_at", text),
|
||||||
|
col!("recompute_from_id", i64),
|
||||||
|
col!("recompute_requested_at", text),
|
||||||
|
col!("recompute_reason", text),
|
||||||
|
col!("recompute_started_at", text),
|
||||||
|
col!("recompute_completed_at", text),
|
||||||
|
col!("last_projected_at", text),
|
||||||
|
col!("last_successful_at", text),
|
||||||
|
col!("last_error", text),
|
||||||
|
col!("lease_owner", text),
|
||||||
|
col!("lease_token", text),
|
||||||
|
col!("lease_expires_at", text),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "model_day_usage",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("local_day", text),
|
||||||
|
col!("site_id", i64),
|
||||||
|
col!("model", text),
|
||||||
|
col!("total_calls", i64),
|
||||||
|
col!("success_calls", i64),
|
||||||
|
col!("failed_calls", i64),
|
||||||
|
col!("total_tokens", i64),
|
||||||
|
col!("total_spend", f64),
|
||||||
|
col!("total_latency_ms", i64),
|
||||||
|
col!("latency_count", i64),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "site_day_usage",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("local_day", text),
|
||||||
|
col!("site_id", i64),
|
||||||
|
col!("total_calls", i64),
|
||||||
|
col!("success_calls", i64),
|
||||||
|
col!("failed_calls", i64),
|
||||||
|
col!("total_tokens", i64),
|
||||||
|
col!("total_summary_spend", f64),
|
||||||
|
col!("total_site_spend", f64),
|
||||||
|
col!("total_latency_ms", i64),
|
||||||
|
col!("latency_count", i64),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "site_hour_usage",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("bucket_start_utc", text),
|
||||||
|
col!("site_id", i64),
|
||||||
|
col!("total_calls", i64),
|
||||||
|
col!("success_calls", i64),
|
||||||
|
col!("failed_calls", i64),
|
||||||
|
col!("total_tokens", i64),
|
||||||
|
col!("total_summary_spend", f64),
|
||||||
|
col!("total_site_spend", f64),
|
||||||
|
col!("total_latency_ms", i64),
|
||||||
|
col!("latency_count", i64),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
pub async fn migrate(
|
pub async fn migrate(
|
||||||
|
|||||||
@@ -66,6 +66,14 @@ impl IntoResponse for AppError {
|
|||||||
} => Some(*retry_after_secs),
|
} => Some(*retry_after_secs),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
let billing = match &self {
|
||||||
|
AppError::RateLimited | AppError::RateLimitedWithRetry { .. } => Some(json!({
|
||||||
|
"reason": "insufficient_quota",
|
||||||
|
"rechargePath": "/user/recharge",
|
||||||
|
"usagePath": "/user/usage",
|
||||||
|
})),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
(
|
(
|
||||||
status,
|
status,
|
||||||
Json(json!({
|
Json(json!({
|
||||||
@@ -74,6 +82,7 @@ impl IntoResponse for AppError {
|
|||||||
"type": status.canonical_reason(),
|
"type": status.canonical_reason(),
|
||||||
},
|
},
|
||||||
"retry_after_secs": retry_after_secs,
|
"retry_after_secs": retry_after_secs,
|
||||||
|
"billing": billing,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
|
|||||||
+24
-5
@@ -6,18 +6,21 @@ pub mod proxy;
|
|||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
pub mod shared;
|
pub mod shared;
|
||||||
|
pub mod template_http;
|
||||||
pub mod transformers;
|
pub mod transformers;
|
||||||
pub mod workflow;
|
pub mod workflow;
|
||||||
|
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use std::sync::Arc;
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub config: config::AppConfig,
|
pub config: config::AppConfig,
|
||||||
pub db: Arc<SqlitePool>,
|
pub db: Arc<SqlitePool>,
|
||||||
|
pub http: reqwest::Client,
|
||||||
pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>,
|
pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>,
|
||||||
pub builtin_keys: Arc<services::builtin_keys::BuiltinKeys>,
|
pub builtin_keys: Arc<services::builtin_keys::BuiltinKeys>,
|
||||||
|
pub runtime_auth: Arc<services::runtime_auth::RuntimeAuthSettings>,
|
||||||
pub register_captcha: Arc<services::register_captcha::RegisterCaptchaStore>,
|
pub register_captcha: Arc<services::register_captcha::RegisterCaptchaStore>,
|
||||||
pub registration_limiter: Arc<services::registration_limiter::RegistrationLimiter>,
|
pub registration_limiter: Arc<services::registration_limiter::RegistrationLimiter>,
|
||||||
}
|
}
|
||||||
@@ -27,6 +30,7 @@ impl AppState {
|
|||||||
config: config::AppConfig,
|
config: config::AppConfig,
|
||||||
db: SqlitePool,
|
db: SqlitePool,
|
||||||
builtin_keys: services::builtin_keys::BuiltinKeys,
|
builtin_keys: services::builtin_keys::BuiltinKeys,
|
||||||
|
runtime_auth: services::runtime_auth::RuntimeAuthSettings,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let boosapi_host = if config.server.host == "0.0.0.0" || config.server.host == "::" {
|
let boosapi_host = if config.server.host == "0.0.0.0" || config.server.host == "::" {
|
||||||
"127.0.0.1".to_string()
|
"127.0.0.1".to_string()
|
||||||
@@ -38,21 +42,36 @@ impl AppState {
|
|||||||
let comfyui = services::comfyui_process::ComfyUIProcess::new(
|
let comfyui = services::comfyui_process::ComfyUIProcess::new(
|
||||||
config.comfyui.clone(),
|
config.comfyui.clone(),
|
||||||
boosapi_url.clone(),
|
boosapi_url.clone(),
|
||||||
config.auth.proxy_token.clone(),
|
runtime_auth.proxy_token(),
|
||||||
comfyui_agent_secret.clone(),
|
comfyui_agent_secret.clone(),
|
||||||
);
|
);
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(config.proxy.upstream_timeout_secs.max(30)))
|
||||||
|
.connect_timeout(Duration::from_secs(15))
|
||||||
|
.pool_idle_timeout(Duration::from_secs(90))
|
||||||
|
.pool_max_idle_per_host(64)
|
||||||
|
.tcp_nodelay(true)
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|err| {
|
||||||
|
tracing::warn!(%err, "failed to build shared HTTP client; falling back to reqwest default");
|
||||||
|
reqwest::Client::new()
|
||||||
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
db: Arc::new(db),
|
db: Arc::new(db),
|
||||||
|
http,
|
||||||
comfyui,
|
comfyui,
|
||||||
builtin_keys: Arc::new(builtin_keys),
|
builtin_keys: Arc::new(builtin_keys),
|
||||||
register_captcha: Arc::new(
|
runtime_auth: Arc::new(runtime_auth),
|
||||||
services::register_captcha::RegisterCaptchaStore::new(),
|
register_captcha: Arc::new(services::register_captcha::RegisterCaptchaStore::new()),
|
||||||
),
|
|
||||||
registration_limiter: Arc::new(
|
registration_limiter: Arc::new(
|
||||||
services::registration_limiter::RegistrationLimiter::new(),
|
services::registration_limiter::RegistrationLimiter::new(),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn runtime_proxy_token(&self) -> String {
|
||||||
|
self.runtime_auth.proxy_token()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-4
@@ -1,11 +1,16 @@
|
|||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use axum::serve;
|
use axum::serve;
|
||||||
use boosapi::{config::AppConfig, db, services::builtin_keys, AppState};
|
use boosapi::{
|
||||||
|
config::AppConfig,
|
||||||
|
db,
|
||||||
|
services::{builtin_keys, runtime_auth},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use tokio::signal;
|
use tokio::signal;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::{prelude::*, EnvFilter};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(
|
#[command(
|
||||||
@@ -104,7 +109,11 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let filter = EnvFilter::try_from_default_env()
|
let filter = EnvFilter::try_from_default_env()
|
||||||
.unwrap_or_else(|_| EnvFilter::new(&config.server.log_level));
|
.unwrap_or_else(|_| EnvFilter::new(&config.server.log_level));
|
||||||
|
|
||||||
tracing_subscriber::fmt().with_env_filter(filter).init();
|
tracing_subscriber::registry()
|
||||||
|
.with(filter)
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.with(boosapi::services::runtime_logs::RuntimeLogLayer)
|
||||||
|
.init();
|
||||||
|
|
||||||
tracing::info!("Loaded configuration from {}", args.config);
|
tracing::info!("Loaded configuration from {}", args.config);
|
||||||
|
|
||||||
@@ -116,6 +125,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// Run migrations
|
// Run migrations
|
||||||
db::run_migrations(db_kind, Some(&pool), None).await?;
|
db::run_migrations(db_kind, Some(&pool), None).await?;
|
||||||
|
db::schema_compat::ensure_sqlite_upstream_compat(&pool).await?;
|
||||||
tracing::info!("Database migrations applied");
|
tracing::info!("Database migrations applied");
|
||||||
|
|
||||||
if args.migrate_only {
|
if args.migrate_only {
|
||||||
@@ -134,9 +144,15 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let builtin = builtin_keys::BuiltinKeys {
|
let builtin = builtin_keys::BuiltinKeys {
|
||||||
comfyui_agent: comfyui_agent_key,
|
comfyui_agent: comfyui_agent_key,
|
||||||
};
|
};
|
||||||
|
let runtime_auth = runtime_auth::RuntimeAuthSettings::load(
|
||||||
|
&pool,
|
||||||
|
config.auth.proxy_token.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("failed to load runtime auth settings")?;
|
||||||
|
|
||||||
// Build app state
|
// Build app state
|
||||||
let state = AppState::new(config.clone(), pool, builtin);
|
let state = AppState::new(config.clone(), pool, builtin, runtime_auth);
|
||||||
if let Err(err) = state.comfyui.start().await {
|
if let Err(err) = state.comfyui.start().await {
|
||||||
tracing::warn!(%err, "ComfyUI embedded process did not start");
|
tracing::warn!(%err, "ComfyUI embedded process did not start");
|
||||||
}
|
}
|
||||||
|
|||||||
+494
-48
@@ -14,9 +14,7 @@ use crate::transformers::{
|
|||||||
canonical::CanonicalRequest,
|
canonical::CanonicalRequest,
|
||||||
};
|
};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use reqwest::Client;
|
use serde_json::{json, Value};
|
||||||
use serde_json::Value;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
/// Resolved upstream info for a given model request.
|
/// Resolved upstream info for a given model request.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -26,15 +24,6 @@ pub struct UpstreamRoute {
|
|||||||
pub model: String,
|
pub model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a shared reqwest client with sensible defaults.
|
|
||||||
fn http_client() -> Client {
|
|
||||||
Client::builder()
|
|
||||||
.timeout(Duration::from_secs(300))
|
|
||||||
.pool_max_idle_per_host(32)
|
|
||||||
.build()
|
|
||||||
.expect("Failed to build HTTP client")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_upstream_json(
|
fn parse_upstream_json(
|
||||||
text: &str,
|
text: &str,
|
||||||
upstream_url: &str,
|
upstream_url: &str,
|
||||||
@@ -174,11 +163,11 @@ fn aggregate_chat_sse(text: &str, fallback_model: &str) -> Option<Value> {
|
|||||||
|
|
||||||
/// Execute a non-streaming chat completions request.
|
/// Execute a non-streaming chat completions request.
|
||||||
pub async fn execute_chat_completions(
|
pub async fn execute_chat_completions(
|
||||||
_state: AppState,
|
state: AppState,
|
||||||
route: UpstreamRoute,
|
route: UpstreamRoute,
|
||||||
body: Value,
|
body: Value,
|
||||||
) -> AppResult<Value> {
|
) -> AppResult<Value> {
|
||||||
let client = http_client();
|
let client = state.http.clone();
|
||||||
let upstream_url = upstream_url(&route.base_url, "/chat/completions");
|
let upstream_url = upstream_url(&route.base_url, "/chat/completions");
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
@@ -210,19 +199,29 @@ pub async fn execute_chat_completions(
|
|||||||
/// Execute a streaming chat completions request.
|
/// Execute a streaming chat completions request.
|
||||||
/// Returns a vector of SSE data chunks as strings.
|
/// Returns a vector of SSE data chunks as strings.
|
||||||
pub async fn execute_chat_completions_stream(
|
pub async fn execute_chat_completions_stream(
|
||||||
_state: AppState,
|
state: AppState,
|
||||||
route: UpstreamRoute,
|
route: UpstreamRoute,
|
||||||
body: Value,
|
body: Value,
|
||||||
) -> AppResult<Vec<String>> {
|
) -> AppResult<Vec<String>> {
|
||||||
execute_streaming_request(route, "/chat/completions", body).await
|
execute_streaming_request(state, route, "/chat/completions", body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a streaming chat completions request and return the live upstream response.
|
||||||
|
pub async fn execute_chat_completions_stream_response(
|
||||||
|
state: AppState,
|
||||||
|
route: UpstreamRoute,
|
||||||
|
body: Value,
|
||||||
|
) -> AppResult<reqwest::Response> {
|
||||||
|
execute_streaming_response(state, route, "/chat/completions", body).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute an Anthropic Messages API request against an Anthropic-shaped upstream.
|
/// Execute an Anthropic Messages API request against an Anthropic-shaped upstream.
|
||||||
pub async fn execute_anthropic_messages(
|
pub async fn execute_anthropic_messages(
|
||||||
|
state: AppState,
|
||||||
route: UpstreamRoute,
|
route: UpstreamRoute,
|
||||||
request: CanonicalRequest,
|
request: CanonicalRequest,
|
||||||
) -> AppResult<Value> {
|
) -> AppResult<Value> {
|
||||||
let client = http_client();
|
let client = state.http.clone();
|
||||||
let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/'));
|
let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/'));
|
||||||
let compatibility = AnthropicMessagesCompatibility::default();
|
let compatibility = AnthropicMessagesCompatibility::default();
|
||||||
let body = format_messages_request_with_compat(&request, &compatibility);
|
let body = format_messages_request_with_compat(&request, &compatibility);
|
||||||
@@ -260,10 +259,21 @@ pub async fn execute_anthropic_messages(
|
|||||||
|
|
||||||
/// Execute a streaming Anthropic Messages request and return Anthropic SSE frames.
|
/// Execute a streaming Anthropic Messages request and return Anthropic SSE frames.
|
||||||
pub async fn execute_anthropic_messages_stream(
|
pub async fn execute_anthropic_messages_stream(
|
||||||
|
state: AppState,
|
||||||
route: UpstreamRoute,
|
route: UpstreamRoute,
|
||||||
request: CanonicalRequest,
|
request: CanonicalRequest,
|
||||||
) -> AppResult<Vec<String>> {
|
) -> AppResult<Vec<String>> {
|
||||||
let client = http_client();
|
let response = execute_anthropic_messages_stream_response(state, route, request).await?;
|
||||||
|
collect_anthropic_stream_frames(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a streaming Anthropic Messages request and return the live upstream response.
|
||||||
|
pub async fn execute_anthropic_messages_stream_response(
|
||||||
|
state: AppState,
|
||||||
|
route: UpstreamRoute,
|
||||||
|
request: CanonicalRequest,
|
||||||
|
) -> AppResult<reqwest::Response> {
|
||||||
|
let client = state.http.clone();
|
||||||
let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/'));
|
let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/'));
|
||||||
let compatibility = AnthropicMessagesCompatibility::default();
|
let compatibility = AnthropicMessagesCompatibility::default();
|
||||||
let body = format_messages_request_with_compat(&request, &compatibility);
|
let body = format_messages_request_with_compat(&request, &compatibility);
|
||||||
@@ -292,6 +302,10 @@ pub async fn execute_anthropic_messages_stream(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn collect_anthropic_stream_frames(resp: reqwest::Response) -> AppResult<Vec<String>> {
|
||||||
let mut frames = Vec::new();
|
let mut frames = Vec::new();
|
||||||
let mut stream = resp.bytes_stream();
|
let mut stream = resp.bytes_stream();
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
@@ -406,32 +420,12 @@ fn denormalize_anthropic_stop_reason(reason: &str) -> &str {
|
|||||||
/// Execute a streaming request against any upstream path (e.g. /responses).
|
/// Execute a streaming request against any upstream path (e.g. /responses).
|
||||||
/// Returns SSE `data: ...` lines verbatim.
|
/// Returns SSE `data: ...` lines verbatim.
|
||||||
pub async fn execute_streaming_request(
|
pub async fn execute_streaming_request(
|
||||||
|
state: AppState,
|
||||||
route: UpstreamRoute,
|
route: UpstreamRoute,
|
||||||
path_suffix: &str,
|
path_suffix: &str,
|
||||||
body: Value,
|
body: Value,
|
||||||
) -> AppResult<Vec<String>> {
|
) -> AppResult<Vec<String>> {
|
||||||
let client = http_client();
|
let resp = execute_streaming_response(state, route, path_suffix, body).await?;
|
||||||
let upstream_url = upstream_url(&route.base_url, path_suffix);
|
|
||||||
|
|
||||||
let resp = client
|
|
||||||
.post(&upstream_url)
|
|
||||||
.header("Authorization", format!("Bearer {}", route.api_key))
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::UpstreamError(format!("Upstream request failed: {e}")))?;
|
|
||||||
|
|
||||||
let status = resp.status();
|
|
||||||
if !status.is_success() {
|
|
||||||
let text = resp
|
|
||||||
.text()
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|_| "unknown error".to_string());
|
|
||||||
return Err(AppError::UpstreamError(format!(
|
|
||||||
"Upstream returned {status}: {text}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut chunks = Vec::new();
|
let mut chunks = Vec::new();
|
||||||
let mut stream = resp.bytes_stream();
|
let mut stream = resp.bytes_stream();
|
||||||
@@ -455,14 +449,50 @@ pub async fn execute_streaming_request(
|
|||||||
Ok(chunks)
|
Ok(chunks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Execute a streaming request against any upstream path and return the live response.
|
||||||
|
///
|
||||||
|
/// The caller owns draining `bytes_stream()` so bytes can be flushed to the
|
||||||
|
/// downstream client as soon as the upstream sends them.
|
||||||
|
pub async fn execute_streaming_response(
|
||||||
|
state: AppState,
|
||||||
|
route: UpstreamRoute,
|
||||||
|
path_suffix: &str,
|
||||||
|
body: Value,
|
||||||
|
) -> AppResult<reqwest::Response> {
|
||||||
|
let client = state.http.clone();
|
||||||
|
let upstream_url = upstream_url(&route.base_url, path_suffix);
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post(&upstream_url)
|
||||||
|
.header("Authorization", format!("Bearer {}", route.api_key))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::UpstreamError(format!("Upstream request failed: {e}")))?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let text = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "unknown error".to_string());
|
||||||
|
return Err(AppError::UpstreamError(format!(
|
||||||
|
"Upstream returned {status}: {text}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
/// Generic non-streaming proxy request to any upstream endpoint path.
|
/// Generic non-streaming proxy request to any upstream endpoint path.
|
||||||
pub async fn execute_proxy_request(
|
pub async fn execute_proxy_request(
|
||||||
_state: AppState,
|
state: AppState,
|
||||||
route: UpstreamRoute,
|
route: UpstreamRoute,
|
||||||
path_suffix: &str,
|
path_suffix: &str,
|
||||||
body: Value,
|
body: Value,
|
||||||
) -> AppResult<Value> {
|
) -> AppResult<Value> {
|
||||||
let client = http_client();
|
let client = state.http.clone();
|
||||||
let upstream_url = upstream_url(&route.base_url, path_suffix);
|
let upstream_url = upstream_url(&route.base_url, path_suffix);
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
@@ -492,8 +522,12 @@ pub async fn execute_proxy_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Generic GET proxy request to an upstream endpoint path.
|
/// Generic GET proxy request to an upstream endpoint path.
|
||||||
pub async fn execute_proxy_get(route: UpstreamRoute, path_suffix: &str) -> AppResult<Value> {
|
pub async fn execute_proxy_get(
|
||||||
let client = http_client();
|
state: AppState,
|
||||||
|
route: UpstreamRoute,
|
||||||
|
path_suffix: &str,
|
||||||
|
) -> AppResult<Value> {
|
||||||
|
let client = state.http.clone();
|
||||||
let upstream_url = upstream_url(&route.base_url, path_suffix);
|
let upstream_url = upstream_url(&route.base_url, path_suffix);
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
@@ -518,6 +552,23 @@ pub async fn execute_proxy_get(route: UpstreamRoute, path_suffix: &str) -> AppRe
|
|||||||
parse_upstream_json(&text, &upstream_url, status)
|
parse_upstream_json(&text, &upstream_url, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn execute_video_task_status(
|
||||||
|
state: AppState,
|
||||||
|
route: UpstreamRoute,
|
||||||
|
id: &str,
|
||||||
|
) -> AppResult<Value> {
|
||||||
|
if comfy_video_template_id(&route).is_some() {
|
||||||
|
return comfy_history(&state, id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let suffix = if is_volcengine_ark_base(&route.base_url) {
|
||||||
|
format!("/contents/generations/tasks/{id}")
|
||||||
|
} else {
|
||||||
|
format!("/videos/{id}")
|
||||||
|
};
|
||||||
|
execute_proxy_get(state, route, &suffix).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Execute an OpenAI-shaped video task request, with Volcengine Ark native
|
/// Execute an OpenAI-shaped video task request, with Volcengine Ark native
|
||||||
/// task endpoints as a first-class compatibility target.
|
/// task endpoints as a first-class compatibility target.
|
||||||
pub async fn execute_video_generation(
|
pub async fn execute_video_generation(
|
||||||
@@ -525,6 +576,10 @@ pub async fn execute_video_generation(
|
|||||||
route: UpstreamRoute,
|
route: UpstreamRoute,
|
||||||
body: Value,
|
body: Value,
|
||||||
) -> AppResult<Value> {
|
) -> AppResult<Value> {
|
||||||
|
if let Some(template_id) = comfy_video_template_id(&route) {
|
||||||
|
return execute_comfy_video_generation(state, route, body, &template_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
if is_volcengine_ark_base(&route.base_url) {
|
if is_volcengine_ark_base(&route.base_url) {
|
||||||
let body = volcengine_video_create_body(body, &route.model)?;
|
let body = volcengine_video_create_body(body, &route.model)?;
|
||||||
return execute_proxy_request(state, route, "/contents/generations/tasks", body).await;
|
return execute_proxy_request(state, route, "/contents/generations/tasks", body).await;
|
||||||
@@ -533,6 +588,76 @@ pub async fn execute_video_generation(
|
|||||||
execute_proxy_request(state, route, "/videos", body).await
|
execute_proxy_request(state, route, "/videos", body).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn comfy_video_template_id(route: &UpstreamRoute) -> Option<String> {
|
||||||
|
route.base_url
|
||||||
|
.strip_prefix("comfy://")
|
||||||
|
.or_else(|| route.base_url.strip_prefix("comfy-template://"))
|
||||||
|
.or_else(|| route.model.strip_prefix("comfy://"))
|
||||||
|
.or_else(|| route.model.strip_prefix("comfy:"))
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_comfy_video_generation(
|
||||||
|
state: AppState,
|
||||||
|
route: UpstreamRoute,
|
||||||
|
body: Value,
|
||||||
|
template_id: &str,
|
||||||
|
) -> AppResult<Value> {
|
||||||
|
if !state.config.comfyui.uses_native_backend() {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Comfy 视频模型需要配置 comfyui.backend = cloud、external 或 runninghub".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let workflow = crate::workflow::storage::get_workflow(&state.db, template_id, None, true)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::BadRequest(format!("Comfy 视频模型模板不可用: {err}")))?;
|
||||||
|
let mut prompt = workflow_prompt_from_graph(&workflow.graph)
|
||||||
|
.ok_or_else(|| AppError::BadRequest("Comfy 视频模型模板缺少 prompt/workflow JSON".into()))?;
|
||||||
|
if prompt_contains_class_type(&prompt, "BoosAPITextToVideo") {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Comfy 视频模型模板不能包含 BoosAPITextToVideo 自定义节点,请选择真实 Comfy 视频节点模板".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut mapping = comfy_mapping_from_graph(&workflow.graph).unwrap_or_else(default_comfy_mapping);
|
||||||
|
if !mapping_has_node(&mapping, &["prompt", "positive", "text", "promptNode"]) {
|
||||||
|
mapping = infer_comfy_mapping(&prompt);
|
||||||
|
}
|
||||||
|
let video_prompt = inject_comfy_video_inputs(&mut prompt, &mapping, &body)?;
|
||||||
|
let client_id = format!("boosapi-videos-{}", uuid::Uuid::new_v4().simple());
|
||||||
|
let response = comfy_json(
|
||||||
|
&state,
|
||||||
|
reqwest::Method::POST,
|
||||||
|
"/prompt",
|
||||||
|
Some(json!({
|
||||||
|
"client_id": client_id,
|
||||||
|
"prompt": prompt,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let prompt_id = scalar_string(
|
||||||
|
response
|
||||||
|
.get("prompt_id")
|
||||||
|
.or_else(|| response.get("promptId"))
|
||||||
|
.or_else(|| response.pointer("/data/prompt_id"))
|
||||||
|
.or_else(|| response.pointer("/data/promptId")),
|
||||||
|
)
|
||||||
|
.ok_or_else(|| AppError::UpstreamError(format!("Comfy response missing prompt_id: {response}")))?;
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"id": prompt_id,
|
||||||
|
"object": "video.generation.task",
|
||||||
|
"model": route.model,
|
||||||
|
"status": "queued",
|
||||||
|
"backend": "comfy",
|
||||||
|
"template_id": template_id,
|
||||||
|
"prompt": video_prompt,
|
||||||
|
"response": response,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
fn is_volcengine_ark_base(base_url: &str) -> bool {
|
fn is_volcengine_ark_base(base_url: &str) -> bool {
|
||||||
base_url.contains("ark.cn-beijing.volces.com") || base_url.contains("volces.com/api/v3")
|
base_url.contains("ark.cn-beijing.volces.com") || base_url.contains("volces.com/api/v3")
|
||||||
}
|
}
|
||||||
@@ -602,13 +727,334 @@ fn scalar_to_string(value: &Value) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scalar_string(value: Option<&Value>) -> Option<String> {
|
||||||
|
scalar_to_string(value?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workflow_prompt_from_graph(graph: &Value) -> Option<Value> {
|
||||||
|
graph.get("prompt")
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| graph.get("workflow").cloned())
|
||||||
|
.or_else(|| graph.get("comfyPrompt").cloned())
|
||||||
|
.or_else(|| graph_to_comfy_prompt(graph))
|
||||||
|
.or_else(|| graph.is_object().then(|| graph.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn comfy_mapping_from_graph(graph: &Value) -> Option<Value> {
|
||||||
|
graph.pointer("/metadata/moneyPrinter/nodeMapping")
|
||||||
|
.or_else(|| graph.pointer("/moneyPrinter/nodeMapping"))
|
||||||
|
.or_else(|| graph.pointer("/metadata/video/nodeMapping"))
|
||||||
|
.or_else(|| graph.pointer("/video/nodeMapping"))
|
||||||
|
.or_else(|| graph.pointer("/metadata/digitalHuman/nodeMapping"))
|
||||||
|
.or_else(|| graph.pointer("/digitalHuman/nodeMapping"))
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn graph_to_comfy_prompt(graph: &Value) -> Option<Value> {
|
||||||
|
let nodes = graph.get("nodes")?.as_array()?;
|
||||||
|
let mut prompt = serde_json::Map::new();
|
||||||
|
for node in nodes {
|
||||||
|
let id = scalar_string(node.get("id"))?;
|
||||||
|
let class_type = node
|
||||||
|
.get("type")
|
||||||
|
.or_else(|| node.get("class_type"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
if class_type.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let inputs = node
|
||||||
|
.get("params")
|
||||||
|
.or_else(|| node.get("inputs"))
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
prompt.insert(id, json!({ "class_type": class_type, "inputs": inputs }));
|
||||||
|
}
|
||||||
|
for edge in graph.get("edges").and_then(Value::as_array).into_iter().flatten() {
|
||||||
|
let Some(from) = edge.get("from").and_then(Value::as_str) else { continue; };
|
||||||
|
let Some(to) = edge.get("to").and_then(Value::as_str) else { continue; };
|
||||||
|
let Some((from_id, _from_port)) = from.split_once('.') else { continue; };
|
||||||
|
let Some((to_id, to_field)) = to.split_once('.') else { continue; };
|
||||||
|
if let Some(inputs) = prompt
|
||||||
|
.get_mut(to_id)
|
||||||
|
.and_then(Value::as_object_mut)
|
||||||
|
.and_then(|node| node.get_mut("inputs"))
|
||||||
|
.and_then(Value::as_object_mut)
|
||||||
|
{
|
||||||
|
inputs.insert(to_field.to_string(), json!([from_id, 0]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Value::Object(prompt))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_comfy_mapping() -> Value {
|
||||||
|
json!({
|
||||||
|
"prompt": { "nodeId": "", "field": "prompt" },
|
||||||
|
"negativePrompt": { "nodeId": "", "field": "text" },
|
||||||
|
"ratio": { "nodeId": "", "field": "ratio" },
|
||||||
|
"resolution": { "nodeId": "", "field": "resolution" },
|
||||||
|
"duration": { "nodeId": "", "field": "duration" }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mapping_value<'a>(mapping: &'a Value, keys: &[&str]) -> Option<&'a Value> {
|
||||||
|
keys.iter().find_map(|key| mapping.get(*key))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mapping_node_id(mapping: &Value, keys: &[&str]) -> Option<String> {
|
||||||
|
mapping_value(mapping, keys).and_then(|value| {
|
||||||
|
if value.is_object() {
|
||||||
|
scalar_string(value.get("nodeId").or_else(|| value.get("node_id")).or(Some(value)))
|
||||||
|
} else {
|
||||||
|
scalar_string(Some(value))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mapping_field(mapping: &Value, keys: &[&str], default_field: &str) -> String {
|
||||||
|
mapping_value(mapping, keys)
|
||||||
|
.and_then(|value| {
|
||||||
|
if value.is_object() {
|
||||||
|
scalar_string(value.get("field").or_else(|| value.get("fieldName")))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| default_field.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mapping_has_node(mapping: &Value, keys: &[&str]) -> bool {
|
||||||
|
mapping_node_id(mapping, keys).is_some_and(|value| !value.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_node_by_class(prompt: &serde_json::Map<String, Value>, class_type: &str) -> Option<String> {
|
||||||
|
prompt
|
||||||
|
.iter()
|
||||||
|
.find(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some(class_type))
|
||||||
|
.map(|(id, _)| id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn infer_comfy_mapping(prompt: &Value) -> Value {
|
||||||
|
let Some(object) = prompt.as_object() else {
|
||||||
|
return default_comfy_mapping();
|
||||||
|
};
|
||||||
|
let prompt_node = object
|
||||||
|
.iter()
|
||||||
|
.find(|(_, node)| {
|
||||||
|
let class_type = node.get("class_type").and_then(Value::as_str).unwrap_or_default().to_ascii_lowercase();
|
||||||
|
let inputs = node
|
||||||
|
.get("inputs")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.map(|value| value.keys().cloned().collect::<Vec<_>>().join(" ").to_ascii_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
class_type.contains("text")
|
||||||
|
|| class_type.contains("prompt")
|
||||||
|
|| class_type.contains("wan")
|
||||||
|
|| class_type.contains("ltx")
|
||||||
|
|| class_type.contains("video")
|
||||||
|
|| inputs.contains("prompt")
|
||||||
|
|| inputs.contains("positive")
|
||||||
|
|| inputs.contains("text")
|
||||||
|
})
|
||||||
|
.map(|(id, _)| id.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let video_node = first_node_by_class(object, "Wan2ImageToVideoApi")
|
||||||
|
.or_else(|| first_node_by_class(object, "Wan2TextToVideoApi"))
|
||||||
|
.or_else(|| first_node_by_class(object, "TextGenerateLTX2Prompt"))
|
||||||
|
.or_else(|| first_node_by_class(object, "KSampler"))
|
||||||
|
.unwrap_or_else(|| prompt_node.clone());
|
||||||
|
json!({
|
||||||
|
"prompt": { "nodeId": prompt_node, "field": "text" },
|
||||||
|
"ratio": { "nodeId": video_node, "field": "ratio" },
|
||||||
|
"resolution": { "nodeId": video_node, "field": "resolution" },
|
||||||
|
"duration": { "nodeId": video_node, "field": "duration" }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value) -> AppResult<()> {
|
||||||
|
if node_id.trim().is_empty() {
|
||||||
|
return Err(AppError::BadRequest("Comfy 视频模型节点映射包含空 nodeId".into()));
|
||||||
|
}
|
||||||
|
let Some(node) = prompt.get_mut(node_id) else {
|
||||||
|
return Err(AppError::BadRequest(format!("Comfy prompt missing node {node_id}")));
|
||||||
|
};
|
||||||
|
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
|
||||||
|
return Err(AppError::BadRequest(format!("Comfy node {node_id} has no inputs")));
|
||||||
|
};
|
||||||
|
inputs.insert(field.to_string(), value);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value) -> AppResult<String> {
|
||||||
|
let video_prompt = scalar_string(body.get("prompt"))
|
||||||
|
.ok_or_else(|| AppError::BadRequest("prompt is required for Comfy video models".into()))?;
|
||||||
|
let prompt_node = mapping_node_id(mapping, &["prompt", "positive", "text", "promptNode"])
|
||||||
|
.ok_or_else(|| AppError::BadRequest("Comfy 视频模型缺少提示词节点映射".into()))?;
|
||||||
|
apply_node_input(
|
||||||
|
prompt,
|
||||||
|
&prompt_node,
|
||||||
|
&mapping_field(mapping, &["prompt", "positive", "text", "promptNode"], "prompt"),
|
||||||
|
json!(video_prompt),
|
||||||
|
)?;
|
||||||
|
if let Some(negative_node) = mapping_node_id(mapping, &["negativePrompt", "negative", "negativeNode"]) {
|
||||||
|
let negative = scalar_string(body.get("negative_prompt").or_else(|| body.get("negative")))
|
||||||
|
.unwrap_or_else(|| "low quality, blurry, distorted, watermark, text artifacts".to_string());
|
||||||
|
apply_node_input(
|
||||||
|
prompt,
|
||||||
|
&negative_node,
|
||||||
|
&mapping_field(mapping, &["negativePrompt", "negative", "negativeNode"], "text"),
|
||||||
|
json!(negative),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
for (keys, request_keys, default_field) in [
|
||||||
|
(&["ratio", "ratioNode"][..], &["ratio", "aspectRatio", "aspect_ratio"][..], "ratio"),
|
||||||
|
(&["resolution", "resolutionNode"][..], &["resolution", "size"][..], "resolution"),
|
||||||
|
(&["duration", "durationNode"][..], &["duration"][..], "duration"),
|
||||||
|
] {
|
||||||
|
if let Some(node_id) = mapping_node_id(mapping, keys) {
|
||||||
|
if let Some(value) = request_keys.iter().find_map(|key| body.get(*key).cloned()) {
|
||||||
|
apply_node_input(prompt, &node_id, &mapping_field(mapping, keys, default_field), value)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(video_prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_contains_class_type(prompt: &Value, class_type: &str) -> bool {
|
||||||
|
match prompt {
|
||||||
|
Value::Object(object) => {
|
||||||
|
object
|
||||||
|
.get("class_type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|value| value == class_type)
|
||||||
|
|| object.values().any(|value| prompt_contains_class_type(value, class_type))
|
||||||
|
}
|
||||||
|
Value::Array(items) => items.iter().any(|value| prompt_contains_class_type(value, class_type)),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn comfy_native_path(state: &AppState, path: &str) -> String {
|
||||||
|
if state.config.comfyui.uses_cloud_backend() {
|
||||||
|
format!(
|
||||||
|
"/api{}",
|
||||||
|
path.strip_prefix('/')
|
||||||
|
.map(|value| format!("/{value}"))
|
||||||
|
.unwrap_or_else(|| path.to_string())
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
path.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn comfy_native_url(state: &AppState, path: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{}{}",
|
||||||
|
state.config.comfyui.target_base_url(),
|
||||||
|
comfy_native_path(state, path)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn comfy_json(
|
||||||
|
state: &AppState,
|
||||||
|
method: reqwest::Method,
|
||||||
|
path: &str,
|
||||||
|
body: Option<Value>,
|
||||||
|
) -> AppResult<Value> {
|
||||||
|
let client = state.http.clone();
|
||||||
|
let mut request = client.request(method, comfy_native_url(state, path));
|
||||||
|
if let Some(api_key) = state
|
||||||
|
.config
|
||||||
|
.comfyui
|
||||||
|
.api_key
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
request = request
|
||||||
|
.header("X-API-Key", api_key)
|
||||||
|
.header("Authorization", format!("Bearer {api_key}"));
|
||||||
|
}
|
||||||
|
if let Some(body) = body {
|
||||||
|
request = request.json(&body);
|
||||||
|
}
|
||||||
|
let response = request
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::UpstreamError(format!("Comfy backend unreachable: {err}")))?;
|
||||||
|
let status = response.status();
|
||||||
|
let text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||||
|
let value = serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({ "body": text }));
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(AppError::UpstreamError(value.to_string()));
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn comfy_history(state: &AppState, prompt_id: &str) -> AppResult<Value> {
|
||||||
|
let path = if state.config.comfyui.uses_cloud_backend() {
|
||||||
|
format!("/jobs/{prompt_id}")
|
||||||
|
} else {
|
||||||
|
format!("/history/{prompt_id}")
|
||||||
|
};
|
||||||
|
let history = comfy_json(state, reqwest::Method::GET, &path, None).await?;
|
||||||
|
let failed = comfy_failure_message(&history);
|
||||||
|
Ok(json!({
|
||||||
|
"id": prompt_id,
|
||||||
|
"object": "video.generation.task",
|
||||||
|
"backend": "comfy",
|
||||||
|
"status": if failed.is_some() {
|
||||||
|
"failed"
|
||||||
|
} else if comfy_output_objects(&history) {
|
||||||
|
"succeeded"
|
||||||
|
} else {
|
||||||
|
"running"
|
||||||
|
},
|
||||||
|
"error": failed,
|
||||||
|
"history": history,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn comfy_failure_message(value: &Value) -> Option<String> {
|
||||||
|
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
if matches!(status.as_str(), "failed" | "error" | "cancelled" | "canceled") {
|
||||||
|
return Some(
|
||||||
|
scalar_string(value.pointer("/execution_error/exception_message"))
|
||||||
|
.or_else(|| scalar_string(value.get("error")))
|
||||||
|
.or_else(|| scalar_string(value.get("message")))
|
||||||
|
.unwrap_or_else(|| format!("Comfy job {status}")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
scalar_string(value.pointer("/execution_error/exception_message"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn comfy_output_objects(value: &Value) -> bool {
|
||||||
|
match value {
|
||||||
|
Value::Object(object) => {
|
||||||
|
["videos", "gifs", "images"]
|
||||||
|
.iter()
|
||||||
|
.any(|key| object.get(*key).and_then(Value::as_array).is_some_and(|items| !items.is_empty()))
|
||||||
|
|| object.values().any(comfy_output_objects)
|
||||||
|
}
|
||||||
|
Value::Array(items) => items.iter().any(comfy_output_objects),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Execute an image generation request.
|
/// Execute an image generation request.
|
||||||
pub async fn execute_image_generation(
|
pub async fn execute_image_generation(
|
||||||
_state: AppState,
|
state: AppState,
|
||||||
route: UpstreamRoute,
|
route: UpstreamRoute,
|
||||||
body: Value,
|
body: Value,
|
||||||
) -> AppResult<Value> {
|
) -> AppResult<Value> {
|
||||||
let client = http_client();
|
let client = state.http.clone();
|
||||||
let upstream_url = upstream_url(&route.base_url, "/images/generations");
|
let upstream_url = upstream_url(&route.base_url, "/images/generations");
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
@@ -661,13 +1107,13 @@ pub struct MultipartField {
|
|||||||
/// the route-resolved upstream model and skip any client-side fields
|
/// the route-resolved upstream model and skip any client-side fields
|
||||||
/// that don't belong on the wire.
|
/// that don't belong on the wire.
|
||||||
pub async fn execute_image_edit(
|
pub async fn execute_image_edit(
|
||||||
_state: AppState,
|
state: AppState,
|
||||||
route: UpstreamRoute,
|
route: UpstreamRoute,
|
||||||
fields: Vec<MultipartField>,
|
fields: Vec<MultipartField>,
|
||||||
) -> AppResult<Value> {
|
) -> AppResult<Value> {
|
||||||
use reqwest::multipart::{Form, Part};
|
use reqwest::multipart::{Form, Part};
|
||||||
|
|
||||||
let client = http_client();
|
let client = state.http.clone();
|
||||||
let upstream_url = upstream_url(&route.base_url, "/images/edits");
|
let upstream_url = upstream_url(&route.base_url, "/images/edits");
|
||||||
|
|
||||||
let mut form = Form::new();
|
let mut form = Form::new();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,8 @@ use serde_json::{json, Value};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
const RUNTIME_HEALTH_DEGRADED_LATENCY_MS: i64 = 5_000;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ListAccountsQuery {
|
pub struct ListAccountsQuery {
|
||||||
pub site_id: Option<i64>,
|
pub site_id: Option<i64>,
|
||||||
@@ -151,10 +153,23 @@ pub async fn list_accounts(
|
|||||||
let site_by_id: std::collections::HashMap<i64, &Site> =
|
let site_by_id: std::collections::HashMap<i64, &Site> =
|
||||||
sites.iter().map(|s| (s.id, s)).collect();
|
sites.iter().map(|s| (s.id, s)).collect();
|
||||||
|
|
||||||
let latest_health_rows: Vec<(i64, String, Option<i64>, Option<String>, Option<i64>, Option<String>)> =
|
let latest_health_rows: Vec<(
|
||||||
|
i64,
|
||||||
|
String,
|
||||||
|
Option<i64>,
|
||||||
|
Option<String>,
|
||||||
|
Option<i64>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<i64>,
|
||||||
|
Option<i64>,
|
||||||
|
i64,
|
||||||
|
)> =
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT l.account_id, l.status, l.http_status, l.error_message, l.latency_ms, l.created_at
|
SELECT l.account_id, l.status, l.http_status, l.error_message, l.latency_ms, l.created_at,
|
||||||
|
l.model_requested, l.model_actual, l.route_id, l.channel_id, l.id
|
||||||
FROM proxy_logs l
|
FROM proxy_logs l
|
||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
SELECT account_id, MAX(id) AS id
|
SELECT account_id, MAX(id) AS id
|
||||||
@@ -176,14 +191,42 @@ pub async fn list_accounts(
|
|||||||
Option<String>,
|
Option<String>,
|
||||||
Option<i64>,
|
Option<i64>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<i64>,
|
||||||
|
Option<i64>,
|
||||||
|
i64,
|
||||||
),
|
),
|
||||||
> = latest_health_rows
|
> = latest_health_rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(
|
.map(
|
||||||
|(account_id, status, http_status, error_message, latency_ms, created_at)| {
|
|(
|
||||||
|
account_id,
|
||||||
|
status,
|
||||||
|
http_status,
|
||||||
|
error_message,
|
||||||
|
latency_ms,
|
||||||
|
created_at,
|
||||||
|
model_requested,
|
||||||
|
model_actual,
|
||||||
|
route_id,
|
||||||
|
channel_id,
|
||||||
|
log_id,
|
||||||
|
)| {
|
||||||
(
|
(
|
||||||
account_id,
|
account_id,
|
||||||
(status, http_status, error_message, latency_ms, created_at),
|
(
|
||||||
|
status,
|
||||||
|
http_status,
|
||||||
|
error_message,
|
||||||
|
latency_ms,
|
||||||
|
created_at,
|
||||||
|
model_requested,
|
||||||
|
model_actual,
|
||||||
|
route_id,
|
||||||
|
channel_id,
|
||||||
|
log_id,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -214,6 +257,11 @@ fn account_response_value(
|
|||||||
Option<String>,
|
Option<String>,
|
||||||
Option<i64>,
|
Option<i64>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<i64>,
|
||||||
|
Option<i64>,
|
||||||
|
i64,
|
||||||
)>,
|
)>,
|
||||||
) -> Value {
|
) -> Value {
|
||||||
let credential_mode = resolve_account_credential_mode(account);
|
let credential_mode = resolve_account_credential_mode(account);
|
||||||
@@ -246,17 +294,47 @@ fn account_response_value(
|
|||||||
"state": "unhealthy",
|
"state": "unhealthy",
|
||||||
"reason": "连接凭证已过期,请更新凭证",
|
"reason": "连接凭证已过期,请更新凭证",
|
||||||
})
|
})
|
||||||
} else if let Some((status, http_status, error_message, latency_ms, created_at)) =
|
} else if let Some((
|
||||||
|
status,
|
||||||
|
http_status,
|
||||||
|
error_message,
|
||||||
|
latency_ms,
|
||||||
|
created_at,
|
||||||
|
model_requested,
|
||||||
|
model_actual,
|
||||||
|
route_id,
|
||||||
|
channel_id,
|
||||||
|
log_id,
|
||||||
|
)) =
|
||||||
runtime_health
|
runtime_health
|
||||||
{
|
{
|
||||||
let ok = status == "success";
|
let ok = status == "success";
|
||||||
|
let is_degraded_latency =
|
||||||
|
ok && latency_ms.is_some_and(|ms| ms >= RUNTIME_HEALTH_DEGRADED_LATENCY_MS);
|
||||||
|
let model = model_actual
|
||||||
|
.as_ref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.or_else(|| model_requested.as_ref().filter(|value| !value.trim().is_empty()));
|
||||||
json!({
|
json!({
|
||||||
"state": if ok { "healthy" } else { "unhealthy" },
|
"state": if !ok {
|
||||||
|
"unhealthy"
|
||||||
|
} else if is_degraded_latency {
|
||||||
|
"degraded"
|
||||||
|
} else {
|
||||||
|
"healthy"
|
||||||
|
},
|
||||||
"reason": if ok {
|
"reason": if ok {
|
||||||
|
if is_degraded_latency {
|
||||||
|
match latency_ms {
|
||||||
|
Some(ms) => format!("最近代理请求成功但延迟偏高,用时 {}ms", ms),
|
||||||
|
None => "最近代理请求成功但延迟偏高".to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
match latency_ms {
|
match latency_ms {
|
||||||
Some(ms) => format!("最近代理请求成功,用时 {}ms", ms),
|
Some(ms) => format!("最近代理请求成功,用时 {}ms", ms),
|
||||||
None => "最近代理请求成功".to_string(),
|
None => "最近代理请求成功".to_string(),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
error_message
|
error_message
|
||||||
.clone()
|
.clone()
|
||||||
@@ -265,11 +343,17 @@ fn account_response_value(
|
|||||||
"latencyMs": latency_ms,
|
"latencyMs": latency_ms,
|
||||||
"httpStatus": http_status,
|
"httpStatus": http_status,
|
||||||
"checkedAt": created_at,
|
"checkedAt": created_at,
|
||||||
|
"modelRequested": model_requested,
|
||||||
|
"modelActual": model_actual,
|
||||||
|
"recentModel": model,
|
||||||
|
"routeId": route_id,
|
||||||
|
"channelId": channel_id,
|
||||||
|
"logId": log_id,
|
||||||
})
|
})
|
||||||
} else if credential_mode == "apikey" || account.status == "active" {
|
} else if credential_mode == "apikey" || account.status == "active" {
|
||||||
json!({
|
json!({
|
||||||
"state": "degraded",
|
"state": "unknown",
|
||||||
"reason": "尚无代理请求记录,等待首次调用后更新",
|
"reason": "尚无代理请求记录,首次调用后自动更新",
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
json!({
|
json!({
|
||||||
@@ -1266,3 +1350,163 @@ pub async fn refresh_account_models(
|
|||||||
}
|
}
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn account_fixture(status: &str, credential_mode: &str) -> Account {
|
||||||
|
Account {
|
||||||
|
id: 7,
|
||||||
|
site_id: 3,
|
||||||
|
username: Some("tester".to_string()),
|
||||||
|
access_token: "sk-test".to_string(),
|
||||||
|
api_token: None,
|
||||||
|
balance: Some(0.0),
|
||||||
|
balance_used: Some(0.0),
|
||||||
|
quota: None,
|
||||||
|
unit_cost: None,
|
||||||
|
value_score: None,
|
||||||
|
status: status.to_string(),
|
||||||
|
checkin_enabled: Some(false),
|
||||||
|
last_checkin_at: None,
|
||||||
|
last_balance_refresh: None,
|
||||||
|
extra_config: Some(json!({ "credentialMode": credential_mode }).to_string()),
|
||||||
|
is_pinned: Some(false),
|
||||||
|
sort_order: Some(0),
|
||||||
|
oauth_provider: None,
|
||||||
|
oauth_account_key: None,
|
||||||
|
oauth_project_id: None,
|
||||||
|
created_at: None,
|
||||||
|
updated_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn site_fixture(status: &str) -> Site {
|
||||||
|
Site {
|
||||||
|
id: 3,
|
||||||
|
name: "Demo".to_string(),
|
||||||
|
platform: "openai".to_string(),
|
||||||
|
url: "https://example.com".to_string(),
|
||||||
|
status: status.to_string(),
|
||||||
|
api_key: None,
|
||||||
|
proxy_url: None,
|
||||||
|
use_system_proxy: None,
|
||||||
|
custom_headers: None,
|
||||||
|
external_checkin_url: None,
|
||||||
|
global_weight: None,
|
||||||
|
post_refresh_probe_enabled: None,
|
||||||
|
post_refresh_probe_model: None,
|
||||||
|
post_refresh_probe_scope: None,
|
||||||
|
post_refresh_probe_latency_threshold_ms: None,
|
||||||
|
is_pinned: Some(false),
|
||||||
|
sort_order: Some(0),
|
||||||
|
created_at: None,
|
||||||
|
updated_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn active_apikey_without_proxy_logs_is_pending_detection_not_degraded() {
|
||||||
|
let account = account_fixture("active", "apikey");
|
||||||
|
let site = site_fixture("active");
|
||||||
|
|
||||||
|
let value = account_response_value(&account, Some(&site), None);
|
||||||
|
let runtime_health = value
|
||||||
|
.get("runtimeHealth")
|
||||||
|
.expect("runtimeHealth should be present");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
runtime_health.get("state").and_then(Value::as_str),
|
||||||
|
Some("unknown")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
runtime_health.get("reason").and_then(Value::as_str),
|
||||||
|
Some("尚无代理请求记录,首次调用后自动更新")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_health_includes_recent_proxy_context_for_diagnostics() {
|
||||||
|
let account = account_fixture("active", "apikey");
|
||||||
|
let site = site_fixture("active");
|
||||||
|
let latest = (
|
||||||
|
"failed".to_string(),
|
||||||
|
Some(404),
|
||||||
|
Some("Not found".to_string()),
|
||||||
|
Some(1820),
|
||||||
|
Some("2026-06-08 10:00:00".to_string()),
|
||||||
|
Some("gpt-4o-mini".to_string()),
|
||||||
|
Some("gpt-4o-mini-2026".to_string()),
|
||||||
|
Some(77),
|
||||||
|
Some(88),
|
||||||
|
101,
|
||||||
|
);
|
||||||
|
|
||||||
|
let value = account_response_value(&account, Some(&site), Some(&latest));
|
||||||
|
let runtime_health = value
|
||||||
|
.get("runtimeHealth")
|
||||||
|
.expect("runtimeHealth should be present");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
runtime_health.get("state").and_then(Value::as_str),
|
||||||
|
Some("unhealthy")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
runtime_health.get("recentModel").and_then(Value::as_str),
|
||||||
|
Some("gpt-4o-mini-2026")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
runtime_health.get("modelRequested").and_then(Value::as_str),
|
||||||
|
Some("gpt-4o-mini")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
runtime_health.get("routeId").and_then(Value::as_i64),
|
||||||
|
Some(77)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
runtime_health.get("channelId").and_then(Value::as_i64),
|
||||||
|
Some(88)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
runtime_health.get("logId").and_then(Value::as_i64),
|
||||||
|
Some(101)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn successful_but_slow_proxy_log_marks_runtime_health_degraded() {
|
||||||
|
let account = account_fixture("active", "apikey");
|
||||||
|
let site = site_fixture("active");
|
||||||
|
let latest = (
|
||||||
|
"success".to_string(),
|
||||||
|
Some(200),
|
||||||
|
None,
|
||||||
|
Some(7928),
|
||||||
|
Some("2026-06-08 10:00:00".to_string()),
|
||||||
|
Some("gpt-4o-mini".to_string()),
|
||||||
|
Some("gpt-4o-mini".to_string()),
|
||||||
|
Some(77),
|
||||||
|
Some(88),
|
||||||
|
101,
|
||||||
|
);
|
||||||
|
|
||||||
|
let value = account_response_value(&account, Some(&site), Some(&latest));
|
||||||
|
let runtime_health = value
|
||||||
|
.get("runtimeHealth")
|
||||||
|
.expect("runtimeHealth should be present");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
runtime_health.get("state").and_then(Value::as_str),
|
||||||
|
Some("degraded")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
runtime_health.get("reason").and_then(Value::as_str),
|
||||||
|
Some("最近代理请求成功但延迟偏高,用时 7928ms")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
runtime_health.get("latencyMs").and_then(Value::as_i64),
|
||||||
|
Some(7928)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+2436
-76
File diff suppressed because it is too large
Load Diff
@@ -409,6 +409,7 @@ mod tests {
|
|||||||
first_byte_timeout_secs: 30,
|
first_byte_timeout_secs: 30,
|
||||||
max_retries: 3,
|
max_retries: 3,
|
||||||
proxy_log_retention_days: 7,
|
proxy_log_retention_days: 7,
|
||||||
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
},
|
},
|
||||||
@@ -416,6 +417,7 @@ mod tests {
|
|||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
comfyui_agent: (1, "sk-comfyui-abcdef0123456789".into()),
|
comfyui_agent: (1, "sk-comfyui-abcdef0123456789".into()),
|
||||||
},
|
},
|
||||||
|
crate::services::runtime_auth::RuntimeAuthSettings::from_token_for_test("proxy"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+77
-16
@@ -24,7 +24,7 @@ mod sites;
|
|||||||
mod tokens;
|
mod tokens;
|
||||||
mod users;
|
mod users;
|
||||||
|
|
||||||
/// Middleware to require admin authentication via config token or admin JWT.
|
/// Middleware to require admin authentication via the configured admin API token.
|
||||||
async fn require_admin_auth(
|
async fn require_admin_auth(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -37,21 +37,9 @@ async fn require_admin_auth(
|
|||||||
.and_then(|v| v.strip_prefix("Bearer "))
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
|
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
|
||||||
|
|
||||||
if auth_header == state.config.auth.auth_token {
|
if auth_header != state.config.auth.auth_token {
|
||||||
return Ok(next.run(request).await);
|
|
||||||
}
|
|
||||||
|
|
||||||
match crate::auth::jwt::validate_jwt(&state.config.auth.jwt_secret, auth_header) {
|
|
||||||
Ok(claims) if claims.role == "admin" => {}
|
|
||||||
Ok(_) => {
|
|
||||||
return Err(AppError::Forbidden(
|
|
||||||
"Admin account is required for this route".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
return Err(AppError::Unauthorized("Invalid admin token".into()));
|
return Err(AppError::Unauthorized("Invalid admin token".into()));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Ok(next.run(request).await)
|
Ok(next.run(request).await)
|
||||||
}
|
}
|
||||||
@@ -73,6 +61,15 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
|||||||
.route(
|
.route(
|
||||||
"/api/sites/{id}/disabled-models",
|
"/api/sites/{id}/disabled-models",
|
||||||
get(sites::list_site_disabled_models).put(sites::update_site_disabled_models),
|
get(sites::list_site_disabled_models).put(sites::update_site_disabled_models),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/sites/{id}/available-models",
|
||||||
|
get(sites::list_site_available_models),
|
||||||
|
)
|
||||||
|
.route("/api/sites/{id}/probe-now", post(sites::probe_site_now))
|
||||||
|
.route(
|
||||||
|
"/api/sites/{id}/probe-stream",
|
||||||
|
get(sites::probe_site_stream),
|
||||||
);
|
);
|
||||||
|
|
||||||
let accounts_routes = Router::new()
|
let accounts_routes = Router::new()
|
||||||
@@ -217,6 +214,7 @@ const HIDDEN_SETTINGS_PREFIX: &str = "builtin_key.";
|
|||||||
|
|
||||||
fn is_hidden_setting_key(key: &str) -> bool {
|
fn is_hidden_setting_key(key: &str) -> bool {
|
||||||
key.starts_with(HIDDEN_SETTINGS_PREFIX)
|
key.starts_with(HIDDEN_SETTINGS_PREFIX)
|
||||||
|
|| key == crate::services::runtime_auth::PROXY_TOKEN_SETTING_KEY
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/settings — return all settings as a key-value map.
|
/// GET /api/settings — return all settings as a key-value map.
|
||||||
@@ -288,9 +286,15 @@ async fn update_setting(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::auth::jwt::generate_jwt;
|
||||||
use crate::config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig};
|
use crate::config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig};
|
||||||
use crate::services::builtin_keys::BuiltinKeys;
|
use crate::services::builtin_keys::BuiltinKeys;
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http::{Request, StatusCode},
|
||||||
|
};
|
||||||
use sqlx::sqlite::SqlitePoolOptions;
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
use tower::ServiceExt as _;
|
||||||
|
|
||||||
async fn settings_state() -> AppState {
|
async fn settings_state() -> AppState {
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
@@ -313,6 +317,12 @@ mod tests {
|
|||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO settings (key, value) VALUES ('runtime.auth.proxy_token', 'sk-runtime-secret')",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
AppState::new(
|
AppState::new(
|
||||||
AppConfig {
|
AppConfig {
|
||||||
@@ -341,6 +351,7 @@ mod tests {
|
|||||||
first_byte_timeout_secs: 30,
|
first_byte_timeout_secs: 30,
|
||||||
max_retries: 3,
|
max_retries: 3,
|
||||||
proxy_log_retention_days: 7,
|
proxy_log_retention_days: 7,
|
||||||
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
},
|
},
|
||||||
@@ -348,11 +359,12 @@ mod tests {
|
|||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
comfyui_agent: (1, "sk-comfyui-secret".into()),
|
comfyui_agent: (1, "sk-comfyui-secret".into()),
|
||||||
},
|
},
|
||||||
|
crate::services::runtime_auth::RuntimeAuthSettings::from_token_for_test("proxy"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 5.2 — `GET /api/settings` returns regular settings but never any
|
/// 5.2 — `GET /api/settings` returns regular settings but never protected
|
||||||
/// `builtin_key.*` rows.
|
/// secret rows.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn list_settings_filters_builtin_key_prefix() {
|
async fn list_settings_filters_builtin_key_prefix() {
|
||||||
let state = settings_state().await;
|
let state = settings_state().await;
|
||||||
@@ -363,6 +375,10 @@ mod tests {
|
|||||||
!data.contains_key("builtin_key.comfyui_agent.secret"),
|
!data.contains_key("builtin_key.comfyui_agent.secret"),
|
||||||
"builtin_key.* rows must not appear in /api/settings response"
|
"builtin_key.* rows must not appear in /api/settings response"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
!data.contains_key("runtime.auth.proxy_token"),
|
||||||
|
"runtime proxy token must not appear in /api/settings response"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 5.3 — `PUT /api/settings` rejects writes targeting `builtin_key.*`
|
/// 5.3 — `PUT /api/settings` rejects writes targeting `builtin_key.*`
|
||||||
@@ -401,4 +417,49 @@ mod tests {
|
|||||||
.expect("update ok");
|
.expect("update ok");
|
||||||
assert_eq!(resp.0["data"]["value"], "Renamed");
|
assert_eq!(resp.0["data"]["value"], "Renamed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_routes_accept_config_token_and_reject_admin_jwt() {
|
||||||
|
let state = settings_state().await;
|
||||||
|
let app = routes(state.clone()).with_state(state.clone());
|
||||||
|
|
||||||
|
let token_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/settings/auth/info")
|
||||||
|
.header("Authorization", "Bearer auth")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("token request");
|
||||||
|
assert_eq!(token_response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let admin_jwt = generate_jwt(&state.config.auth.jwt_secret, "1", "admin", 1)
|
||||||
|
.expect("admin jwt");
|
||||||
|
let jwt_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/settings/auth/info")
|
||||||
|
.header("Authorization", format!("Bearer {admin_jwt}"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("jwt request");
|
||||||
|
assert_eq!(jwt_response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
let missing_response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/settings/auth/info")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("missing auth request");
|
||||||
|
assert_eq!(missing_response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+962
-6
File diff suppressed because it is too large
Load Diff
+150
-1
@@ -324,7 +324,10 @@ pub async fn get_user_usage(
|
|||||||
// Recent logs
|
// Recent logs
|
||||||
let recent_logs: Vec<Value> = sqlx::query(
|
let recent_logs: Vec<Value> = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT l.*
|
SELECT
|
||||||
|
l.*,
|
||||||
|
k.id AS downstream_api_key_id,
|
||||||
|
k.name AS downstream_key_name
|
||||||
FROM usage_logs l
|
FROM usage_logs l
|
||||||
JOIN downstream_api_keys k ON l.downstream_key_id = k.id
|
JOIN downstream_api_keys k ON l.downstream_key_id = k.id
|
||||||
WHERE k.user_id = ?
|
WHERE k.user_id = ?
|
||||||
@@ -341,6 +344,11 @@ pub async fn get_user_usage(
|
|||||||
json!({
|
json!({
|
||||||
"id": row.get::<i64, _>("id"),
|
"id": row.get::<i64, _>("id"),
|
||||||
"model": row.get::<Option<String>, _>("model"),
|
"model": row.get::<Option<String>, _>("model"),
|
||||||
|
"downstreamApiKeyId": row.get::<Option<i64>, _>("downstream_api_key_id"),
|
||||||
|
"downstreamKeyName": row.get::<Option<String>, _>("downstream_key_name"),
|
||||||
|
"status": "success",
|
||||||
|
"httpStatus": 200,
|
||||||
|
"source": "usage-logs",
|
||||||
"promptTokens": row.get::<Option<i64>, _>("prompt_tokens").unwrap_or(0),
|
"promptTokens": row.get::<Option<i64>, _>("prompt_tokens").unwrap_or(0),
|
||||||
"completionTokens": row.get::<Option<i64>, _>("completion_tokens").unwrap_or(0),
|
"completionTokens": row.get::<Option<i64>, _>("completion_tokens").unwrap_or(0),
|
||||||
"totalTokens": row.get::<Option<i64>, _>("total_tokens").unwrap_or(0),
|
"totalTokens": row.get::<Option<i64>, _>("total_tokens").unwrap_or(0),
|
||||||
@@ -699,3 +707,144 @@ pub async fn update_user_key(
|
|||||||
|
|
||||||
Ok(Json(json!({ "data": updated })))
|
Ok(Json(json!({ "data": updated })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig},
|
||||||
|
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
|
||||||
|
};
|
||||||
|
use axum::extract::State;
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
|
||||||
|
async fn test_state() -> AppState {
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.expect("in-memory database");
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("users table");
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE downstream_api_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
group_name TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
enabled INTEGER DEFAULT 1,
|
||||||
|
expires_at TEXT,
|
||||||
|
max_cost REAL,
|
||||||
|
used_cost REAL DEFAULT 0,
|
||||||
|
max_requests INTEGER,
|
||||||
|
used_requests INTEGER DEFAULT 0,
|
||||||
|
supported_models TEXT,
|
||||||
|
allowed_route_ids TEXT,
|
||||||
|
site_weight_multipliers TEXT,
|
||||||
|
excluded_site_ids TEXT,
|
||||||
|
excluded_credential_refs TEXT,
|
||||||
|
last_used_at TEXT,
|
||||||
|
user_id INTEGER,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("downstream keys table");
|
||||||
|
|
||||||
|
AppState::new(
|
||||||
|
AppConfig {
|
||||||
|
server: ServerConfig {
|
||||||
|
host: "127.0.0.1".into(),
|
||||||
|
port: 0,
|
||||||
|
log_level: "info".into(),
|
||||||
|
cors_origins: vec![],
|
||||||
|
},
|
||||||
|
db: DbConfig {
|
||||||
|
url: "sqlite::memory:".into(),
|
||||||
|
max_connections: 1,
|
||||||
|
ssl: false,
|
||||||
|
runtime_db_url: None,
|
||||||
|
runtime_db_dialect: None,
|
||||||
|
runtime_db_ssl: None,
|
||||||
|
},
|
||||||
|
auth: AuthConfig {
|
||||||
|
jwt_secret: "test-secret".into(),
|
||||||
|
jwt_expiry_hours: 1,
|
||||||
|
auth_token: "auth".into(),
|
||||||
|
proxy_token: "proxy".into(),
|
||||||
|
},
|
||||||
|
proxy: ProxyConfig {
|
||||||
|
upstream_timeout_secs: 30,
|
||||||
|
first_byte_timeout_secs: 30,
|
||||||
|
max_retries: 3,
|
||||||
|
proxy_log_retention_days: 7,
|
||||||
|
template_proxy_url: None,
|
||||||
|
},
|
||||||
|
comfyui: Default::default(),
|
||||||
|
},
|
||||||
|
pool,
|
||||||
|
BuiltinKeys {
|
||||||
|
comfyui_agent: (1, "sk-comfyui-test".into()),
|
||||||
|
},
|
||||||
|
RuntimeAuthSettings::from_token_for_test("proxy"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_user_persists_admin_role_for_existing_user() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let user_id = sqlx::query(
|
||||||
|
"INSERT INTO users (username, email, password_hash, role, status)
|
||||||
|
VALUES (?, ?, ?, 'user', 'active')",
|
||||||
|
)
|
||||||
|
.bind("Regular")
|
||||||
|
.bind("regular@example.test")
|
||||||
|
.bind("hash")
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert user")
|
||||||
|
.last_insert_rowid();
|
||||||
|
|
||||||
|
let Json(value) = update_user(
|
||||||
|
State(state.clone()),
|
||||||
|
Path(user_id),
|
||||||
|
Json(UpdateUserRequest {
|
||||||
|
username: None,
|
||||||
|
email: None,
|
||||||
|
password: None,
|
||||||
|
role: Some("admin".into()),
|
||||||
|
status: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("update user");
|
||||||
|
|
||||||
|
assert_eq!(value["data"]["role"], "admin");
|
||||||
|
|
||||||
|
let role: String = sqlx::query_scalar("SELECT role FROM users WHERE id = ?")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("read role");
|
||||||
|
assert_eq!(role, "admin");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+867
-13
@@ -10,14 +10,14 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{ConnectInfo, Path, State},
|
extract::{ConnectInfo, Path, State},
|
||||||
http::HeaderMap,
|
http::{header, HeaderMap},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::net::SocketAddr;
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use tracing::info;
|
use tracing::{info, warn};
|
||||||
|
|
||||||
// ── Request / Response types ─────────────────────────────────────────
|
// ── Request / Response types ─────────────────────────────────────────
|
||||||
|
|
||||||
@@ -70,7 +70,6 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.route("/api/users/register", post(register))
|
.route("/api/users/register", post(register))
|
||||||
.route("/api/auth/login", post(login))
|
.route("/api/auth/login", post(login))
|
||||||
.route("/api/users/login", post(login))
|
.route("/api/users/login", post(login))
|
||||||
.route("/api/auth/login-api-key", post(login_api_key))
|
|
||||||
.route("/api/users/login-api-key", post(login_api_key))
|
.route("/api/users/login-api-key", post(login_api_key))
|
||||||
.route("/api/auth/me", get(me))
|
.route("/api/auth/me", get(me))
|
||||||
.route("/api/users/me", get(me).patch(update_me))
|
.route("/api/users/me", get(me).patch(update_me))
|
||||||
@@ -229,24 +228,82 @@ async fn register(
|
|||||||
/// POST /api/auth/login
|
/// POST /api/auth/login
|
||||||
async fn login(
|
async fn login(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
Json(payload): Json<LoginRequest>,
|
Json(payload): Json<LoginRequest>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
info!("Login: {}", payload.email);
|
info!("Login: {}", payload.email);
|
||||||
|
let client_ip = client_ip_from_headers(&headers, addr);
|
||||||
|
let user_agent = user_agent_from_headers(&headers);
|
||||||
|
let email = payload.email.trim().to_string();
|
||||||
|
|
||||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
|
let user: User = if let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
|
||||||
.bind(&payload.email)
|
.bind(&email)
|
||||||
.fetch_optional(&*state.db)
|
.fetch_optional(&*state.db)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::Unauthorized("Invalid email or password".into()))?;
|
{
|
||||||
|
user
|
||||||
|
} else {
|
||||||
|
audit_user_login(&state, LoginAuditEvent {
|
||||||
|
method: LoginMethod::Password,
|
||||||
|
success: false,
|
||||||
|
user: None,
|
||||||
|
identifier: Some(&email),
|
||||||
|
api_key: None,
|
||||||
|
client_ip: &client_ip,
|
||||||
|
user_agent: user_agent.as_deref(),
|
||||||
|
reason: Some("Invalid email or password"),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
return Err(AppError::Unauthorized("Invalid email or password".into()));
|
||||||
|
};
|
||||||
|
|
||||||
if user.status != "active" {
|
if user.status != "active" {
|
||||||
|
audit_user_login(&state, LoginAuditEvent {
|
||||||
|
method: LoginMethod::Password,
|
||||||
|
success: false,
|
||||||
|
user: Some(&user),
|
||||||
|
identifier: Some(&email),
|
||||||
|
api_key: None,
|
||||||
|
client_ip: &client_ip,
|
||||||
|
user_agent: user_agent.as_deref(),
|
||||||
|
reason: Some("Account is disabled"),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
return Err(AppError::Forbidden("Account is disabled".into()));
|
return Err(AppError::Forbidden("Account is disabled".into()));
|
||||||
}
|
}
|
||||||
|
if user.role == "admin" {
|
||||||
|
audit_user_login(&state, LoginAuditEvent {
|
||||||
|
method: LoginMethod::Password,
|
||||||
|
success: false,
|
||||||
|
user: Some(&user),
|
||||||
|
identifier: Some(&email),
|
||||||
|
api_key: None,
|
||||||
|
client_ip: &client_ip,
|
||||||
|
user_agent: user_agent.as_deref(),
|
||||||
|
reason: Some("Admin users must sign in with the admin API token"),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"Admin users must sign in with the admin API token".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let valid = verify_password(&payload.password, &user.password_hash)
|
let valid = verify_password(&payload.password, &user.password_hash)
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?;
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?;
|
||||||
|
|
||||||
if !valid {
|
if !valid {
|
||||||
|
audit_user_login(&state, LoginAuditEvent {
|
||||||
|
method: LoginMethod::Password,
|
||||||
|
success: false,
|
||||||
|
user: Some(&user),
|
||||||
|
identifier: Some(&email),
|
||||||
|
api_key: None,
|
||||||
|
client_ip: &client_ip,
|
||||||
|
user_agent: user_agent.as_deref(),
|
||||||
|
reason: Some("Invalid email or password"),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
return Err(AppError::Unauthorized("Invalid email or password".into()));
|
return Err(AppError::Unauthorized("Invalid email or password".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +315,18 @@ async fn login(
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
||||||
|
|
||||||
|
audit_user_login(&state, LoginAuditEvent {
|
||||||
|
method: LoginMethod::Password,
|
||||||
|
success: true,
|
||||||
|
user: Some(&user),
|
||||||
|
identifier: Some(&email),
|
||||||
|
api_key: None,
|
||||||
|
client_ip: &client_ip,
|
||||||
|
user_agent: user_agent.as_deref(),
|
||||||
|
reason: None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"token": token,
|
"token": token,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -288,10 +357,25 @@ async fn login(
|
|||||||
/// be enabled, and the owning user must be active.
|
/// be enabled, and the owning user must be active.
|
||||||
async fn login_api_key(
|
async fn login_api_key(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
Json(payload): Json<LoginApiKeyRequest>,
|
Json(payload): Json<LoginApiKeyRequest>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
|
let client_ip = client_ip_from_headers(&headers, addr);
|
||||||
|
let user_agent = user_agent_from_headers(&headers);
|
||||||
let key = payload.api_key.trim();
|
let key = payload.api_key.trim();
|
||||||
if key.is_empty() {
|
if key.is_empty() {
|
||||||
|
audit_user_login(&state, LoginAuditEvent {
|
||||||
|
method: LoginMethod::ApiKey,
|
||||||
|
success: false,
|
||||||
|
user: None,
|
||||||
|
identifier: None,
|
||||||
|
api_key: None,
|
||||||
|
client_ip: &client_ip,
|
||||||
|
user_agent: user_agent.as_deref(),
|
||||||
|
reason: Some("api_key is required"),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
return Err(AppError::BadRequest("api_key is required".into()));
|
return Err(AppError::BadRequest("api_key is required".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,20 +386,91 @@ async fn login_api_key(
|
|||||||
.fetch_optional(&*state.db)
|
.fetch_optional(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let api_key = row.ok_or_else(|| AppError::Unauthorized("Invalid API key".into()))?;
|
let api_key = if let Some(api_key) = row {
|
||||||
let user_id = api_key
|
api_key
|
||||||
.user_id
|
} else {
|
||||||
.ok_or_else(|| AppError::Unauthorized("API key is not bound to a user".into()))?;
|
let masked_key = mask_api_key(key);
|
||||||
|
audit_user_login(&state, LoginAuditEvent {
|
||||||
|
method: LoginMethod::ApiKey,
|
||||||
|
success: false,
|
||||||
|
user: None,
|
||||||
|
identifier: Some(masked_key.as_str()),
|
||||||
|
api_key: None,
|
||||||
|
client_ip: &client_ip,
|
||||||
|
user_agent: user_agent.as_deref(),
|
||||||
|
reason: Some("Invalid API key"),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
return Err(AppError::Unauthorized("Invalid API key".into()));
|
||||||
|
};
|
||||||
|
let user_id = if let Some(user_id) = api_key.user_id {
|
||||||
|
user_id
|
||||||
|
} else {
|
||||||
|
audit_user_login(&state, LoginAuditEvent {
|
||||||
|
method: LoginMethod::ApiKey,
|
||||||
|
success: false,
|
||||||
|
user: None,
|
||||||
|
identifier: Some(&api_key.name),
|
||||||
|
api_key: Some(&api_key),
|
||||||
|
client_ip: &client_ip,
|
||||||
|
user_agent: user_agent.as_deref(),
|
||||||
|
reason: Some("API key is not bound to a user"),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
return Err(AppError::Unauthorized("API key is not bound to a user".into()));
|
||||||
|
};
|
||||||
|
|
||||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
let user: User = if let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(&*state.db)
|
.fetch_optional(&*state.db)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::Unauthorized("User not found".into()))?;
|
{
|
||||||
|
user
|
||||||
|
} else {
|
||||||
|
audit_user_login(&state, LoginAuditEvent {
|
||||||
|
method: LoginMethod::ApiKey,
|
||||||
|
success: false,
|
||||||
|
user: None,
|
||||||
|
identifier: Some(&api_key.name),
|
||||||
|
api_key: Some(&api_key),
|
||||||
|
client_ip: &client_ip,
|
||||||
|
user_agent: user_agent.as_deref(),
|
||||||
|
reason: Some("User not found"),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
return Err(AppError::Unauthorized("User not found".into()));
|
||||||
|
};
|
||||||
|
|
||||||
if user.status != "active" {
|
if user.status != "active" {
|
||||||
|
audit_user_login(&state, LoginAuditEvent {
|
||||||
|
method: LoginMethod::ApiKey,
|
||||||
|
success: false,
|
||||||
|
user: Some(&user),
|
||||||
|
identifier: Some(&user.email),
|
||||||
|
api_key: Some(&api_key),
|
||||||
|
client_ip: &client_ip,
|
||||||
|
user_agent: user_agent.as_deref(),
|
||||||
|
reason: Some("Account is disabled"),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
return Err(AppError::Forbidden("Account is disabled".into()));
|
return Err(AppError::Forbidden("Account is disabled".into()));
|
||||||
}
|
}
|
||||||
|
if user.role == "admin" {
|
||||||
|
audit_user_login(&state, LoginAuditEvent {
|
||||||
|
method: LoginMethod::ApiKey,
|
||||||
|
success: false,
|
||||||
|
user: Some(&user),
|
||||||
|
identifier: Some(&user.email),
|
||||||
|
api_key: Some(&api_key),
|
||||||
|
client_ip: &client_ip,
|
||||||
|
user_agent: user_agent.as_deref(),
|
||||||
|
reason: Some("Admin users must sign in with the admin API token"),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"Admin users must sign in with the admin API token".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
info!("Login via API key: user_id={}", user.id);
|
info!("Login via API key: user_id={}", user.id);
|
||||||
|
|
||||||
@@ -327,6 +482,18 @@ async fn login_api_key(
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
||||||
|
|
||||||
|
audit_user_login(&state, LoginAuditEvent {
|
||||||
|
method: LoginMethod::ApiKey,
|
||||||
|
success: true,
|
||||||
|
user: Some(&user),
|
||||||
|
identifier: Some(&user.email),
|
||||||
|
api_key: Some(&api_key),
|
||||||
|
client_ip: &client_ip,
|
||||||
|
user_agent: user_agent.as_deref(),
|
||||||
|
reason: None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"token": token,
|
"token": token,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -503,6 +670,225 @@ async fn get_user(
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── User login audit helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum LoginMethod {
|
||||||
|
Password,
|
||||||
|
ApiKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoginMethod {
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
LoginMethod::Password => "password",
|
||||||
|
LoginMethod::ApiKey => "api_key",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title_label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
LoginMethod::Password => "密码登录",
|
||||||
|
LoginMethod::ApiKey => "API Key 登录",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoginAuditEvent<'a> {
|
||||||
|
method: LoginMethod,
|
||||||
|
success: bool,
|
||||||
|
user: Option<&'a User>,
|
||||||
|
identifier: Option<&'a str>,
|
||||||
|
api_key: Option<&'a DownstreamApiKey>,
|
||||||
|
client_ip: &'a str,
|
||||||
|
user_agent: Option<&'a str>,
|
||||||
|
reason: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn audit_user_login(state: &AppState, audit: LoginAuditEvent<'_>) {
|
||||||
|
let event_type = "user_login";
|
||||||
|
let level = if audit.success { "info" } else { "warning" };
|
||||||
|
let account_label = display_user_for_audit(audit.user, audit.identifier);
|
||||||
|
let title = if audit.success {
|
||||||
|
format!("用户登录成功:{account_label}")
|
||||||
|
} else {
|
||||||
|
format!("用户登录失败:{account_label}")
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut message_parts = Vec::new();
|
||||||
|
if let Some(user) = audit.user {
|
||||||
|
message_parts.push(format!("账号={}", user_account_label_for_audit(user)));
|
||||||
|
if !user.email.trim().is_empty() {
|
||||||
|
message_parts.push(format!("邮箱={}", user.email.trim()));
|
||||||
|
}
|
||||||
|
message_parts.push(format!("用户ID={}", user.id));
|
||||||
|
} else if let Some(identifier) = audit.identifier {
|
||||||
|
message_parts.push(format!("标识={}", identifier));
|
||||||
|
}
|
||||||
|
message_parts.push(format!("方式={}", audit.method.label()));
|
||||||
|
message_parts.push(format!("IP={}", audit.client_ip));
|
||||||
|
if let Some(api_key) = audit.api_key {
|
||||||
|
message_parts.push(format!("密钥={}", api_key.name));
|
||||||
|
message_parts.push(format!("密钥ID={}", api_key.id));
|
||||||
|
}
|
||||||
|
if let Some(reason) = audit.reason.filter(|value| !value.trim().is_empty()) {
|
||||||
|
message_parts.push(format!("原因={reason}"));
|
||||||
|
}
|
||||||
|
if let Some(user_agent) = audit.user_agent.filter(|value| !value.trim().is_empty()) {
|
||||||
|
message_parts.push(format!("UA={}", truncate_for_audit(user_agent, 180)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = sqlx::query(
|
||||||
|
"INSERT INTO events (type, title, message, level, read, related_id, related_type)
|
||||||
|
VALUES (?, ?, ?, ?, 0, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(event_type)
|
||||||
|
.bind(title)
|
||||||
|
.bind(message_parts.join(" · "))
|
||||||
|
.bind(level)
|
||||||
|
.bind(audit.user.map(|user| user.id))
|
||||||
|
.bind(if audit.user.is_some() { Some("user") } else { None::<&str> })
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
%err,
|
||||||
|
method = audit.method.label(),
|
||||||
|
success = audit.success,
|
||||||
|
client_ip = audit.client_ip,
|
||||||
|
"failed to write user login audit event",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
info!(
|
||||||
|
method = audit.method.label(),
|
||||||
|
method_title = audit.method.title_label(),
|
||||||
|
success = audit.success,
|
||||||
|
client_ip = audit.client_ip,
|
||||||
|
"user login audit recorded",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_user_for_audit(user: Option<&User>, identifier: Option<&str>) -> String {
|
||||||
|
if let Some(user) = user {
|
||||||
|
return user_account_label_for_audit(user);
|
||||||
|
}
|
||||||
|
identifier
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("未知用户")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_account_label_for_audit(user: &User) -> String {
|
||||||
|
let username = user.username.trim();
|
||||||
|
let email = user.email.trim();
|
||||||
|
match (username.is_empty(), email.is_empty(), username == email) {
|
||||||
|
(false, false, false) => format!("{username} <{email}>"),
|
||||||
|
(false, _, _) => username.to_string(),
|
||||||
|
(_, false, _) => email.to_string(),
|
||||||
|
_ => format!("用户 #{}", user.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_agent_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||||
|
headers
|
||||||
|
.get(header::USER_AGENT)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(|value| truncate_for_audit(value, 240))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_ip_from_headers(headers: &HeaderMap, fallback: SocketAddr) -> String {
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
for name in [
|
||||||
|
"cf-connecting-ip",
|
||||||
|
"x-real-ip",
|
||||||
|
"x-forwarded-for",
|
||||||
|
"forwarded",
|
||||||
|
] {
|
||||||
|
candidates.extend(header_ip_values(headers, name));
|
||||||
|
}
|
||||||
|
if let Some(ipv4) = candidates.iter().find(|value| is_ipv4_address(value)) {
|
||||||
|
return ipv4.clone();
|
||||||
|
}
|
||||||
|
if let Some(first) = candidates.into_iter().next() {
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
fallback.ip().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ipv4_address(value: &str) -> bool {
|
||||||
|
matches!(value.parse::<IpAddr>(), Ok(IpAddr::V4(_)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_ip_values(headers: &HeaderMap, name: &str) -> Vec<String> {
|
||||||
|
let Some(raw) = headers.get(name).and_then(|value| value.to_str().ok()) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let raw = raw.trim();
|
||||||
|
if raw.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if name.eq_ignore_ascii_case("forwarded") {
|
||||||
|
return forwarded_header_ips(raw);
|
||||||
|
}
|
||||||
|
raw.split(',').filter_map(normalize_ip_token).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forwarded_header_ips(raw: &str) -> Vec<String> {
|
||||||
|
raw.split(',')
|
||||||
|
.flat_map(|entry| entry.split(';'))
|
||||||
|
.filter_map(|part| {
|
||||||
|
let (key, value) = part.split_once('=')?;
|
||||||
|
if !key.trim().eq_ignore_ascii_case("for") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
normalize_ip_token(value)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_ip_token(raw: &str) -> Option<String> {
|
||||||
|
let mut token = raw.trim().trim_matches('"').trim_matches('\'').trim();
|
||||||
|
if token.is_empty() || token.eq_ignore_ascii_case("unknown") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if token.starts_with('[') {
|
||||||
|
if let Some(end) = token.find(']') {
|
||||||
|
token = &token[1..end];
|
||||||
|
}
|
||||||
|
} else if let Some((host, port)) = token.rsplit_once(':') {
|
||||||
|
if host.matches(':').count() == 0 && port.parse::<u16>().is_ok() {
|
||||||
|
token = host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token
|
||||||
|
.parse::<IpAddr>()
|
||||||
|
.map(|ip| ip.to_string())
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_for_audit(value: &str, max_chars: usize) -> String {
|
||||||
|
if value.chars().count() <= max_chars {
|
||||||
|
return value.to_string();
|
||||||
|
}
|
||||||
|
let mut truncated = value.chars().take(max_chars.saturating_sub(1)).collect::<String>();
|
||||||
|
truncated.push('…');
|
||||||
|
truncated
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mask_api_key(key: &str) -> String {
|
||||||
|
let trimmed = key.trim();
|
||||||
|
let tail = trimmed.chars().rev().take(4).collect::<Vec<_>>();
|
||||||
|
if tail.is_empty() {
|
||||||
|
return "sk-****".to_string();
|
||||||
|
}
|
||||||
|
let suffix = tail.into_iter().rev().collect::<String>();
|
||||||
|
format!("••••{suffix}")
|
||||||
|
}
|
||||||
|
|
||||||
// ── Inline JWT extract helper for this module ──────────────────────────
|
// ── Inline JWT extract helper for this module ──────────────────────────
|
||||||
|
|
||||||
fn extract_jwt(state: &AppState, headers: &HeaderMap) -> AppResult<(i64, String)> {
|
fn extract_jwt(state: &AppState, headers: &HeaderMap) -> AppResult<(i64, String)> {
|
||||||
@@ -522,3 +908,471 @@ fn extract_jwt(state: &AppState, headers: &HeaderMap) -> AppResult<(i64, String)
|
|||||||
|
|
||||||
Ok((uid, claims.role))
|
Ok((uid, claims.role))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
auth::{jwt::validate_jwt, password::hash_password},
|
||||||
|
config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig},
|
||||||
|
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
|
||||||
|
};
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
|
||||||
|
async fn test_state() -> AppState {
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.expect("in-memory database");
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("users table");
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE downstream_api_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
group_name TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
enabled INTEGER DEFAULT 1,
|
||||||
|
expires_at TEXT,
|
||||||
|
max_cost REAL,
|
||||||
|
used_cost REAL DEFAULT 0,
|
||||||
|
max_requests INTEGER,
|
||||||
|
used_requests INTEGER DEFAULT 0,
|
||||||
|
supported_models TEXT,
|
||||||
|
allowed_route_ids TEXT,
|
||||||
|
site_weight_multipliers TEXT,
|
||||||
|
excluded_site_ids TEXT,
|
||||||
|
excluded_credential_refs TEXT,
|
||||||
|
last_used_at TEXT,
|
||||||
|
user_id INTEGER,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("downstream keys table");
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
message TEXT,
|
||||||
|
level TEXT NOT NULL DEFAULT 'info',
|
||||||
|
read INTEGER DEFAULT 0,
|
||||||
|
related_id INTEGER,
|
||||||
|
related_type TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("events table");
|
||||||
|
|
||||||
|
AppState::new(
|
||||||
|
AppConfig {
|
||||||
|
server: ServerConfig {
|
||||||
|
host: "127.0.0.1".into(),
|
||||||
|
port: 0,
|
||||||
|
log_level: "info".into(),
|
||||||
|
cors_origins: vec![],
|
||||||
|
},
|
||||||
|
db: DbConfig {
|
||||||
|
url: "sqlite::memory:".into(),
|
||||||
|
max_connections: 1,
|
||||||
|
ssl: false,
|
||||||
|
runtime_db_url: None,
|
||||||
|
runtime_db_dialect: None,
|
||||||
|
runtime_db_ssl: None,
|
||||||
|
},
|
||||||
|
auth: AuthConfig {
|
||||||
|
jwt_secret: "test-secret".into(),
|
||||||
|
jwt_expiry_hours: 1,
|
||||||
|
auth_token: "auth".into(),
|
||||||
|
proxy_token: "proxy".into(),
|
||||||
|
},
|
||||||
|
proxy: ProxyConfig {
|
||||||
|
upstream_timeout_secs: 30,
|
||||||
|
first_byte_timeout_secs: 30,
|
||||||
|
max_retries: 3,
|
||||||
|
proxy_log_retention_days: 7,
|
||||||
|
template_proxy_url: None,
|
||||||
|
},
|
||||||
|
comfyui: Default::default(),
|
||||||
|
payment: Default::default(),
|
||||||
|
},
|
||||||
|
pool,
|
||||||
|
BuiltinKeys {
|
||||||
|
comfyui_agent: (1, "sk-comfyui-test".into()),
|
||||||
|
},
|
||||||
|
RuntimeAuthSettings::from_token_for_test("proxy"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_addr() -> ConnectInfo<SocketAddr> {
|
||||||
|
ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_headers() -> HeaderMap {
|
||||||
|
HeaderMap::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn headers_with(name: &'static str, value: &'static str) -> HeaderMap {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(name, value.parse().expect("valid test header"));
|
||||||
|
headers
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn latest_audit_event(state: &AppState) -> (String, String, String, i64, String) {
|
||||||
|
sqlx::query_as::<_, (String, String, String, i64, String)>(
|
||||||
|
"SELECT type, title, COALESCE(message, ''), COALESCE(related_id, 0), COALESCE(related_type, '')
|
||||||
|
FROM events
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.fetch_one(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("latest audit event")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn password_login_rejects_admin_users() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let password_hash = hash_password("admin-password").expect("password hash");
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (username, email, password_hash, role, status)
|
||||||
|
VALUES (?, ?, ?, 'admin', 'active')",
|
||||||
|
)
|
||||||
|
.bind("Admin")
|
||||||
|
.bind("admin@example.test")
|
||||||
|
.bind(password_hash)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert admin user");
|
||||||
|
|
||||||
|
let err = login(
|
||||||
|
State(state),
|
||||||
|
test_addr(),
|
||||||
|
empty_headers(),
|
||||||
|
Json(LoginRequest {
|
||||||
|
email: "admin@example.test".into(),
|
||||||
|
password: "admin-password".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("admin password login must be rejected");
|
||||||
|
|
||||||
|
assert!(matches!(err, AppError::Forbidden(message) if message.contains("admin API token")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn password_login_allows_regular_users() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let password_hash = hash_password("user-password").expect("password hash");
|
||||||
|
let user_id = sqlx::query(
|
||||||
|
"INSERT INTO users (username, email, password_hash, role, status)
|
||||||
|
VALUES (?, ?, ?, 'user', 'active')",
|
||||||
|
)
|
||||||
|
.bind("Regular User")
|
||||||
|
.bind("user@example.test")
|
||||||
|
.bind(password_hash)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert user")
|
||||||
|
.last_insert_rowid();
|
||||||
|
|
||||||
|
let Json(value) = login(
|
||||||
|
State(state.clone()),
|
||||||
|
test_addr(),
|
||||||
|
empty_headers(),
|
||||||
|
Json(LoginRequest {
|
||||||
|
email: "user@example.test".into(),
|
||||||
|
password: "user-password".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("regular user password login succeeds");
|
||||||
|
|
||||||
|
assert_eq!(value["user"]["role"], "user");
|
||||||
|
let token = value["token"].as_str().expect("token");
|
||||||
|
let claims = validate_jwt(&state.config.auth.jwt_secret, token).expect("valid jwt");
|
||||||
|
assert_eq!(claims.sub, user_id.to_string());
|
||||||
|
assert_eq!(claims.role, "user");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn password_login_audits_forwarded_client_ip() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let password_hash = hash_password("user-password").expect("password hash");
|
||||||
|
let user_id = sqlx::query(
|
||||||
|
"INSERT INTO users (username, email, password_hash, role, status)
|
||||||
|
VALUES (?, ?, ?, 'user', 'active')",
|
||||||
|
)
|
||||||
|
.bind("Regular User")
|
||||||
|
.bind("audit@example.test")
|
||||||
|
.bind(password_hash)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert user")
|
||||||
|
.last_insert_rowid();
|
||||||
|
|
||||||
|
let Json(_) = login(
|
||||||
|
State(state.clone()),
|
||||||
|
test_addr(),
|
||||||
|
headers_with("x-forwarded-for", "203.0.113.8, 10.0.0.1"),
|
||||||
|
Json(LoginRequest {
|
||||||
|
email: "audit@example.test".into(),
|
||||||
|
password: "user-password".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("regular user password login succeeds");
|
||||||
|
|
||||||
|
let (event_type, title, message, related_id, related_type) = latest_audit_event(&state).await;
|
||||||
|
assert_eq!(event_type, "user_login");
|
||||||
|
assert!(title.contains("用户登录成功"));
|
||||||
|
assert!(title.contains("Regular User <audit@example.test>"));
|
||||||
|
assert!(message.starts_with("账号=Regular User <audit@example.test>"));
|
||||||
|
assert!(message.contains("邮箱=audit@example.test"));
|
||||||
|
assert!(message.contains("方式=password"));
|
||||||
|
assert!(message.contains("IP=203.0.113.8"));
|
||||||
|
assert_eq!(related_id, user_id);
|
||||||
|
assert_eq!(related_type, "user");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn api_key_login_rejects_admin_role() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let password_hash = hash_password("unused-password").expect("password hash");
|
||||||
|
let user_id = sqlx::query(
|
||||||
|
"INSERT INTO users (username, email, password_hash, role, status)
|
||||||
|
VALUES (?, ?, ?, 'admin', 'active')",
|
||||||
|
)
|
||||||
|
.bind("Admin")
|
||||||
|
.bind("admin@example.test")
|
||||||
|
.bind(password_hash)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert admin user")
|
||||||
|
.last_insert_rowid();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO downstream_api_keys (name, key, user_id, enabled)
|
||||||
|
VALUES (?, ?, ?, 1)",
|
||||||
|
)
|
||||||
|
.bind("Admin default")
|
||||||
|
.bind("sk-admin-bound")
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert bound api key");
|
||||||
|
|
||||||
|
let err = login_api_key(
|
||||||
|
State(state),
|
||||||
|
test_addr(),
|
||||||
|
empty_headers(),
|
||||||
|
Json(LoginApiKeyRequest {
|
||||||
|
api_key: "sk-admin-bound".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("admin api key login must be rejected");
|
||||||
|
|
||||||
|
assert!(matches!(err, AppError::Forbidden(message) if message.contains("admin API token")));
|
||||||
|
assert!(user_id > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn api_key_login_rejects_latest_admin_role_after_promotion() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let password_hash = hash_password("unused-password").expect("password hash");
|
||||||
|
let user_id = sqlx::query(
|
||||||
|
"INSERT INTO users (username, email, password_hash, role, status)
|
||||||
|
VALUES (?, ?, ?, 'user', 'active')",
|
||||||
|
)
|
||||||
|
.bind("Promoted User")
|
||||||
|
.bind("promoted@example.test")
|
||||||
|
.bind(password_hash)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert regular user")
|
||||||
|
.last_insert_rowid();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO downstream_api_keys (name, key, user_id, enabled)
|
||||||
|
VALUES (?, ?, ?, 1)",
|
||||||
|
)
|
||||||
|
.bind("Existing user key")
|
||||||
|
.bind("sk-promoted-bound")
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert bound api key");
|
||||||
|
|
||||||
|
sqlx::query("UPDATE users SET role = 'admin' WHERE id = ?")
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("promote user to admin");
|
||||||
|
|
||||||
|
let err = login_api_key(
|
||||||
|
State(state),
|
||||||
|
test_addr(),
|
||||||
|
empty_headers(),
|
||||||
|
Json(LoginApiKeyRequest {
|
||||||
|
api_key: "sk-promoted-bound".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("api key login must use latest role and reject admin");
|
||||||
|
|
||||||
|
assert!(matches!(err, AppError::Forbidden(message) if message.contains("admin API token")));
|
||||||
|
assert!(user_id > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn api_key_login_allows_regular_user_role() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let password_hash = hash_password("unused-password").expect("password hash");
|
||||||
|
let user_id = sqlx::query(
|
||||||
|
"INSERT INTO users (username, email, password_hash, role, status)
|
||||||
|
VALUES (?, ?, ?, 'user', 'active')",
|
||||||
|
)
|
||||||
|
.bind("Regular User")
|
||||||
|
.bind("regular@example.test")
|
||||||
|
.bind(password_hash)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert regular user")
|
||||||
|
.last_insert_rowid();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO downstream_api_keys (name, key, user_id, enabled)
|
||||||
|
VALUES (?, ?, ?, 1)",
|
||||||
|
)
|
||||||
|
.bind("Regular default")
|
||||||
|
.bind("sk-user-bound")
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert bound api key");
|
||||||
|
|
||||||
|
let Json(value) = login_api_key(
|
||||||
|
State(state.clone()),
|
||||||
|
test_addr(),
|
||||||
|
empty_headers(),
|
||||||
|
Json(LoginApiKeyRequest {
|
||||||
|
api_key: "sk-user-bound".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("regular api key login succeeds");
|
||||||
|
|
||||||
|
assert_eq!(value["user"]["role"], "user");
|
||||||
|
assert_eq!(value["data"]["user"]["role"], "user");
|
||||||
|
let token = value["token"].as_str().expect("token");
|
||||||
|
let claims = validate_jwt(&state.config.auth.jwt_secret, token).expect("valid jwt");
|
||||||
|
assert_eq!(claims.sub, user_id.to_string());
|
||||||
|
assert_eq!(claims.role, "user");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn api_key_login_audits_forwarded_header_ip_and_key_name() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let password_hash = hash_password("unused-password").expect("password hash");
|
||||||
|
let user_id = sqlx::query(
|
||||||
|
"INSERT INTO users (username, email, password_hash, role, status)
|
||||||
|
VALUES (?, ?, ?, 'user', 'active')",
|
||||||
|
)
|
||||||
|
.bind("Regular User")
|
||||||
|
.bind("key-audit@example.test")
|
||||||
|
.bind(password_hash)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert regular user")
|
||||||
|
.last_insert_rowid();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO downstream_api_keys (name, key, user_id, enabled)
|
||||||
|
VALUES (?, ?, ?, 1)",
|
||||||
|
)
|
||||||
|
.bind("Mobile Login Key")
|
||||||
|
.bind("sk-user-audit-bound")
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert bound api key");
|
||||||
|
|
||||||
|
let Json(_) = login_api_key(
|
||||||
|
State(state.clone()),
|
||||||
|
test_addr(),
|
||||||
|
headers_with("forwarded", "for=\"198.51.100.24:443\";proto=https"),
|
||||||
|
Json(LoginApiKeyRequest {
|
||||||
|
api_key: "sk-user-audit-bound".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("regular api key login succeeds");
|
||||||
|
|
||||||
|
let (event_type, title, message, related_id, related_type) = latest_audit_event(&state).await;
|
||||||
|
assert_eq!(event_type, "user_login");
|
||||||
|
assert!(title.contains("用户登录成功"));
|
||||||
|
assert!(title.contains("Regular User <key-audit@example.test>"));
|
||||||
|
assert!(message.starts_with("账号=Regular User <key-audit@example.test>"));
|
||||||
|
assert!(message.contains("方式=api_key"));
|
||||||
|
assert!(message.contains("IP=198.51.100.24"));
|
||||||
|
assert!(message.contains("密钥=Mobile Login Key"));
|
||||||
|
assert_eq!(related_id, user_id);
|
||||||
|
assert_eq!(related_type, "user");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_audit_prefers_ipv4_from_proxy_headers() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let password_hash = hash_password("user-password").expect("password hash");
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (username, email, password_hash, role, status)
|
||||||
|
VALUES (?, ?, ?, 'user', 'active')",
|
||||||
|
)
|
||||||
|
.bind("IPv4 User")
|
||||||
|
.bind("ipv4@example.test")
|
||||||
|
.bind(password_hash)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert user");
|
||||||
|
|
||||||
|
let Json(_) = login(
|
||||||
|
State(state.clone()),
|
||||||
|
test_addr(),
|
||||||
|
headers_with("x-forwarded-for", "2a09:bac2:3918:16dc::247:4d, 203.0.113.9"),
|
||||||
|
Json(LoginRequest {
|
||||||
|
email: "ipv4@example.test".into(),
|
||||||
|
password: "user-password".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("regular user password login succeeds");
|
||||||
|
|
||||||
|
let (_, _, message, _, _) = latest_audit_event(&state).await;
|
||||||
|
assert!(message.contains("IP=203.0.113.9"), "{message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+114
-2
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::api_key::{authorize_downstream_token, DownstreamAuthResult},
|
auth::api_key::{authorize_downstream_token, DownstreamAuthResult},
|
||||||
|
auth::jwt::validate_jwt,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
@@ -47,10 +48,20 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Ok(claims) = validate_jwt(&state.config.auth.jwt_secret, auth) {
|
||||||
|
if claims.role == "admin" {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"Admin account is required for this route".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Downstream API key
|
// Downstream API key
|
||||||
if auth.starts_with("sk-") {
|
if auth.starts_with("sk-") {
|
||||||
|
let proxy_token = state.runtime_proxy_token();
|
||||||
let result =
|
let result =
|
||||||
authorize_downstream_token(auth, &state.db, Some(&state.config.auth.proxy_token))
|
authorize_downstream_token(auth, &state.db, Some(&proxy_token))
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Unauthorized)?;
|
.map_err(AppError::Unauthorized)?;
|
||||||
|
|
||||||
@@ -68,6 +79,105 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
auth::jwt::generate_jwt,
|
||||||
|
config::{AppConfig, AuthConfig, ComfyUIConfig, DbConfig, ProxyConfig, ServerConfig},
|
||||||
|
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
|
||||||
|
};
|
||||||
|
use axum::http::HeaderValue;
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
|
||||||
|
async fn test_state() -> AppState {
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.expect("sqlite pool");
|
||||||
|
|
||||||
|
AppState::new(
|
||||||
|
AppConfig {
|
||||||
|
server: ServerConfig {
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 4000,
|
||||||
|
log_level: "info".to_string(),
|
||||||
|
cors_origins: Vec::new(),
|
||||||
|
},
|
||||||
|
db: DbConfig {
|
||||||
|
url: "sqlite::memory:".to_string(),
|
||||||
|
max_connections: 1,
|
||||||
|
ssl: false,
|
||||||
|
runtime_db_url: None,
|
||||||
|
runtime_db_dialect: None,
|
||||||
|
runtime_db_ssl: None,
|
||||||
|
},
|
||||||
|
auth: AuthConfig {
|
||||||
|
jwt_secret: "test-jwt-secret".to_string(),
|
||||||
|
jwt_expiry_hours: 24,
|
||||||
|
auth_token: "test-admin-token".to_string(),
|
||||||
|
proxy_token: "sk-test-proxy".to_string(),
|
||||||
|
},
|
||||||
|
proxy: ProxyConfig {
|
||||||
|
upstream_timeout_secs: 30,
|
||||||
|
first_byte_timeout_secs: 30,
|
||||||
|
max_retries: 1,
|
||||||
|
proxy_log_retention_days: 7,
|
||||||
|
template_proxy_url: None,
|
||||||
|
},
|
||||||
|
comfyui: ComfyUIConfig::default(),
|
||||||
|
},
|
||||||
|
pool,
|
||||||
|
BuiltinKeys {
|
||||||
|
comfyui_agent: (0, "sk-comfyui-test".to_string()),
|
||||||
|
},
|
||||||
|
RuntimeAuthSettings::from_token_for_test("sk-test-proxy"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bearer_headers(token: &str) -> HeaderMap {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
"Authorization",
|
||||||
|
HeaderValue::from_str(&format!("Bearer {token}")).expect("valid bearer header"),
|
||||||
|
);
|
||||||
|
headers
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn comfyui_auth_accepts_admin_jwt() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let token = generate_jwt(
|
||||||
|
&state.config.auth.jwt_secret,
|
||||||
|
"42",
|
||||||
|
"admin",
|
||||||
|
state.config.auth.jwt_expiry_hours,
|
||||||
|
)
|
||||||
|
.expect("admin jwt");
|
||||||
|
|
||||||
|
check_auth(&state, &bearer_headers(&token))
|
||||||
|
.await
|
||||||
|
.expect("admin jwt can access comfyui proxy routes");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn comfyui_auth_rejects_user_jwt() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let token = generate_jwt(
|
||||||
|
&state.config.auth.jwt_secret,
|
||||||
|
"43",
|
||||||
|
"user",
|
||||||
|
state.config.auth.jwt_expiry_hours,
|
||||||
|
)
|
||||||
|
.expect("user jwt");
|
||||||
|
|
||||||
|
let result = check_auth(&state, &bearer_headers(&token)).await;
|
||||||
|
|
||||||
|
assert!(matches!(result, Err(AppError::Forbidden(_))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get ComfyUI base URL from embedded/external runtime configuration.
|
/// Get ComfyUI base URL from embedded/external runtime configuration.
|
||||||
fn comfyui_base(state: &AppState) -> String {
|
fn comfyui_base(state: &AppState) -> String {
|
||||||
state.comfyui.internal_base_url()
|
state.comfyui.internal_base_url()
|
||||||
@@ -200,7 +310,9 @@ async fn proxy_ws_bridge(mut client_socket: axum::extract::ws::WebSocket, target
|
|||||||
Ok(request) => request,
|
Ok(request) => request,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let _ = client_socket
|
let _ = client_socket
|
||||||
.send(Message::Text(format!("invalid ComfyUI websocket URL: {err}").into()))
|
.send(Message::Text(
|
||||||
|
format!("invalid ComfyUI websocket URL: {err}").into(),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+133
-20
@@ -243,11 +243,12 @@ async fn resolve_agent_auth(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
|
let proxy_token = state.runtime_proxy_token();
|
||||||
|
if token == state.config.auth.auth_token || token == proxy_token {
|
||||||
return Ok(AgentAuthContext {
|
return Ok(AgentAuthContext {
|
||||||
user_id: None,
|
user_id: None,
|
||||||
role: "admin".to_string(),
|
role: "admin".to_string(),
|
||||||
outbound_key: state.config.auth.proxy_token.clone(),
|
outbound_key: proxy_token,
|
||||||
key_name: "global-proxy".to_string(),
|
key_name: "global-proxy".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -282,7 +283,7 @@ async fn resolve_agent_auth(
|
|||||||
Err(_) if claims.role == "admin" => Ok(AgentAuthContext {
|
Err(_) if claims.role == "admin" => Ok(AgentAuthContext {
|
||||||
user_id: Some(user_id),
|
user_id: Some(user_id),
|
||||||
role: claims.role,
|
role: claims.role,
|
||||||
outbound_key: state.config.auth.proxy_token.clone(),
|
outbound_key: state.runtime_proxy_token(),
|
||||||
key_name: "global-proxy".to_string(),
|
key_name: "global-proxy".to_string(),
|
||||||
}),
|
}),
|
||||||
Err(message) => Err((
|
Err(message) => Err((
|
||||||
@@ -368,6 +369,8 @@ async fn status_handler(
|
|||||||
"keyName": key_name,
|
"keyName": key_name,
|
||||||
"userId": auth.user_id,
|
"userId": auth.user_id,
|
||||||
"role": auth.role,
|
"role": auth.role,
|
||||||
|
"apiBase": upstream_base,
|
||||||
|
"upstreamBase": upstream_base,
|
||||||
"upstreamLabel": upstream_label,
|
"upstreamLabel": upstream_label,
|
||||||
"comfyui": state.comfyui.status_response().await,
|
"comfyui": state.comfyui.status_response().await,
|
||||||
})))
|
})))
|
||||||
@@ -680,6 +683,77 @@ fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) ->
|
|||||||
.is_ok()
|
.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn string_field(value: &Value, keys: &[&str]) -> Option<String> {
|
||||||
|
keys.iter()
|
||||||
|
.find_map(|key| value.get(*key).and_then(Value::as_str))
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_reasoning_delta(parsed: &Value) -> Option<String> {
|
||||||
|
if let Some(delta) = parsed
|
||||||
|
.get("choices")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.and_then(|choices| choices.first())
|
||||||
|
.and_then(|choice| choice.get("delta"))
|
||||||
|
{
|
||||||
|
if let Some(text) = string_field(
|
||||||
|
delta,
|
||||||
|
&[
|
||||||
|
"reasoning_content",
|
||||||
|
"reasoning",
|
||||||
|
"thinking",
|
||||||
|
"thinking_delta",
|
||||||
|
"reasoning_delta",
|
||||||
|
],
|
||||||
|
) {
|
||||||
|
return Some(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(text) = delta
|
||||||
|
.get("reasoning")
|
||||||
|
.and_then(|value| value.get("content").or_else(|| value.get("text")))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
{
|
||||||
|
return Some(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let event_type = parsed
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default();
|
||||||
|
match event_type {
|
||||||
|
"response.reasoning.delta"
|
||||||
|
| "response.reasoning_text.delta"
|
||||||
|
| "response.reasoning_summary_text.delta" => {
|
||||||
|
string_field(parsed, &["delta", "text", "content"])
|
||||||
|
}
|
||||||
|
"content_block_delta" => {
|
||||||
|
let delta = parsed.get("delta")?;
|
||||||
|
match delta
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
{
|
||||||
|
"thinking_delta" => string_field(delta, &["thinking", "text", "delta"]),
|
||||||
|
"signature_delta" => None,
|
||||||
|
other if other.contains("thinking") || other.contains("reasoning") => {
|
||||||
|
string_field(delta, &["thinking", "text", "content", "delta"])
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other if other.contains("reasoning") || other.contains("thinking") => string_field(
|
||||||
|
parsed,
|
||||||
|
&["delta", "text", "content", "thinking", "reasoning"],
|
||||||
|
),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn attachment_summary(attachment: &AgentAttachment) -> String {
|
fn attachment_summary(attachment: &AgentAttachment) -> String {
|
||||||
let mime = attachment
|
let mime = attachment
|
||||||
.mime_type
|
.mime_type
|
||||||
@@ -701,7 +775,8 @@ fn attachment_summary(attachment: &AgentAttachment) -> String {
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.unwrap_or("file");
|
.unwrap_or("file");
|
||||||
let header = format!("\n\n[素材引用: {reference}, 名称: {name}, 类型: {mime}, 分类: {media_kind}]");
|
let header =
|
||||||
|
format!("\n\n[素材引用: {reference}, 名称: {name}, 类型: {mime}, 分类: {media_kind}]");
|
||||||
|
|
||||||
if let Some(text) = attachment
|
if let Some(text) = attachment
|
||||||
.text
|
.text
|
||||||
@@ -935,22 +1010,7 @@ async fn chat_handler(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let delta = parsed
|
if let Some(reasoning) = extract_reasoning_delta(&parsed) {
|
||||||
.get("choices")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.and_then(|choices| choices.first())
|
|
||||||
.and_then(|choice| choice.get("delta"))
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or(Value::Null);
|
|
||||||
|
|
||||||
let reasoning = delta
|
|
||||||
.get("reasoning_content")
|
|
||||||
.or_else(|| delta.get("reasoning"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
if !reasoning.is_empty() {
|
|
||||||
if !send_sse(
|
if !send_sse(
|
||||||
&tx,
|
&tx,
|
||||||
json!({
|
json!({
|
||||||
@@ -962,6 +1022,14 @@ async fn chat_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let delta = parsed
|
||||||
|
.get("choices")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.and_then(|choices| choices.first())
|
||||||
|
.and_then(|choice| choice.get("delta"))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
|
||||||
let content = delta
|
let content = delta
|
||||||
.get("content")
|
.get("content")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
@@ -1234,6 +1302,7 @@ mod tests {
|
|||||||
first_byte_timeout_secs: 30,
|
first_byte_timeout_secs: 30,
|
||||||
max_retries: 3,
|
max_retries: 3,
|
||||||
proxy_log_retention_days: 7,
|
proxy_log_retention_days: 7,
|
||||||
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
},
|
},
|
||||||
@@ -1241,6 +1310,7 @@ mod tests {
|
|||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
comfyui_agent: (1, "sk-comfyui-test".into()),
|
comfyui_agent: (1, "sk-comfyui-test".into()),
|
||||||
},
|
},
|
||||||
|
crate::services::runtime_auth::RuntimeAuthSettings::from_token_for_test("proxy"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1260,6 +1330,49 @@ mod tests {
|
|||||||
result.expect("status_handler returned error").0
|
result.expect("status_handler returned error").0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_chat_reasoning_delta() {
|
||||||
|
let parsed = json!({
|
||||||
|
"choices": [{
|
||||||
|
"delta": {
|
||||||
|
"reasoning_content": "正在拆解镜头..."
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
extract_reasoning_delta(&parsed).as_deref(),
|
||||||
|
Some("正在拆解镜头...")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_responses_reasoning_delta() {
|
||||||
|
let parsed = json!({
|
||||||
|
"type": "response.reasoning_summary_text.delta",
|
||||||
|
"delta": "先分析角色和场景。"
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
extract_reasoning_delta(&parsed).as_deref(),
|
||||||
|
Some("先分析角色和场景。")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_anthropic_thinking_delta() {
|
||||||
|
let parsed = json!({
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": 0,
|
||||||
|
"delta": {
|
||||||
|
"type": "thinking_delta",
|
||||||
|
"thinking": "用户需要一个视频分镜。"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
extract_reasoning_delta(&parsed).as_deref(),
|
||||||
|
Some("用户需要一个视频分镜。")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 3.5 — `/api/comfyui-agent/status` reflects the env state in one shot:
|
/// 3.5 — `/api/comfyui-agent/status` reflects the env state in one shot:
|
||||||
/// no env → local; whitespace/empty env → still local (treated as unset);
|
/// no env → local; whitespace/empty env → still local (treated as unset);
|
||||||
/// real env → fallback_remote with trimmed `upstreamBase`.
|
/// real env → fallback_remote with trimmed `upstreamBase`.
|
||||||
|
|||||||
+1711
-16
File diff suppressed because it is too large
Load Diff
+671
-13
@@ -1,14 +1,23 @@
|
|||||||
// Events routes — system event notifications
|
// Events routes — system event notifications
|
||||||
|
|
||||||
use crate::{db::models::event::Event, error::AppResult, AppState};
|
use crate::{
|
||||||
|
db::models::event::Event,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
services::runtime_logs,
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
middleware,
|
||||||
|
response::Response,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use serde::Serialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tracing::info;
|
use tracing::{debug, info};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ListEventsQuery {
|
pub struct ListEventsQuery {
|
||||||
@@ -19,12 +28,35 @@ pub struct ListEventsQuery {
|
|||||||
read: Option<String>, // "true" | "false"
|
read: Option<String>, // "true" | "false"
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes(state: AppState) -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/events", get(list_events).delete(clear_events))
|
.route("/api/events", get(list_events).delete(clear_events))
|
||||||
.route("/api/events/count", get(unread_count))
|
.route("/api/events/count", get(unread_count))
|
||||||
.route("/api/events/{id}/read", post(mark_read))
|
.route("/api/events/{id}/read", post(mark_read))
|
||||||
.route("/api/events/read-all", post(mark_all_read))
|
.route("/api/events/read-all", post(mark_all_read))
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
state,
|
||||||
|
require_events_admin_auth,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn require_events_admin_auth(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
request: axum::http::Request<axum::body::Body>,
|
||||||
|
next: axum::middleware::Next,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let auth_header = headers
|
||||||
|
.get("Authorization")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
|
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
|
||||||
|
|
||||||
|
if auth_header != state.config.auth.auth_token {
|
||||||
|
return Err(AppError::Unauthorized("Invalid admin token".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(next.run(request).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/events
|
/// GET /api/events
|
||||||
@@ -32,20 +64,29 @@ async fn list_events(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(q): Query<ListEventsQuery>,
|
Query(q): Query<ListEventsQuery>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
info!("Listing events");
|
debug!("Listing events");
|
||||||
let limit = q.limit.unwrap_or(30).clamp(1, 500);
|
let limit = q.limit.unwrap_or(30).clamp(1, 500);
|
||||||
let offset = q.offset.unwrap_or(0).max(0);
|
let offset = q.offset.unwrap_or(0).max(0);
|
||||||
|
let filter_type = q
|
||||||
|
.filter_type
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string);
|
||||||
|
let read_filter = q.read.as_deref();
|
||||||
|
let source_limit = offset
|
||||||
|
.saturating_add(limit)
|
||||||
|
.saturating_add(200)
|
||||||
|
.clamp(50, 1000);
|
||||||
|
|
||||||
let mut sql = "SELECT * FROM events WHERE 1=1".to_string();
|
let mut sql = "SELECT id, type, title, message, level, read, related_id, related_type, created_at FROM events WHERE 1=1".to_string();
|
||||||
let mut bind_idx = 0;
|
|
||||||
let mut params: Vec<String> = vec![];
|
let mut params: Vec<String> = vec![];
|
||||||
|
|
||||||
if let Some(ref t) = q.filter_type {
|
if let Some(ref t) = filter_type {
|
||||||
bind_idx += 1;
|
sql.push_str(" AND type = ?");
|
||||||
sql.push_str(&format!(" AND type = ?{}", bind_idx));
|
|
||||||
params.push(t.clone());
|
params.push(t.clone());
|
||||||
}
|
}
|
||||||
if let Some(ref r) = q.read {
|
if let Some(r) = read_filter {
|
||||||
if r == "true" {
|
if r == "true" {
|
||||||
sql.push_str(" AND read = 1");
|
sql.push_str(" AND read = 1");
|
||||||
} else if r == "false" {
|
} else if r == "false" {
|
||||||
@@ -53,16 +94,304 @@ async fn list_events(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sql.push_str(" ORDER BY created_at DESC");
|
sql.push_str(" ORDER BY created_at DESC LIMIT 1000");
|
||||||
sql.push_str(&format!(" LIMIT {} OFFSET {}", limit, offset));
|
|
||||||
|
|
||||||
let mut query = sqlx::query_as::<_, Event>(&sql);
|
let mut query = sqlx::query_as::<_, Event>(&sql);
|
||||||
for p in ¶ms {
|
for p in ¶ms {
|
||||||
query = query.bind(p);
|
query = query.bind(p);
|
||||||
}
|
}
|
||||||
let events: Vec<Event> = query.fetch_all(&*state.db).await?;
|
let events: Vec<Event> = query.fetch_all(&*state.db).await?;
|
||||||
|
let mut rows: Vec<EventListItem> = events.into_iter().map(EventListItem::from).collect();
|
||||||
|
|
||||||
Ok(Json(json!({ "data": events })))
|
if should_include_source(filter_type.as_deref(), "runtime") {
|
||||||
|
rows.extend(
|
||||||
|
runtime_logs::list()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|entry| read_filter != Some("false") || !entry.is_read)
|
||||||
|
.map(EventListItem::from),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if should_include_read_only_source(filter_type.as_deref(), read_filter, "checkin") {
|
||||||
|
rows.extend(load_checkin_log_events(&state, source_limit).await?);
|
||||||
|
}
|
||||||
|
if should_include_read_only_source(filter_type.as_deref(), read_filter, "proxy") {
|
||||||
|
rows.extend(load_proxy_log_events(&state, source_limit).await?);
|
||||||
|
}
|
||||||
|
if should_include_read_only_source(filter_type.as_deref(), read_filter, "balance") {
|
||||||
|
rows.extend(load_balance_log_events(&state, source_limit).await?);
|
||||||
|
}
|
||||||
|
if should_include_read_only_source(filter_type.as_deref(), read_filter, "token") {
|
||||||
|
rows.extend(load_token_log_events(&state, source_limit).await?);
|
||||||
|
}
|
||||||
|
if should_include_read_only_source(filter_type.as_deref(), read_filter, "site_notice") {
|
||||||
|
rows.extend(load_site_notice_events(&state, source_limit).await?);
|
||||||
|
}
|
||||||
|
if should_include_read_only_source(filter_type.as_deref(), read_filter, "status") {
|
||||||
|
rows.extend(load_status_log_events(&state, source_limit).await?);
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.sort_by(|a, b| {
|
||||||
|
b.created_at
|
||||||
|
.cmp(&a.created_at)
|
||||||
|
.then_with(|| a.id.cmp(&b.id))
|
||||||
|
});
|
||||||
|
let start = offset as usize;
|
||||||
|
let end = start.saturating_add(limit as usize).min(rows.len());
|
||||||
|
let page = if start < rows.len() {
|
||||||
|
rows[start..end].to_vec()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(json!({ "data": page })))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_include_source(filter_type: Option<&str>, source: &str) -> bool {
|
||||||
|
filter_type.is_none_or(|value| value == source)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_include_read_only_source(
|
||||||
|
filter_type: Option<&str>,
|
||||||
|
read_filter: Option<&str>,
|
||||||
|
source: &str,
|
||||||
|
) -> bool {
|
||||||
|
should_include_source(filter_type, source) && read_filter != Some("false")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_checkin_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
|
||||||
|
let rows = sqlx::query_as::<_, EventListItem>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
(-10000000000 - c.id) AS id,
|
||||||
|
'checkin' AS event_type,
|
||||||
|
CASE
|
||||||
|
WHEN lower(COALESCE(c.status, '')) IN ('success', 'ok', 'completed') THEN '签到成功'
|
||||||
|
WHEN lower(COALESCE(c.status, '')) IN ('skipped', 'skip') THEN '签到跳过'
|
||||||
|
ELSE '签到失败'
|
||||||
|
END || ':' || COALESCE(NULLIF(s.name, ''), '账号 #' || c.account_id) AS title,
|
||||||
|
COALESCE(NULLIF(a.username, ''), '账号 #' || c.account_id)
|
||||||
|
|| ' · 状态=' || COALESCE(NULLIF(c.status, ''), '-')
|
||||||
|
|| CASE WHEN c.reward IS NOT NULL AND trim(c.reward) != '' THEN ' · 奖励=' || c.reward ELSE '' END
|
||||||
|
|| CASE WHEN c.message IS NOT NULL AND trim(c.message) != '' THEN ' · ' || c.message ELSE '' END AS message,
|
||||||
|
CASE
|
||||||
|
WHEN lower(COALESCE(c.status, '')) IN ('success', 'ok', 'completed') THEN 'info'
|
||||||
|
WHEN lower(COALESCE(c.status, '')) IN ('skipped', 'skip') THEN 'warning'
|
||||||
|
ELSE 'error'
|
||||||
|
END AS level,
|
||||||
|
1 AS is_read,
|
||||||
|
c.id AS related_id,
|
||||||
|
'checkin_log' AS related_type,
|
||||||
|
c.created_at AS created_at
|
||||||
|
FROM checkin_logs c
|
||||||
|
LEFT JOIN accounts a ON a.id = c.account_id
|
||||||
|
LEFT JOIN sites s ON s.id = a.site_id
|
||||||
|
ORDER BY c.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_proxy_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
|
||||||
|
let rows = sqlx::query_as::<_, EventListItem>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
(-20000000000 - l.id) AS id,
|
||||||
|
'proxy' AS event_type,
|
||||||
|
CASE
|
||||||
|
WHEN lower(COALESCE(l.status, '')) = 'success' THEN '代理请求成功'
|
||||||
|
ELSE '代理请求失败'
|
||||||
|
END || ':' || COALESCE(NULLIF(l.model_requested, ''), NULLIF(l.model_actual, ''), '未知模型') AS title,
|
||||||
|
'站点=' || COALESCE(NULLIF(s.name, ''), '-')
|
||||||
|
|| ' · 账号=' || COALESCE(NULLIF(a.username, ''), CASE WHEN l.account_id IS NOT NULL THEN '账号 #' || l.account_id ELSE '-' END)
|
||||||
|
|| ' · 状态=' || COALESCE(NULLIF(l.status, ''), '-')
|
||||||
|
|| CASE WHEN l.http_status IS NOT NULL THEN ' · HTTP ' || l.http_status ELSE '' END
|
||||||
|
|| CASE WHEN l.latency_ms IS NOT NULL THEN ' · ' || l.latency_ms || 'ms' ELSE '' END
|
||||||
|
|| CASE WHEN l.total_tokens IS NOT NULL THEN ' · Tokens=' || l.total_tokens ELSE '' END
|
||||||
|
|| CASE WHEN l.estimated_cost IS NOT NULL THEN ' · 成本=' || printf('$%.6f', l.estimated_cost) ELSE '' END
|
||||||
|
|| CASE WHEN l.error_message IS NOT NULL AND trim(l.error_message) != '' THEN ' · ' || l.error_message ELSE '' END AS message,
|
||||||
|
CASE WHEN lower(COALESCE(l.status, '')) = 'success' THEN 'info' ELSE 'error' END AS level,
|
||||||
|
1 AS is_read,
|
||||||
|
l.id AS related_id,
|
||||||
|
'proxy_log' AS related_type,
|
||||||
|
l.created_at AS created_at
|
||||||
|
FROM proxy_logs l
|
||||||
|
LEFT JOIN accounts a ON a.id = l.account_id
|
||||||
|
LEFT JOIN sites s ON s.id = a.site_id
|
||||||
|
ORDER BY l.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_balance_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
|
||||||
|
let rows = sqlx::query_as::<_, EventListItem>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
(-30000000000 - a.id) AS id,
|
||||||
|
'balance' AS event_type,
|
||||||
|
'余额已刷新:' || COALESCE(NULLIF(s.name, ''), '账号 #' || a.id) AS title,
|
||||||
|
COALESCE(NULLIF(a.username, ''), '账号 #' || a.id)
|
||||||
|
|| ' · 当前余额=' || printf('%.6f', COALESCE(a.balance, 0))
|
||||||
|
|| ' · 已用=' || printf('%.6f', COALESCE(a.balance_used, 0))
|
||||||
|
|| ' · 额度=' || printf('%.6f', COALESCE(a.quota, 0)) AS message,
|
||||||
|
'info' AS level,
|
||||||
|
1 AS is_read,
|
||||||
|
a.id AS related_id,
|
||||||
|
'account' AS related_type,
|
||||||
|
a.last_balance_refresh AS created_at
|
||||||
|
FROM accounts a
|
||||||
|
LEFT JOIN sites s ON s.id = a.site_id
|
||||||
|
WHERE a.last_balance_refresh IS NOT NULL
|
||||||
|
AND trim(a.last_balance_refresh) != ''
|
||||||
|
ORDER BY a.last_balance_refresh DESC
|
||||||
|
LIMIT ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_token_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
|
||||||
|
let rows = sqlx::query_as::<_, EventListItem>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
(-40000000000 - t.id) AS id,
|
||||||
|
'token' AS event_type,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(t.updated_at, t.created_at, '') != COALESCE(t.created_at, '') THEN '令牌已更新'
|
||||||
|
ELSE '令牌已创建'
|
||||||
|
END || ':' || COALESCE(NULLIF(t.name, ''), 'Token #' || t.id) AS title,
|
||||||
|
COALESCE(NULLIF(s.name, ''), '-')
|
||||||
|
|| ' · ' || COALESCE(NULLIF(a.username, ''), '账号 #' || t.account_id)
|
||||||
|
|| ' · 状态=' || COALESCE(NULLIF(t.value_status, ''), '-')
|
||||||
|
|| CASE WHEN COALESCE(t.enabled, 1) = 1 THEN ' · 已启用' ELSE ' · 已禁用' END
|
||||||
|
|| CASE WHEN t.token_group IS NOT NULL AND trim(t.token_group) != '' THEN ' · 分组=' || t.token_group ELSE '' END
|
||||||
|
|| CASE WHEN t.source IS NOT NULL AND trim(t.source) != '' THEN ' · 来源=' || t.source ELSE '' END AS message,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(t.enabled, 1) = 1
|
||||||
|
AND lower(COALESCE(t.value_status, 'ready')) IN ('ready', 'valid') THEN 'info'
|
||||||
|
ELSE 'warning'
|
||||||
|
END AS level,
|
||||||
|
1 AS is_read,
|
||||||
|
t.id AS related_id,
|
||||||
|
'account_token' AS related_type,
|
||||||
|
COALESCE(t.updated_at, t.created_at) AS created_at
|
||||||
|
FROM account_tokens t
|
||||||
|
LEFT JOIN accounts a ON a.id = t.account_id
|
||||||
|
LEFT JOIN sites s ON s.id = a.site_id
|
||||||
|
WHERE COALESCE(t.updated_at, t.created_at) IS NOT NULL
|
||||||
|
AND trim(COALESCE(t.updated_at, t.created_at)) != ''
|
||||||
|
ORDER BY COALESCE(t.updated_at, t.created_at) DESC
|
||||||
|
LIMIT ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_site_notice_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
|
||||||
|
let rows = sqlx::query_as::<_, EventListItem>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
(-50000000000 - n.id) AS id,
|
||||||
|
'site_notice' AS event_type,
|
||||||
|
'站点公告:' || COALESCE(NULLIF(n.title, ''), '未命名公告') AS title,
|
||||||
|
COALESCE(NULLIF(s.name, ''), '站点 #' || n.site_id)
|
||||||
|
|| CASE WHEN n.content IS NOT NULL AND trim(n.content) != '' THEN ' · ' || substr(n.content, 1, 420) ELSE '' END AS message,
|
||||||
|
COALESCE(NULLIF(n.level, ''), 'info') AS level,
|
||||||
|
1 AS is_read,
|
||||||
|
n.id AS related_id,
|
||||||
|
'site_announcement' AS related_type,
|
||||||
|
COALESCE(n.upstream_created_at, n.first_seen_at) AS created_at
|
||||||
|
FROM site_announcements n
|
||||||
|
LEFT JOIN sites s ON s.id = n.site_id
|
||||||
|
ORDER BY COALESCE(n.upstream_created_at, n.first_seen_at) DESC
|
||||||
|
LIMIT ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_status_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
|
||||||
|
let per_source_limit = limit.clamp(50, 1000);
|
||||||
|
let mut rows = sqlx::query_as::<_, EventListItem>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
(-60000000000 - s.id) AS id,
|
||||||
|
'status' AS event_type,
|
||||||
|
'站点状态:' || COALESCE(NULLIF(s.name, ''), '站点 #' || s.id) AS title,
|
||||||
|
'平台=' || COALESCE(NULLIF(s.platform, ''), '-')
|
||||||
|
|| ' · 状态=' || COALESCE(NULLIF(s.status, ''), '-')
|
||||||
|
|| CASE WHEN s.url IS NOT NULL AND trim(s.url) != '' THEN ' · ' || s.url ELSE '' END AS message,
|
||||||
|
CASE
|
||||||
|
WHEN lower(COALESCE(s.status, 'active')) IN ('active', 'enabled', 'healthy') THEN 'info'
|
||||||
|
WHEN lower(COALESCE(s.status, '')) IN ('disabled', 'inactive') THEN 'warning'
|
||||||
|
ELSE 'error'
|
||||||
|
END AS level,
|
||||||
|
1 AS is_read,
|
||||||
|
s.id AS related_id,
|
||||||
|
'site' AS related_type,
|
||||||
|
COALESCE(s.updated_at, s.created_at) AS created_at
|
||||||
|
FROM sites s
|
||||||
|
WHERE COALESCE(s.updated_at, s.created_at) IS NOT NULL
|
||||||
|
AND trim(COALESCE(s.updated_at, s.created_at)) != ''
|
||||||
|
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
|
||||||
|
LIMIT ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(per_source_limit)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
rows.extend(
|
||||||
|
sqlx::query_as::<_, EventListItem>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
(-70000000000 - a.id) AS id,
|
||||||
|
'status' AS event_type,
|
||||||
|
'账号状态:' || COALESCE(NULLIF(s.name, ''), '站点 #' || a.site_id) AS title,
|
||||||
|
COALESCE(NULLIF(a.username, ''), '账号 #' || a.id)
|
||||||
|
|| ' · 状态=' || COALESCE(NULLIF(a.status, ''), '-')
|
||||||
|
|| CASE WHEN a.oauth_provider IS NOT NULL AND trim(a.oauth_provider) != '' THEN ' · OAuth=' || a.oauth_provider ELSE '' END AS message,
|
||||||
|
CASE
|
||||||
|
WHEN lower(COALESCE(a.status, 'active')) IN ('active', 'enabled', 'healthy') THEN 'info'
|
||||||
|
WHEN lower(COALESCE(a.status, '')) IN ('disabled', 'inactive') THEN 'warning'
|
||||||
|
ELSE 'error'
|
||||||
|
END AS level,
|
||||||
|
1 AS is_read,
|
||||||
|
a.id AS related_id,
|
||||||
|
'account' AS related_type,
|
||||||
|
COALESCE(a.updated_at, a.created_at) AS created_at
|
||||||
|
FROM accounts a
|
||||||
|
LEFT JOIN sites s ON s.id = a.site_id
|
||||||
|
WHERE COALESCE(a.updated_at, a.created_at) IS NOT NULL
|
||||||
|
AND trim(COALESCE(a.updated_at, a.created_at)) != ''
|
||||||
|
ORDER BY COALESCE(a.updated_at, a.created_at) DESC
|
||||||
|
LIMIT ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(per_source_limit)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/events/count — unread count
|
/// GET /api/events/count — unread count
|
||||||
@@ -98,5 +427,334 @@ async fn clear_events(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
|||||||
sqlx::query("DELETE FROM events")
|
sqlx::query("DELETE FROM events")
|
||||||
.execute(&*state.db)
|
.execute(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
runtime_logs::clear();
|
||||||
Ok(Json(json!({ "success": true })))
|
Ok(Json(json!({ "success": true })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct EventListItem {
|
||||||
|
id: i64,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
event_type: String,
|
||||||
|
title: String,
|
||||||
|
message: Option<String>,
|
||||||
|
level: String,
|
||||||
|
#[serde(rename = "read")]
|
||||||
|
is_read: bool,
|
||||||
|
related_id: Option<i64>,
|
||||||
|
related_type: Option<String>,
|
||||||
|
created_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Event> for EventListItem {
|
||||||
|
fn from(event: Event) -> Self {
|
||||||
|
Self {
|
||||||
|
id: event.id,
|
||||||
|
event_type: event.event_type,
|
||||||
|
title: event.title,
|
||||||
|
message: event.message,
|
||||||
|
level: event.level.unwrap_or_else(|| "info".to_string()),
|
||||||
|
is_read: event.is_read.unwrap_or(false),
|
||||||
|
related_id: event.related_id,
|
||||||
|
related_type: event.related_type,
|
||||||
|
created_at: event.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<runtime_logs::RuntimeLogEntry> for EventListItem {
|
||||||
|
fn from(entry: runtime_logs::RuntimeLogEntry) -> Self {
|
||||||
|
Self {
|
||||||
|
id: entry.id,
|
||||||
|
event_type: entry.event_type,
|
||||||
|
title: entry.title,
|
||||||
|
message: entry.message,
|
||||||
|
level: entry.level,
|
||||||
|
is_read: entry.is_read,
|
||||||
|
related_id: entry.related_id,
|
||||||
|
related_type: entry.related_type,
|
||||||
|
created_at: Some(entry.created_at),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
auth::jwt::generate_jwt,
|
||||||
|
config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig},
|
||||||
|
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http::{Request, StatusCode},
|
||||||
|
};
|
||||||
|
use axum::extract::{Query, State};
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
use tower::ServiceExt as _;
|
||||||
|
|
||||||
|
async fn test_state() -> AppState {
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.expect("memory sqlite");
|
||||||
|
sqlx::migrate!("./migrations")
|
||||||
|
.run(&pool)
|
||||||
|
.await
|
||||||
|
.expect("run migrations");
|
||||||
|
|
||||||
|
AppState::new(
|
||||||
|
AppConfig {
|
||||||
|
server: ServerConfig {
|
||||||
|
host: "127.0.0.1".into(),
|
||||||
|
port: 0,
|
||||||
|
log_level: "info".into(),
|
||||||
|
cors_origins: vec![],
|
||||||
|
},
|
||||||
|
db: DbConfig {
|
||||||
|
url: "sqlite::memory:".into(),
|
||||||
|
max_connections: 1,
|
||||||
|
ssl: false,
|
||||||
|
runtime_db_url: None,
|
||||||
|
runtime_db_dialect: None,
|
||||||
|
runtime_db_ssl: None,
|
||||||
|
},
|
||||||
|
auth: AuthConfig {
|
||||||
|
jwt_secret: "secret".into(),
|
||||||
|
jwt_expiry_hours: 1,
|
||||||
|
auth_token: "auth".into(),
|
||||||
|
proxy_token: "proxy".into(),
|
||||||
|
},
|
||||||
|
proxy: ProxyConfig {
|
||||||
|
upstream_timeout_secs: 30,
|
||||||
|
first_byte_timeout_secs: 30,
|
||||||
|
max_retries: 3,
|
||||||
|
proxy_log_retention_days: 7,
|
||||||
|
template_proxy_url: None,
|
||||||
|
},
|
||||||
|
comfyui: Default::default(),
|
||||||
|
},
|
||||||
|
pool,
|
||||||
|
BuiltinKeys {
|
||||||
|
comfyui_agent: (1, "sk-comfyui-test".into()),
|
||||||
|
},
|
||||||
|
RuntimeAuthSettings::from_token_for_test("proxy"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_event_sources(state: &AppState) {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO sites (id, name, url, platform, status)
|
||||||
|
VALUES (1, '主站', 'https://example.test', 'new-api', 'active')",
|
||||||
|
)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO accounts
|
||||||
|
(id, site_id, username, access_token, balance, balance_used, quota, last_balance_refresh, status)
|
||||||
|
VALUES
|
||||||
|
(10, 1, 'alice', 'access-token', 12.5, 1.25, 20.0, '2026-06-08T09:30:00Z', 'active')",
|
||||||
|
)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO account_tokens
|
||||||
|
(id, account_id, name, token, source, token_group, value_status, enabled, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(20, 10, '默认 Key', 'sk-account-token', 'manual', 'prod', 'ready', 1, '2026-06-08T09:00:00Z', '2026-06-08T09:40:00Z')",
|
||||||
|
)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO checkin_logs (id, account_id, status, message, reward, created_at)
|
||||||
|
VALUES (30, 10, 'success', 'Daily checkin completed', '+$0.0100', '2026-06-08T09:50:00Z')",
|
||||||
|
)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO proxy_logs
|
||||||
|
(id, account_id, model_requested, model_actual, status, http_status, latency_ms, total_tokens, estimated_cost, error_message, created_at)
|
||||||
|
VALUES
|
||||||
|
(40, 10, 'gpt-test', 'gpt-test', 'failed', 404, 812, 33, 0.0012, 'model not found', '2026-06-08T10:00:00Z')",
|
||||||
|
)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO site_announcements
|
||||||
|
(id, site_id, platform, source_key, title, content, level, first_seen_at, read_at)
|
||||||
|
VALUES
|
||||||
|
(50, 1, 'new-api', 'notice-1', '维护通知', '上游将维护 30 分钟', 'warning', '2026-06-08T10:10:00Z', NULL)",
|
||||||
|
)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_type(state: AppState, filter_type: Option<&str>) -> Value {
|
||||||
|
let Json(body) = list_events(
|
||||||
|
State(state),
|
||||||
|
Query(ListEventsQuery {
|
||||||
|
limit: Some(50),
|
||||||
|
offset: Some(0),
|
||||||
|
filter_type: filter_type.map(str::to_string),
|
||||||
|
read: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn events_router_requires_admin_api_token() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let app = routes(state.clone()).with_state(state.clone());
|
||||||
|
|
||||||
|
let ok_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/events")
|
||||||
|
.header("Authorization", "Bearer auth")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("admin token request");
|
||||||
|
assert_eq!(ok_response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let user_jwt = generate_jwt(&state.config.auth.jwt_secret, "1", "user", 1)
|
||||||
|
.expect("user jwt");
|
||||||
|
let user_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/events")
|
||||||
|
.header("Authorization", format!("Bearer {user_jwt}"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("user jwt request");
|
||||||
|
assert_eq!(user_response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
let missing_response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/events")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("missing auth request");
|
||||||
|
assert_eq!(missing_response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data_types(body: &Value) -> Vec<String> {
|
||||||
|
body["data"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|row| row.get("type").and_then(Value::as_str).map(str::to_string))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_events_aggregates_business_log_sources() {
|
||||||
|
let state = test_state().await;
|
||||||
|
seed_event_sources(&state).await;
|
||||||
|
|
||||||
|
let all = list_for_type(state.clone(), None).await;
|
||||||
|
let all_types = data_types(&all);
|
||||||
|
assert!(all_types.contains(&"checkin".to_string()));
|
||||||
|
assert!(all_types.contains(&"proxy".to_string()));
|
||||||
|
assert!(all_types.contains(&"balance".to_string()));
|
||||||
|
assert!(all_types.contains(&"token".to_string()));
|
||||||
|
assert!(all_types.contains(&"site_notice".to_string()));
|
||||||
|
assert!(all_types.contains(&"status".to_string()));
|
||||||
|
|
||||||
|
for (filter_type, expected_title) in [
|
||||||
|
("checkin", "签到成功"),
|
||||||
|
("proxy", "代理请求失败"),
|
||||||
|
("balance", "余额已刷新"),
|
||||||
|
("token", "令牌已更新"),
|
||||||
|
("site_notice", "站点公告"),
|
||||||
|
("status", "站点状态"),
|
||||||
|
] {
|
||||||
|
let body = list_for_type(state.clone(), Some(filter_type)).await;
|
||||||
|
let rows = body["data"].as_array().unwrap();
|
||||||
|
assert!(!rows.is_empty(), "{filter_type} should return rows");
|
||||||
|
assert!(rows
|
||||||
|
.iter()
|
||||||
|
.all(|row| row.get("type").and_then(Value::as_str) == Some(filter_type)));
|
||||||
|
assert!(rows.iter().any(|row| row
|
||||||
|
.get("title")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains(expected_title)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unread_filter_excludes_read_only_virtual_logs() {
|
||||||
|
let state = test_state().await;
|
||||||
|
seed_event_sources(&state).await;
|
||||||
|
|
||||||
|
let Json(checkin_body) = list_events(
|
||||||
|
State(state.clone()),
|
||||||
|
Query(ListEventsQuery {
|
||||||
|
limit: Some(50),
|
||||||
|
offset: Some(0),
|
||||||
|
filter_type: Some("checkin".into()),
|
||||||
|
read: Some("false".into()),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(checkin_body["data"].as_array().unwrap().is_empty());
|
||||||
|
|
||||||
|
let Json(notice_body) = list_events(
|
||||||
|
State(state),
|
||||||
|
Query(ListEventsQuery {
|
||||||
|
limit: Some(50),
|
||||||
|
offset: Some(0),
|
||||||
|
filter_type: Some("site_notice".into()),
|
||||||
|
read: Some("false".into()),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(notice_body["data"].as_array().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn status_snapshots_are_available_in_all_logs_and_status_filter() {
|
||||||
|
let state = test_state().await;
|
||||||
|
seed_event_sources(&state).await;
|
||||||
|
|
||||||
|
let all = list_for_type(state.clone(), None).await;
|
||||||
|
assert!(data_types(&all).contains(&"status".to_string()));
|
||||||
|
|
||||||
|
let status = list_for_type(state, Some("status")).await;
|
||||||
|
let rows = status["data"].as_array().unwrap();
|
||||||
|
assert!(rows.iter().any(|row| row["title"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("站点状态")));
|
||||||
|
assert!(rows.iter().any(|row| row["title"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("账号状态")));
|
||||||
|
assert!(rows
|
||||||
|
.iter()
|
||||||
|
.all(|row| row.get("type").and_then(Value::as_str) == Some("status")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+132
-31
@@ -10,9 +10,14 @@ use include_dir::{include_dir, Dir};
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use tower::service_fn;
|
use tower::service_fn;
|
||||||
|
use tower_http::compression::CompressionLayer;
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
static EMBEDDED_WEB: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/web");
|
static EMBEDDED_WEB: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/web");
|
||||||
|
static EMBEDDED_WEBSITE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/website");
|
||||||
|
const _: &[u8] = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/dist/web/index.html"));
|
||||||
|
const _: &[u8] =
|
||||||
|
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/dist/website/index.html"));
|
||||||
|
|
||||||
mod admin;
|
mod admin;
|
||||||
mod auth;
|
mod auth;
|
||||||
@@ -22,7 +27,7 @@ mod comfyui_agent;
|
|||||||
mod digital_human;
|
mod digital_human;
|
||||||
mod events;
|
mod events;
|
||||||
mod health;
|
mod health;
|
||||||
mod monitor;
|
mod money_printer;
|
||||||
mod oauth;
|
mod oauth;
|
||||||
mod organizations;
|
mod organizations;
|
||||||
mod picture_agent;
|
mod picture_agent;
|
||||||
@@ -31,7 +36,6 @@ mod search;
|
|||||||
mod site_announcements;
|
mod site_announcements;
|
||||||
pub(crate) mod stats;
|
pub(crate) mod stats;
|
||||||
mod tasks;
|
mod tasks;
|
||||||
mod update_center;
|
|
||||||
mod user;
|
mod user;
|
||||||
mod user_api_keys;
|
mod user_api_keys;
|
||||||
#[path = "video_agent_legacy.rs"]
|
#[path = "video_agent_legacy.rs"]
|
||||||
@@ -46,6 +50,7 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.merge(admin::routes(state.clone()))
|
.merge(admin::routes(state.clone()))
|
||||||
.merge(proxy::routes())
|
.merge(proxy::routes())
|
||||||
.merge(picture_agent::routes())
|
.merge(picture_agent::routes())
|
||||||
|
.merge(money_printer::routes())
|
||||||
.merge(comfyui_agent::routes())
|
.merge(comfyui_agent::routes())
|
||||||
.merge(digital_human::routes())
|
.merge(digital_human::routes())
|
||||||
.merge(comfyui::routes())
|
.merge(comfyui::routes())
|
||||||
@@ -56,9 +61,8 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
|
|
||||||
let api_routes = api_routes
|
let api_routes = api_routes
|
||||||
.merge(auth::routes())
|
.merge(auth::routes())
|
||||||
.merge(events::routes())
|
.merge(events::routes(state.clone()))
|
||||||
.merge(checkin::routes())
|
.merge(checkin::routes())
|
||||||
.merge(monitor::routes())
|
|
||||||
.merge(oauth::routes())
|
.merge(oauth::routes())
|
||||||
.merge(organizations::routes())
|
.merge(organizations::routes())
|
||||||
.merge(search::routes())
|
.merge(search::routes())
|
||||||
@@ -66,7 +70,6 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.merge(workflow_ui::routes())
|
.merge(workflow_ui::routes())
|
||||||
.merge(stats::routes())
|
.merge(stats::routes())
|
||||||
.merge(tasks::routes())
|
.merge(tasks::routes())
|
||||||
.merge(update_center::routes())
|
|
||||||
.merge(user::routes())
|
.merge(user::routes())
|
||||||
.merge(user_api_keys::routes())
|
.merge(user_api_keys::routes())
|
||||||
.merge(workspace::routes())
|
.merge(workspace::routes())
|
||||||
@@ -74,40 +77,42 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let router = Router::new().merge(api_routes);
|
let router = Router::new().merge(api_routes);
|
||||||
match std::env::var("BOOSAPI_WEB_DIR")
|
let router = match std::env::var("BOOSAPI_WEB_DIR")
|
||||||
.ok()
|
.ok()
|
||||||
.map(|value| value.trim().to_string())
|
.map(|value| value.trim().to_string())
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
{
|
{
|
||||||
Some(web_dir) => {
|
Some(web_dir) => {
|
||||||
let web_dir_owned = web_dir.clone();
|
let website_dir = std::env::var("BOOSAPI_WEBSITE_DIR")
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(|| "dist/website".to_string());
|
||||||
let static_service = ServeDir::new(&web_dir)
|
let static_service = ServeDir::new(&web_dir)
|
||||||
.append_index_html_on_directories(true)
|
.append_index_html_on_directories(true)
|
||||||
.fallback(service_fn(move |req: Request<Body>| {
|
.fallback(service_fn(move |req: Request<Body>| {
|
||||||
let web_dir = web_dir_owned.clone();
|
let web_dir = web_dir.clone();
|
||||||
|
let website_dir = website_dir.clone();
|
||||||
async move {
|
async move {
|
||||||
let path = req.uri().path();
|
let path = req.uri().path();
|
||||||
if is_api_path(path) {
|
if is_api_path(path) {
|
||||||
return Ok::<_, Infallible>(api_not_found_response(path));
|
return Ok::<_, Infallible>(api_not_found_response(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
let index_path = format!("{}/index.html", web_dir);
|
Ok::<_, Infallible>(filesystem_static_response(
|
||||||
match tokio::fs::read_to_string(&index_path).await {
|
path,
|
||||||
Ok(html) => Ok::<_, Infallible>(html_response(html)),
|
&web_dir,
|
||||||
Err(_) => Ok::<_, Infallible>(
|
&website_dir,
|
||||||
Response::builder()
|
)
|
||||||
.status(StatusCode::NOT_FOUND)
|
.await)
|
||||||
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
|
||||||
.body(Body::from(format!("404: {} not found", path)))
|
|
||||||
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response()),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
router.fallback_service(static_service)
|
router.fallback_service(static_service)
|
||||||
}
|
}
|
||||||
None => router.fallback(embedded_static_handler),
|
None => router.fallback(embedded_static_handler),
|
||||||
}
|
};
|
||||||
|
|
||||||
|
router.layer(CompressionLayer::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn api_not_found_response(path: &str) -> Response {
|
fn api_not_found_response(path: &str) -> Response {
|
||||||
@@ -120,6 +125,49 @@ fn api_not_found_response(path: &str) -> Response {
|
|||||||
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
|
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn filesystem_static_response(path: &str, web_dir: &str, website_dir: &str) -> Response {
|
||||||
|
let root_dir = if should_serve_website(path) {
|
||||||
|
website_dir
|
||||||
|
} else {
|
||||||
|
web_dir
|
||||||
|
};
|
||||||
|
let asset_path = embedded_asset_path(path);
|
||||||
|
let candidate = format!("{}/{}", root_dir.trim_end_matches('/'), asset_path);
|
||||||
|
|
||||||
|
match tokio::fs::read(&candidate).await {
|
||||||
|
Ok(bytes) => return embedded_file_response(&asset_path, &bytes),
|
||||||
|
Err(_) => {
|
||||||
|
if candidate.ends_with("/index.html") {
|
||||||
|
let without_index = candidate.trim_end_matches("index.html");
|
||||||
|
if let Ok(bytes) = tokio::fs::read(without_index).await {
|
||||||
|
return embedded_file_response(&asset_path, &bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let index_path = format!("{}/index.html", root_dir.trim_end_matches('/'));
|
||||||
|
match tokio::fs::read(&index_path).await {
|
||||||
|
Ok(bytes) => embedded_file_response("index.html", &bytes),
|
||||||
|
Err(_) if should_serve_website(path) => {
|
||||||
|
let fallback_index = format!("{}/index.html", web_dir.trim_end_matches('/'));
|
||||||
|
match tokio::fs::read(&fallback_index).await {
|
||||||
|
Ok(bytes) => embedded_file_response("index.html", &bytes),
|
||||||
|
Err(_) => not_found_response(path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => not_found_response(path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn not_found_response(path: &str) -> Response {
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::NOT_FOUND)
|
||||||
|
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||||
|
.body(Body::from(format!("404: {} not found", path)))
|
||||||
|
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
async fn embedded_static_handler(req: Request<Body>) -> Response {
|
async fn embedded_static_handler(req: Request<Body>) -> Response {
|
||||||
let path = req.uri().path();
|
let path = req.uri().path();
|
||||||
if is_api_path(path) {
|
if is_api_path(path) {
|
||||||
@@ -127,27 +175,64 @@ async fn embedded_static_handler(req: Request<Body>) -> Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let asset_path = embedded_asset_path(path);
|
let asset_path = embedded_asset_path(path);
|
||||||
if let Some(file) = EMBEDDED_WEB.get_file(&asset_path) {
|
let static_dir = if should_serve_website(path) {
|
||||||
|
&EMBEDDED_WEBSITE
|
||||||
|
} else {
|
||||||
|
&EMBEDDED_WEB
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(file) = static_dir.get_file(&asset_path) {
|
||||||
return embedded_file_response(&asset_path, file.contents());
|
return embedded_file_response(&asset_path, file.contents());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(file) = static_dir.get_file("index.html") {
|
||||||
|
return embedded_file_response("index.html", file.contents());
|
||||||
|
}
|
||||||
|
|
||||||
|
let disk_fallback = filesystem_static_response(path, "dist/web", "dist/website").await;
|
||||||
|
if disk_fallback.status() != StatusCode::NOT_FOUND {
|
||||||
|
return disk_fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if should_serve_website(path) && EMBEDDED_WEBSITE.get_file("index.html").is_none() {
|
||||||
if let Some(file) = EMBEDDED_WEB.get_file("index.html") {
|
if let Some(file) = EMBEDDED_WEB.get_file("index.html") {
|
||||||
return embedded_file_response("index.html", file.contents());
|
return embedded_file_response("index.html", file.contents());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Response::builder()
|
Response::builder()
|
||||||
.status(StatusCode::NOT_FOUND)
|
.status(StatusCode::NOT_FOUND)
|
||||||
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||||
.body(Body::from("embedded dist/web/index.html not found"))
|
.body(Body::from(
|
||||||
|
"embedded frontend index.html not found; rebuild with ./build-and-run.sh --production or set BOOSAPI_WEB_DIR/BOOSAPI_WEBSITE_DIR",
|
||||||
|
))
|
||||||
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
|
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_api_path(path: &str) -> bool {
|
fn is_api_path(path: &str) -> bool {
|
||||||
path.starts_with("/api/") || path.starts_with("/v1/")
|
path.starts_with("/api/")
|
||||||
|
|| path.starts_with("/v1/")
|
||||||
|
|| path == "/health"
|
||||||
|
|| path == "/ready"
|
||||||
|
|| path == "/metrics"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_serve_website(path: &str) -> bool {
|
||||||
|
path == "/"
|
||||||
|
|| path == "/new"
|
||||||
|
|| path.starts_with("/new/")
|
||||||
|
|| path.starts_with("/_next/")
|
||||||
|
|| path == "/favicon.ico"
|
||||||
|
|| path == "/favicon.png"
|
||||||
|
|| path == "/favicon-64.png"
|
||||||
|
|| path == "/logo.png"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn embedded_asset_path(path: &str) -> String {
|
fn embedded_asset_path(path: &str) -> String {
|
||||||
let trimmed = path.trim_start_matches('/');
|
let trimmed = path.trim_start_matches('/');
|
||||||
|
if trimmed == "new" || trimmed == "new/" || trimmed == "new/index.html" {
|
||||||
|
return "new.html".to_string();
|
||||||
|
}
|
||||||
if trimmed.is_empty() || trimmed.ends_with('/') {
|
if trimmed.is_empty() || trimmed.ends_with('/') {
|
||||||
format!("{trimmed}index.html")
|
format!("{trimmed}index.html")
|
||||||
} else {
|
} else {
|
||||||
@@ -155,14 +240,6 @@ fn embedded_asset_path(path: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn html_response(html: String) -> Response {
|
|
||||||
Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
|
||||||
.body(Body::from(html))
|
|
||||||
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn embedded_file_response(path: &str, bytes: &[u8]) -> Response {
|
fn embedded_file_response(path: &str, bytes: &[u8]) -> Response {
|
||||||
let content_type = mime_guess::from_path(path)
|
let content_type = mime_guess::from_path(path)
|
||||||
.first_or_octet_stream()
|
.first_or_octet_stream()
|
||||||
@@ -195,3 +272,27 @@ async fn removed_comfy_workbench() -> (StatusCode, Json<Value>) {
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{embedded_asset_path, should_serve_website};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn experimental_website_route_is_served_from_website_export() {
|
||||||
|
assert!(should_serve_website("/new"));
|
||||||
|
assert!(should_serve_website("/new/"));
|
||||||
|
assert!(should_serve_website("/new/index.html"));
|
||||||
|
assert_eq!(embedded_asset_path("/new"), "new.html");
|
||||||
|
assert_eq!(embedded_asset_path("/new/"), "new.html");
|
||||||
|
assert_eq!(embedded_asset_path("/new/index.html"), "new.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn application_routes_still_use_web_spa() {
|
||||||
|
assert!(!should_serve_website("/boos"));
|
||||||
|
assert!(!should_serve_website("/dashboard"));
|
||||||
|
assert!(!should_serve_website("/user/recharge"));
|
||||||
|
assert!(!should_serve_website("/logs"));
|
||||||
|
assert!(!should_serve_website("/events"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,297 +0,0 @@
|
|||||||
// Monitor routes — LDOH incident dashboard proxy with cookie auth
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
error::{AppError, AppResult},
|
|
||||||
AppState,
|
|
||||||
};
|
|
||||||
use axum::{
|
|
||||||
extract::{Path, State},
|
|
||||||
http::{header, HeaderMap},
|
|
||||||
response::Response,
|
|
||||||
routing::{get, post},
|
|
||||||
Json, Router,
|
|
||||||
};
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
const MONITOR_AUTH_COOKIE: &str = "meta_monitor_auth";
|
|
||||||
const LDOH_BASE_URL: &str = "https://ldoh.105117.xyz";
|
|
||||||
const LDOH_COOKIE_SETTING_KEY: &str = "monitor_ldoh_cookie";
|
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
|
||||||
Router::new()
|
|
||||||
.route(
|
|
||||||
"/api/monitor/config",
|
|
||||||
get(get_monitor_config).put(update_monitor_config),
|
|
||||||
)
|
|
||||||
.route("/api/monitor/session", post(create_session))
|
|
||||||
.route("/monitor-proxy/ldoh", get(ldoh_proxy_handler))
|
|
||||||
.route("/monitor-proxy/ldoh/", get(ldoh_proxy_handler))
|
|
||||||
.route("/monitor-proxy/ldoh/{*path}", get(ldoh_proxy_handler))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/monitor/config — returns whether LDOH cookie is configured
|
|
||||||
async fn get_monitor_config(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
|
||||||
let cookie: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
|
|
||||||
.bind(LDOH_COOKIE_SETTING_KEY)
|
|
||||||
.fetch_optional(&*state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let raw = cookie
|
|
||||||
.and_then(|(v,)| {
|
|
||||||
serde_json::from_str::<serde_json::Value>(&v)
|
|
||||||
.ok()
|
|
||||||
.and_then(|parsed| parsed.as_str().map(String::from))
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let configured = !raw.is_empty();
|
|
||||||
let masked = if configured {
|
|
||||||
mask_cookie_value(&raw)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"ldohCookieConfigured": configured,
|
|
||||||
"ldohCookieMasked": masked,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PUT /api/monitor/config — save LDOH cookie
|
|
||||||
async fn update_monitor_config(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(body): Json<serde_json::Value>,
|
|
||||||
) -> AppResult<Json<Value>> {
|
|
||||||
let cookie = body
|
|
||||||
.get("ldohCookie")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
if cookie.is_empty() {
|
|
||||||
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, '')")
|
|
||||||
.bind(LDOH_COOKIE_SETTING_KEY)
|
|
||||||
.execute(&*state.db)
|
|
||||||
.await?;
|
|
||||||
return Ok(Json(json!({
|
|
||||||
"success": true,
|
|
||||||
"message": "LDOH Cookie 已清空",
|
|
||||||
"ldohCookieConfigured": false,
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
|
|
||||||
let normalized = normalize_ldoh_cookie(cookie);
|
|
||||||
if !normalized.starts_with("ld_auth_session=") || normalized.len() < 24 {
|
|
||||||
return Err(AppError::BadRequest("Cookie 格式无效".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let escaped = serde_json::to_string(&normalized).unwrap_or_default();
|
|
||||||
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
|
||||||
.bind(LDOH_COOKIE_SETTING_KEY)
|
|
||||||
.bind(&escaped)
|
|
||||||
.execute(&*state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"success": true,
|
|
||||||
"message": "LDOH Cookie 已保存",
|
|
||||||
"ldohCookieConfigured": true,
|
|
||||||
"ldohCookieMasked": mask_cookie_value(&normalized),
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/monitor/session — returns cookie value for client to set
|
|
||||||
async fn create_session(State(state): State<AppState>) -> Json<Value> {
|
|
||||||
let token = &state.config.auth.auth_token;
|
|
||||||
Json(json!({
|
|
||||||
"success": true,
|
|
||||||
"_cookie": format!("{}={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=7200", MONITOR_AUTH_COOKIE, token),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /monitor-proxy/ldoh/{*path} — reverse proxy to ldoh.105117.xyz
|
|
||||||
async fn ldoh_proxy_handler(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
Path(path): Path<String>,
|
|
||||||
) -> Result<Response, AppError> {
|
|
||||||
// Auth check via cookie
|
|
||||||
let cookies = parse_cookies(
|
|
||||||
headers
|
|
||||||
.get(header::COOKIE)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.unwrap_or(""),
|
|
||||||
);
|
|
||||||
if cookies.get(MONITOR_AUTH_COOKIE).map(|s| s.as_str()) != Some(&state.config.auth.auth_token) {
|
|
||||||
return Err(AppError::Unauthorized(
|
|
||||||
"Missing or invalid monitor session".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get stored LDOH cookie
|
|
||||||
let stored: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
|
|
||||||
.bind(LDOH_COOKIE_SETTING_KEY)
|
|
||||||
.fetch_optional(&*state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let ldoh_cookie = stored
|
|
||||||
.and_then(|(v,)| {
|
|
||||||
serde_json::from_str::<serde_json::Value>(&v)
|
|
||||||
.ok()
|
|
||||||
.and_then(|parsed| parsed.as_str().map(String::from))
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
if ldoh_cookie.is_empty() {
|
|
||||||
return Err(AppError::BadRequest("LDOH cookie not configured".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build upstream URL
|
|
||||||
let clean_path = if path.is_empty() || path == "/" {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("/{}", path)
|
|
||||||
};
|
|
||||||
let target_url = format!("{}{}", LDOH_BASE_URL, clean_path);
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
|
||||||
.build()
|
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to build client: {e}")))?;
|
|
||||||
|
|
||||||
let mut req = client
|
|
||||||
.get(&target_url)
|
|
||||||
.header("cookie", &ldoh_cookie)
|
|
||||||
.header(
|
|
||||||
"accept",
|
|
||||||
headers
|
|
||||||
.get("accept")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.unwrap_or("*/*"),
|
|
||||||
)
|
|
||||||
.header(
|
|
||||||
"accept-language",
|
|
||||||
headers
|
|
||||||
.get("accept-language")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.unwrap_or("zh-CN,zh;q=0.9,en;q=0.8"),
|
|
||||||
)
|
|
||||||
.header(
|
|
||||||
"user-agent",
|
|
||||||
headers
|
|
||||||
.get("user-agent")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.unwrap_or("boosapiMonitorProxy/1.0"),
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(referer) = headers.get("referer").and_then(|v| v.to_str().ok()) {
|
|
||||||
req = req.header("referer", referer.replace("/monitor-proxy/ldoh", ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
let resp = req
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::UpstreamError(format!("LDOH unreachable: {e}")))?;
|
|
||||||
|
|
||||||
let status = resp.status();
|
|
||||||
let content_type = resp
|
|
||||||
.headers()
|
|
||||||
.get("content-type")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.unwrap_or("application/octet-stream")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let mut builder = axum::response::Response::builder().status(status);
|
|
||||||
if let Some(ctl) = resp
|
|
||||||
.headers()
|
|
||||||
.get("cache-control")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
{
|
|
||||||
builder = builder.header("cache-control", ctl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rewrite text content
|
|
||||||
if content_type.contains("text/html")
|
|
||||||
|| content_type.contains("javascript")
|
|
||||||
|| content_type.contains("text/css")
|
|
||||||
|| content_type.contains("application/json")
|
|
||||||
{
|
|
||||||
let text = resp
|
|
||||||
.text()
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
|
||||||
return builder
|
|
||||||
.header(header::CONTENT_TYPE, &content_type)
|
|
||||||
.body(axum::body::Body::from(rewrite_proxy_text(&text)))
|
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")));
|
|
||||||
}
|
|
||||||
|
|
||||||
let bytes = resp
|
|
||||||
.bytes()
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
|
||||||
builder
|
|
||||||
.header(header::CONTENT_TYPE, &content_type)
|
|
||||||
.body(axum::body::Body::from(bytes))
|
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn parse_cookies(raw: &str) -> std::collections::HashMap<String, String> {
|
|
||||||
let mut map = std::collections::HashMap::new();
|
|
||||||
for part in raw.split(';') {
|
|
||||||
let part = part.trim();
|
|
||||||
if let Some(idx) = part.find('=') {
|
|
||||||
let key = part[..idx].trim().to_string();
|
|
||||||
let val = part[idx + 1..].trim().to_string();
|
|
||||||
if !key.is_empty() {
|
|
||||||
map.insert(key, val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
map
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mask_cookie_value(cookie_text: &str) -> String {
|
|
||||||
let raw = cookie_text.trim();
|
|
||||||
if raw.is_empty() {
|
|
||||||
return String::new();
|
|
||||||
}
|
|
||||||
let value = raw.split('=').nth(1).unwrap_or(raw);
|
|
||||||
if value.len() <= 10 {
|
|
||||||
format!("{}****", &value[..2.min(value.len())])
|
|
||||||
} else {
|
|
||||||
format!("{}****{}", &value[..6], &value[value.len() - 4..])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_ldoh_cookie(raw: &str) -> String {
|
|
||||||
let trimmed = raw.trim().to_string();
|
|
||||||
if trimmed.contains("ld_auth_session=") {
|
|
||||||
if let Some(first) = trimmed.split(';').next() {
|
|
||||||
if first.trim().starts_with("ld_auth_session=") {
|
|
||||||
return first.trim().to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
format!("ld_auth_session={}", trimmed)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rewrite_proxy_text(text: &str) -> String {
|
|
||||||
text.replace("https://ldoh.105117.xyz/", "/monitor-proxy/ldoh/")
|
|
||||||
.replace(
|
|
||||||
"https:\\/\\/ldoh.105117.xyz\\/",
|
|
||||||
"\\/monitor-proxy\\/ldoh\\/",
|
|
||||||
)
|
|
||||||
.replace("src=\"/", "src=\"/monitor-proxy/ldoh/")
|
|
||||||
.replace("src='/", "src='/monitor-proxy/ldoh/")
|
|
||||||
.replace("href=\"/", "href=\"/monitor-proxy/ldoh/")
|
|
||||||
.replace("href='/", "href='/monitor-proxy/ldoh/")
|
|
||||||
.replace("action=\"/", "action=\"/monitor-proxy/ldoh/")
|
|
||||||
.replace("action='/", "action='/monitor-proxy/ldoh/")
|
|
||||||
.replace("\"/_next/", "\"/monitor-proxy/ldoh/_next/")
|
|
||||||
.replace("'/_next/", "'/monitor-proxy/ldoh/_next/")
|
|
||||||
.replace("\"\\\\/api/", "\"\\\\/monitor-proxy\\\\/ldoh\\\\/api/")
|
|
||||||
.replace("'/api/", "'/monitor-proxy/ldoh/api/")
|
|
||||||
.replace("\"/api/", "\"/monitor-proxy/ldoh/api/")
|
|
||||||
}
|
|
||||||
+4202
-113
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
|||||||
|
use crate::{
|
||||||
|
auth::jwt::validate_jwt,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
services::payment,
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
body::Bytes,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/payment/config", get(get_payment_config))
|
||||||
|
.route("/api/user/payment/orders/{public_id}", get(get_payment_order))
|
||||||
|
.route(
|
||||||
|
"/api/v1/payment/webhook/stripe",
|
||||||
|
post(stripe_webhook),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_user_id(headers: &HeaderMap, state: &AppState) -> AppResult<i64> {
|
||||||
|
let auth = headers
|
||||||
|
.get("Authorization")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|value| value.strip_prefix("Bearer "))
|
||||||
|
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
|
||||||
|
|
||||||
|
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
|
||||||
|
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||||
|
|
||||||
|
claims
|
||||||
|
.sub
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_payment_config(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
|
Ok(Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"payment": payment::public_config(&state.config.payment),
|
||||||
|
"webhooks": {
|
||||||
|
"stripe": "/api/v1/payment/webhook/stripe",
|
||||||
|
},
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_payment_order(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(public_id): Path<String>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let user_id = current_user_id(&headers, &state)?;
|
||||||
|
let order = payment::fetch_payment_order_by_public_id(&state.db, user_id, public_id.trim()).await?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"order": order,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stripe_webhook(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||||
|
let webhook_secret = state
|
||||||
|
.config
|
||||||
|
.payment
|
||||||
|
.stripe_webhook_secret
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| AppError::ServiceUnavailable("Stripe webhook secret is not configured".into()))?;
|
||||||
|
let signature = headers
|
||||||
|
.get("stripe-signature")
|
||||||
|
.or_else(|| headers.get("Stripe-Signature"))
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.ok_or_else(|| AppError::Unauthorized("Missing Stripe signature".into()))?;
|
||||||
|
if !payment::verify_stripe_signature(&body, signature, webhook_secret, 300) {
|
||||||
|
return Err(AppError::Unauthorized("Invalid Stripe signature".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let event: Value = serde_json::from_slice(&body)
|
||||||
|
.map_err(|_| AppError::BadRequest("Invalid Stripe webhook JSON".into()))?;
|
||||||
|
let Some((event_type, session_id, payment_intent)) = payment::stripe_event_checkout_session(&event) else {
|
||||||
|
return Ok((StatusCode::OK, Json(json!({ "received": true, "ignored": true }))));
|
||||||
|
};
|
||||||
|
|
||||||
|
match event_type.as_str() {
|
||||||
|
"checkout.session.completed" => {
|
||||||
|
let completion = payment::complete_stripe_order_by_session(
|
||||||
|
&state.db,
|
||||||
|
&session_id,
|
||||||
|
payment_intent.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(json!({
|
||||||
|
"received": true,
|
||||||
|
"completed": true,
|
||||||
|
"order": completion.order,
|
||||||
|
"rechargeRecord": completion.recharge_record,
|
||||||
|
"creditedKeyId": completion.credited_key_id,
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
"checkout.session.expired" => {
|
||||||
|
let order = payment::mark_stripe_order_failed_by_session(&state.db, &session_id).await?;
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(json!({
|
||||||
|
"received": true,
|
||||||
|
"failed": order.is_some(),
|
||||||
|
"order": order,
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => Ok((StatusCode::OK, Json(json!({ "received": true, "ignored": true })))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use axum::http::HeaderValue;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use sha2::Sha256;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stripe_signature_verification_matches_route_header_shape() {
|
||||||
|
let payload = br#"{"type":"checkout.session.completed"}"#;
|
||||||
|
let secret = "whsec_route_test";
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
|
||||||
|
mac.update(format!("{timestamp}.{}", String::from_utf8_lossy(payload)).as_bytes());
|
||||||
|
let signature = hex::encode(mac.finalize().into_bytes());
|
||||||
|
let header = HeaderValue::from_str(&format!("t={timestamp},v1={signature}")).unwrap();
|
||||||
|
|
||||||
|
assert!(payment::verify_stripe_signature(
|
||||||
|
payload,
|
||||||
|
header.to_str().unwrap(),
|
||||||
|
secret,
|
||||||
|
300,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
-36
@@ -1,27 +1,28 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
|
AppState,
|
||||||
auth::jwt::validate_jwt,
|
auth::jwt::validate_jwt,
|
||||||
db::models::background_task::BackgroundTask,
|
db::models::background_task::BackgroundTask,
|
||||||
db::models::downstream_api_key::DownstreamApiKey,
|
db::models::downstream_api_key::DownstreamApiKey,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
AppState,
|
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Json, Router,
|
||||||
body::Body,
|
body::Body,
|
||||||
extract::{Multipart, Path, Query, State},
|
extract::{Multipart, Path, Query, State},
|
||||||
http::{header, HeaderMap, Response, StatusCode},
|
http::{HeaderMap, Response, StatusCode, header},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
|
||||||
};
|
};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{Value, json};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
const TEMPLATE_GALLERY_URL: &str =
|
const TEMPLATE_GALLERY_PAGE_URL: &str =
|
||||||
"https://weiyyin.github.io/linux-do-gpt-image/linux_do_2036927_prompts_images.filtered.json";
|
"https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html";
|
||||||
|
const LINUX_DO_TEMPLATE_JSON: &str =
|
||||||
|
include_str!("../../assets/picture-agent/linux_do_2036927_prompts_images.with_tags.json");
|
||||||
const TASK_TYPE_PICTURE_GENERATION: &str = "picture-agent-generation";
|
const TASK_TYPE_PICTURE_GENERATION: &str = "picture-agent-generation";
|
||||||
const TEMPLATE_GALLERY_TIMEOUT_SECS: u64 = 30;
|
|
||||||
const PICTURE_GENERATION_TIMEOUT_SECS: u64 = 300;
|
const PICTURE_GENERATION_TIMEOUT_SECS: u64 = 300;
|
||||||
const PICTURE_GENERATION_RETRY_SECS: u64 = 180;
|
const PICTURE_GENERATION_RETRY_SECS: u64 = 180;
|
||||||
const STALE_RUNNING_JOB_MINUTES: i64 = 12;
|
const STALE_RUNNING_JOB_MINUTES: i64 = 12;
|
||||||
@@ -31,6 +32,7 @@ struct PictureAgentAuth {
|
|||||||
user_id: Option<i64>,
|
user_id: Option<i64>,
|
||||||
role: String,
|
role: String,
|
||||||
outbound_key: String,
|
outbound_key: String,
|
||||||
|
key_id: Option<i64>,
|
||||||
key_name: String,
|
key_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,11 +124,13 @@ async fn resolve_picture_agent_auth(
|
|||||||
let token = extract_bearer_token(headers)
|
let token = extract_bearer_token(headers)
|
||||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||||
|
|
||||||
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
|
let proxy_token = state.runtime_proxy_token();
|
||||||
|
if token == state.config.auth.auth_token || token == proxy_token {
|
||||||
return Ok(PictureAgentAuth {
|
return Ok(PictureAgentAuth {
|
||||||
user_id: None,
|
user_id: None,
|
||||||
role: "admin".to_string(),
|
role: "admin".to_string(),
|
||||||
outbound_key: state.config.auth.proxy_token.clone(),
|
outbound_key: proxy_token,
|
||||||
|
key_id: None,
|
||||||
key_name: "global-proxy".to_string(),
|
key_name: "global-proxy".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -147,12 +151,14 @@ async fn resolve_picture_agent_auth(
|
|||||||
user_id: Some(user_id),
|
user_id: Some(user_id),
|
||||||
role: claims.role,
|
role: claims.role,
|
||||||
outbound_key: key.key,
|
outbound_key: key.key,
|
||||||
|
key_id: Some(key.id),
|
||||||
key_name: key.name,
|
key_name: key.name,
|
||||||
}),
|
}),
|
||||||
Err(_) if claims.role == "admin" => Ok(PictureAgentAuth {
|
Err(_) if claims.role == "admin" => Ok(PictureAgentAuth {
|
||||||
user_id: Some(user_id),
|
user_id: Some(user_id),
|
||||||
role: claims.role,
|
role: claims.role,
|
||||||
outbound_key: state.config.auth.proxy_token.clone(),
|
outbound_key: state.runtime_proxy_token(),
|
||||||
|
key_id: None,
|
||||||
key_name: "global-proxy".to_string(),
|
key_name: "global-proxy".to_string(),
|
||||||
}),
|
}),
|
||||||
Err(message) => Err(AppError::Forbidden(message)),
|
Err(message) => Err(AppError::Forbidden(message)),
|
||||||
@@ -285,6 +291,7 @@ mod tests {
|
|||||||
assert!(!is_picture_model_pattern("deepseek-chat"));
|
assert!(!is_picture_model_pattern("deepseek-chat"));
|
||||||
assert!(!is_picture_model_pattern("qwen-plus"));
|
assert!(!is_picture_model_pattern("qwen-plus"));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prompt_title(prompt: &str, post_number: Option<i64>) -> String {
|
fn prompt_title(prompt: &str, post_number: Option<i64>) -> String {
|
||||||
@@ -344,18 +351,30 @@ async fn templates_handler(
|
|||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let _auth = resolve_picture_agent_auth(&headers, &state).await?;
|
let _auth = resolve_picture_agent_auth(&headers, &state).await?;
|
||||||
let limit = query.limit.unwrap_or(144).clamp(1, 240);
|
let limit = query.limit.unwrap_or(144).clamp(1, 240);
|
||||||
let items = template_client()
|
let (templates, local_status) = match parse_gallery_templates(LINUX_DO_TEMPLATE_JSON, limit) {
|
||||||
.get(TEMPLATE_GALLERY_URL)
|
Ok(templates) => (templates, json!({ "ok": true, "source": "local-json" })),
|
||||||
.send()
|
Err(err) => {
|
||||||
.await
|
tracing::warn!(%err, "Picture Agent local Linux Do template gallery unavailable");
|
||||||
.map_err(|err| AppError::UpstreamError(format!("failed to fetch templates: {err}")))?
|
(Vec::new(), json!({ "ok": false, "message": err }))
|
||||||
.error_for_status()
|
}
|
||||||
.map_err(|err| AppError::UpstreamError(format!("template gallery returned {err}")))?
|
};
|
||||||
.json::<Vec<GalleryItem>>()
|
|
||||||
.await
|
|
||||||
.map_err(|err| AppError::UpstreamError(format!("failed to parse templates: {err}")))?;
|
|
||||||
|
|
||||||
let templates: Vec<Value> = items
|
Ok(Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"sourceUrl": TEMPLATE_GALLERY_PAGE_URL,
|
||||||
|
"source": local_status,
|
||||||
|
"templates": templates,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_gallery_templates(raw: &str, limit: usize) -> Result<Vec<Value>, String> {
|
||||||
|
if limit == 0 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let items = serde_json::from_str::<Vec<GalleryItem>>(raw)
|
||||||
|
.map_err(|err| format!("failed to parse local Linux Do templates: {err}"))?;
|
||||||
|
|
||||||
|
Ok(items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|item| {
|
.filter_map(|item| {
|
||||||
let prompt = item.prompt?.trim().to_string();
|
let prompt = item.prompt?.trim().to_string();
|
||||||
@@ -383,27 +402,14 @@ async fn templates_handler(
|
|||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"source": {
|
"source": {
|
||||||
"name": "Linux Do GPT Image Gallery",
|
"name": "Linux Do GPT Image Gallery",
|
||||||
"url": "https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html",
|
"url": TEMPLATE_GALLERY_PAGE_URL,
|
||||||
"postNumber": post_number,
|
"postNumber": post_number,
|
||||||
"username": item.username,
|
"username": item.username,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
.take(limit)
|
.take(limit)
|
||||||
.collect();
|
.collect())
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"success": true,
|
|
||||||
"sourceUrl": "https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html",
|
|
||||||
"templates": templates,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn template_client() -> Client {
|
|
||||||
Client::builder()
|
|
||||||
.timeout(Duration::from_secs(TEMPLATE_GALLERY_TIMEOUT_SECS))
|
|
||||||
.build()
|
|
||||||
.unwrap_or_else(|_| Client::new())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn picture_generation_client() -> Client {
|
fn picture_generation_client() -> Client {
|
||||||
@@ -654,6 +660,7 @@ async fn create_generation_job(
|
|||||||
let initial_result = json!({
|
let initial_result = json!({
|
||||||
"userId": auth.user_id,
|
"userId": auth.user_id,
|
||||||
"role": auth.role,
|
"role": auth.role,
|
||||||
|
"keyId": auth.key_id,
|
||||||
"keyName": auth.key_name,
|
"keyName": auth.key_name,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"model": model,
|
"model": model,
|
||||||
@@ -735,6 +742,7 @@ async fn create_generation_job(
|
|||||||
let final_result = json!({
|
let final_result = json!({
|
||||||
"userId": auth_for_task.user_id,
|
"userId": auth_for_task.user_id,
|
||||||
"role": auth_for_task.role,
|
"role": auth_for_task.role,
|
||||||
|
"keyId": auth_for_task.key_id,
|
||||||
"keyName": auth_for_task.key_name,
|
"keyName": auth_for_task.key_name,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"model": model,
|
"model": model,
|
||||||
|
|||||||
+766
-334
File diff suppressed because it is too large
Load Diff
+921
-67
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,11 @@
|
|||||||
// Background tasks routes — list and inspect async operations
|
// Background tasks routes — list and inspect async operations
|
||||||
|
|
||||||
use std::convert::Infallible;
|
|
||||||
|
|
||||||
use crate::{db::models::background_task::BackgroundTask, error::AppResult, AppState};
|
use crate::{db::models::background_task::BackgroundTask, error::AppResult, AppState};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
response::{
|
|
||||||
sse::{Event, Sse},
|
|
||||||
IntoResponse,
|
|
||||||
},
|
|
||||||
routing::get,
|
routing::get,
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use futures::stream::Stream;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
@@ -26,10 +19,6 @@ pub fn routes() -> Router<AppState> {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/tasks", get(list_tasks))
|
.route("/api/tasks", get(list_tasks))
|
||||||
.route("/api/tasks/{id}", get(get_task))
|
.route("/api/tasks/{id}", get(get_task))
|
||||||
.route(
|
|
||||||
"/api/update-center/tasks/{id}/stream",
|
|
||||||
get(stream_task_logs),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/tasks — list background tasks
|
/// GET /api/tasks — list background tasks
|
||||||
@@ -67,113 +56,3 @@ async fn get_task(State(state): State<AppState>, Path(id): Path<String>) -> AppR
|
|||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/update-center/tasks/{id}/stream — SSE stream of task progress
|
|
||||||
async fn stream_task_logs(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, axum::response::Response> {
|
|
||||||
info!("Streaming task logs for: {}", id);
|
|
||||||
|
|
||||||
// Check task exists
|
|
||||||
let task: Option<BackgroundTask> =
|
|
||||||
sqlx::query_as::<_, BackgroundTask>("SELECT * FROM background_tasks WHERE id = ?")
|
|
||||||
.bind(&id)
|
|
||||||
.fetch_optional(&*state.db)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
(
|
|
||||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("DB error: {e}"),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let task = match task {
|
|
||||||
Some(t) => t,
|
|
||||||
None => {
|
|
||||||
return Err((
|
|
||||||
axum::http::StatusCode::NOT_FOUND,
|
|
||||||
json!({ "success": false, "message": "task not found" }).to_string(),
|
|
||||||
)
|
|
||||||
.into_response());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Stream task state with polling
|
|
||||||
let initial_status = task.status.clone();
|
|
||||||
let is_done = initial_status != "pending" && initial_status != "running";
|
|
||||||
|
|
||||||
let state_clone = state.clone();
|
|
||||||
let stream = async_stream::stream! {
|
|
||||||
// Yield current task status
|
|
||||||
yield Ok::<_, Infallible>(Event::default()
|
|
||||||
.event("status")
|
|
||||||
.data(json!({ "status": initial_status }).to_string()));
|
|
||||||
|
|
||||||
yield Ok::<_, Infallible>(Event::default()
|
|
||||||
.event("log")
|
|
||||||
.data(json!({
|
|
||||||
"seq": 1,
|
|
||||||
"message": format!("Task {}: {}", initial_status, task.message.as_deref().unwrap_or("")),
|
|
||||||
"createdAt": task.created_at.as_deref().unwrap_or(""),
|
|
||||||
}).to_string()));
|
|
||||||
|
|
||||||
if is_done {
|
|
||||||
yield Ok::<_, Infallible>(Event::default()
|
|
||||||
.event("done")
|
|
||||||
.data(json!({ "status": initial_status }).to_string()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Poll for updates
|
|
||||||
let poll_id = id.clone();
|
|
||||||
for i in 0..120 { // Poll up to 120 times (2 minutes)
|
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
|
|
||||||
|
|
||||||
let current: Option<BackgroundTask> = sqlx::query_as(
|
|
||||||
"SELECT * FROM background_tasks WHERE id = ?"
|
|
||||||
)
|
|
||||||
.bind(&poll_id)
|
|
||||||
.fetch_optional(&*state_clone.db)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten();
|
|
||||||
|
|
||||||
match current {
|
|
||||||
Some(t) => {
|
|
||||||
let status = t.status;
|
|
||||||
let updated_at = t.updated_at.unwrap_or_default();
|
|
||||||
|
|
||||||
yield Ok::<_, Infallible>(Event::default()
|
|
||||||
.event("log")
|
|
||||||
.data(json!({
|
|
||||||
"seq": i + 2,
|
|
||||||
"message": format!("Status: {} — {}", status, t.message.as_deref().unwrap_or("")),
|
|
||||||
"createdAt": updated_at,
|
|
||||||
}).to_string()));
|
|
||||||
|
|
||||||
if status != "pending" && status != "running" {
|
|
||||||
yield Ok::<_, Infallible>(Event::default()
|
|
||||||
.event("done")
|
|
||||||
.data(json!({ "status": status }).to_string()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
yield Ok::<_, Infallible>(Event::default()
|
|
||||||
.event("done")
|
|
||||||
.data(json!({ "status": "unknown", "message": "task removed" }).to_string()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timeout
|
|
||||||
yield Ok::<_, Infallible>(Event::default()
|
|
||||||
.event("done")
|
|
||||||
.data(json!({ "status": "timeout" }).to_string()));
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Sse::new(stream))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,435 +0,0 @@
|
|||||||
// Update Center routes — deploy/rollback orchestration via helper API
|
|
||||||
|
|
||||||
#![allow(clippy::type_complexity)]
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
error::{AppError, AppResult},
|
|
||||||
AppState,
|
|
||||||
};
|
|
||||||
use axum::{
|
|
||||||
extract::State,
|
|
||||||
routing::{get, post, put},
|
|
||||||
Json, Router,
|
|
||||||
};
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
const UC_CONFIG_KEY: &str = "update_center_config";
|
|
||||||
|
|
||||||
const TASK_TYPE_DEPLOY: &str = "update-center-deploy";
|
|
||||||
const TASK_TYPE_ROLLBACK: &str = "update-center-rollback";
|
|
||||||
|
|
||||||
const DEDUPE_KEY_DEPLOY: &str = "update-center:deploy";
|
|
||||||
const DEDUPE_KEY_ROLLBACK: &str = "update-center:rollback";
|
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
|
||||||
Router::new()
|
|
||||||
.route("/api/update-center/status", get(get_status))
|
|
||||||
.route("/api/update-center/check", post(check_status))
|
|
||||||
.route("/api/update-center/config", put(update_config))
|
|
||||||
.route("/api/update-center/deploy", post(deploy_handler))
|
|
||||||
.route("/api/update-center/rollback", post(rollback_handler))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/update-center/status
|
|
||||||
async fn get_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
|
||||||
let config = load_uc_config(&state).await?;
|
|
||||||
let helper_token = get_helper_token();
|
|
||||||
|
|
||||||
let last_deploy = load_last_task(&state, TASK_TYPE_DEPLOY).await?;
|
|
||||||
let last_rollback = load_last_task(&state, TASK_TYPE_ROLLBACK).await?;
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"enabled": config.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false),
|
|
||||||
"helperBaseUrl": config.get("helperBaseUrl"),
|
|
||||||
"namespace": config.get("namespace"),
|
|
||||||
"releaseName": config.get("releaseName"),
|
|
||||||
"helperTokenConfigured": !helper_token.is_empty(),
|
|
||||||
"lastDeploy": last_deploy,
|
|
||||||
"lastRollback": last_rollback,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/update-center/check — refresh status cache
|
|
||||||
async fn check_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
|
||||||
// Same as get_status for now
|
|
||||||
get_status(State(state)).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PUT /api/update-center/config
|
|
||||||
async fn update_config(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(body): Json<Value>,
|
|
||||||
) -> AppResult<Json<Value>> {
|
|
||||||
let serialized = serde_json::to_string(&body)
|
|
||||||
.map_err(|e| AppError::BadRequest(format!("Invalid config JSON: {e}")))?;
|
|
||||||
|
|
||||||
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
|
||||||
.bind(UC_CONFIG_KEY)
|
|
||||||
.bind(&serialized)
|
|
||||||
.execute(&*state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"success": true,
|
|
||||||
"config": body,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/update-center/deploy
|
|
||||||
async fn deploy_handler(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(body): Json<Value>,
|
|
||||||
) -> AppResult<Json<Value>> {
|
|
||||||
let config = load_uc_config(&state).await?;
|
|
||||||
let helper_token = get_helper_token();
|
|
||||||
|
|
||||||
let helper_base = config
|
|
||||||
.get("helperBaseUrl")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
let namespace = config
|
|
||||||
.get("namespace")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
let chart_ref = config
|
|
||||||
.get("chartRef")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
let image_repo = config
|
|
||||||
.get("imageRepository")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
if helper_base.is_empty()
|
|
||||||
|| namespace.is_empty()
|
|
||||||
|| chart_ref.is_empty()
|
|
||||||
|| helper_token.is_empty()
|
|
||||||
{
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"Update center not fully configured".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let target_tag = body
|
|
||||||
.get("targetTag")
|
|
||||||
.or_else(|| body.get("targetVersion"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(str::trim)
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
if target_tag.is_empty() {
|
|
||||||
return Err(AppError::BadRequest("targetTag is required".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract owned values for the spawn
|
|
||||||
let helper_base_owned = helper_base.trim_end_matches('/').to_string();
|
|
||||||
let namespace_owned = namespace.to_string();
|
|
||||||
let chart_ref_owned = chart_ref.to_string();
|
|
||||||
let image_repo_owned = image_repo.to_string();
|
|
||||||
let release_name = config
|
|
||||||
.get("releaseName")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(ToOwned::to_owned);
|
|
||||||
let target_tag_owned = target_tag.to_string();
|
|
||||||
let helper_token_owned = helper_token;
|
|
||||||
let title = format!("部署 {}", target_tag);
|
|
||||||
|
|
||||||
let task_id = uuid::Uuid::new_v4().to_string();
|
|
||||||
|
|
||||||
// Create background task
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO background_tasks (id, task_type, title, status, message, dedupe_key)
|
|
||||||
VALUES (?, ?, ?, 'running', ?, ?)",
|
|
||||||
)
|
|
||||||
.bind(&task_id)
|
|
||||||
.bind(TASK_TYPE_DEPLOY)
|
|
||||||
.bind(&title)
|
|
||||||
.bind(format!(
|
|
||||||
"Starting deploy of {} to {}/{}",
|
|
||||||
target_tag, namespace, chart_ref
|
|
||||||
))
|
|
||||||
.bind(DEDUPE_KEY_DEPLOY)
|
|
||||||
.execute(&*state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Call deploy helper in background (fire-and-forget via tokio)
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let deploy_url = format!("{}/api/deploy", helper_base_owned);
|
|
||||||
let task_id_clone = task_id.clone();
|
|
||||||
let db_pool = state.db.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let payload = json!({
|
|
||||||
"namespace": namespace_owned,
|
|
||||||
"releaseName": release_name,
|
|
||||||
"chartRef": chart_ref_owned,
|
|
||||||
"imageRepository": image_repo_owned,
|
|
||||||
"targetTag": target_tag_owned,
|
|
||||||
});
|
|
||||||
|
|
||||||
match client
|
|
||||||
.post(&deploy_url)
|
|
||||||
.header("Authorization", format!("Bearer {}", helper_token_owned))
|
|
||||||
.json(&payload)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(resp) => {
|
|
||||||
let status = resp.status();
|
|
||||||
let status_code = status.as_u16();
|
|
||||||
let body_text = resp.text().await.unwrap_or_default();
|
|
||||||
|
|
||||||
if status.is_success() {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE background_tasks
|
|
||||||
SET status = 'succeeded', message = ?, finished_at = datetime('now')
|
|
||||||
WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(format!("Deploy completed (HTTP {})", status_code))
|
|
||||||
.bind(&task_id_clone)
|
|
||||||
.execute(&*db_pool)
|
|
||||||
.await;
|
|
||||||
} else {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE background_tasks
|
|
||||||
SET status = 'failed', error = ?, finished_at = datetime('now')
|
|
||||||
WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(format!(
|
|
||||||
"Helper returned HTTP {}: {}",
|
|
||||||
status_code, body_text
|
|
||||||
))
|
|
||||||
.bind(&task_id_clone)
|
|
||||||
.execute(&*db_pool)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE background_tasks
|
|
||||||
SET status = 'failed', error = ?, finished_at = datetime('now')
|
|
||||||
WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(format!("Helper unreachable: {}", e))
|
|
||||||
.bind(&task_id_clone)
|
|
||||||
.execute(&*db_pool)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"success": true,
|
|
||||||
"reused": false,
|
|
||||||
"task": {
|
|
||||||
"id": task_id,
|
|
||||||
"type": TASK_TYPE_DEPLOY,
|
|
||||||
"title": title,
|
|
||||||
"status": "running",
|
|
||||||
},
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/update-center/rollback
|
|
||||||
async fn rollback_handler(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(body): Json<Value>,
|
|
||||||
) -> AppResult<Json<Value>> {
|
|
||||||
let config = load_uc_config(&state).await?;
|
|
||||||
let helper_token = get_helper_token();
|
|
||||||
|
|
||||||
let helper_base = config
|
|
||||||
.get("helperBaseUrl")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
let namespace = config
|
|
||||||
.get("namespace")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
if helper_base.is_empty() || namespace.is_empty() || helper_token.is_empty() {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"Update center not fully configured".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let target_revision = body
|
|
||||||
.get("targetRevision")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(str::trim)
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
if target_revision.is_empty() {
|
|
||||||
return Err(AppError::BadRequest("targetRevision is required".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let helper_base_owned = helper_base.trim_end_matches('/').to_string();
|
|
||||||
let namespace_owned = namespace.to_string();
|
|
||||||
let release_name = config
|
|
||||||
.get("releaseName")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(ToOwned::to_owned);
|
|
||||||
let target_revision_owned = target_revision.to_string();
|
|
||||||
let helper_token_owned = helper_token;
|
|
||||||
let title = format!("回退 {}", target_revision);
|
|
||||||
|
|
||||||
let task_id = uuid::Uuid::new_v4().to_string();
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO background_tasks (id, task_type, title, status, message, dedupe_key)
|
|
||||||
VALUES (?, ?, ?, 'running', ?, ?)",
|
|
||||||
)
|
|
||||||
.bind(&task_id)
|
|
||||||
.bind(TASK_TYPE_ROLLBACK)
|
|
||||||
.bind(&title)
|
|
||||||
.bind(format!(
|
|
||||||
"Starting rollback to {} in {}",
|
|
||||||
target_revision, namespace
|
|
||||||
))
|
|
||||||
.bind(DEDUPE_KEY_ROLLBACK)
|
|
||||||
.execute(&*state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let rollback_url = format!("{}/api/rollback", helper_base_owned);
|
|
||||||
let task_id_clone = task_id.clone();
|
|
||||||
let db_pool = state.db.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let payload = json!({
|
|
||||||
"namespace": namespace_owned,
|
|
||||||
"releaseName": release_name,
|
|
||||||
"targetRevision": target_revision_owned,
|
|
||||||
});
|
|
||||||
|
|
||||||
match client
|
|
||||||
.post(&rollback_url)
|
|
||||||
.header("Authorization", format!("Bearer {}", helper_token_owned))
|
|
||||||
.json(&payload)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(resp) => {
|
|
||||||
let status = resp.status();
|
|
||||||
let status_code = status.as_u16();
|
|
||||||
let body_text = resp.text().await.unwrap_or_default();
|
|
||||||
|
|
||||||
if status.is_success() {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE background_tasks
|
|
||||||
SET status = 'succeeded', message = ?, finished_at = datetime('now')
|
|
||||||
WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(format!("Rollback completed (HTTP {})", status_code))
|
|
||||||
.bind(&task_id_clone)
|
|
||||||
.execute(&*db_pool)
|
|
||||||
.await;
|
|
||||||
} else {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE background_tasks
|
|
||||||
SET status = 'failed', error = ?, finished_at = datetime('now')
|
|
||||||
WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(format!(
|
|
||||||
"Helper returned HTTP {}: {}",
|
|
||||||
status_code, body_text
|
|
||||||
))
|
|
||||||
.bind(&task_id_clone)
|
|
||||||
.execute(&*db_pool)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE background_tasks
|
|
||||||
SET status = 'failed', error = ?, finished_at = datetime('now')
|
|
||||||
WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(format!("Helper unreachable: {}", e))
|
|
||||||
.bind(&task_id_clone)
|
|
||||||
.execute(&*db_pool)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"success": true,
|
|
||||||
"reused": false,
|
|
||||||
"task": {
|
|
||||||
"id": task_id,
|
|
||||||
"type": TASK_TYPE_ROLLBACK,
|
|
||||||
"title": title,
|
|
||||||
"status": "running",
|
|
||||||
},
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn get_helper_token() -> String {
|
|
||||||
std::env::var("UPDATE_CENTER_HELPER_TOKEN")
|
|
||||||
.or_else(|_| std::env::var("DEPLOY_HELPER_TOKEN"))
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_uc_config(state: &AppState) -> AppResult<Value> {
|
|
||||||
let row: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
|
|
||||||
.bind(UC_CONFIG_KEY)
|
|
||||||
.fetch_optional(&*state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
match row {
|
|
||||||
Some((v,)) => serde_json::from_str(&v)
|
|
||||||
.map_err(|e| AppError::BadRequest(format!("Invalid update center config JSON: {e}"))),
|
|
||||||
None => Ok(json!({})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_last_task(state: &AppState, task_type: &str) -> AppResult<Option<Value>> {
|
|
||||||
let row: Option<(
|
|
||||||
String,
|
|
||||||
String,
|
|
||||||
Option<String>,
|
|
||||||
Option<String>,
|
|
||||||
String,
|
|
||||||
Option<String>,
|
|
||||||
Option<String>,
|
|
||||||
)> = sqlx::query_as(
|
|
||||||
"SELECT id, status, message, error, created_at, started_at, finished_at
|
|
||||||
FROM background_tasks
|
|
||||||
WHERE task_type = ?
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 1",
|
|
||||||
)
|
|
||||||
.bind(task_type)
|
|
||||||
.fetch_optional(&*state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(row.map(
|
|
||||||
|(id, status, message, error, created_at, started_at, finished_at)| {
|
|
||||||
json!({
|
|
||||||
"id": id,
|
|
||||||
"type": task_type,
|
|
||||||
"status": status,
|
|
||||||
"message": message,
|
|
||||||
"error": error,
|
|
||||||
"createdAt": created_at,
|
|
||||||
"startedAt": started_at,
|
|
||||||
"finishedAt": finished_at,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
))
|
|
||||||
}
|
|
||||||
+1683
-54
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ use std::convert::Infallible;
|
|||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
auth::jwt::validate_jwt,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
@@ -200,15 +201,28 @@ async fn external_comfyui_status_value(state: &AppState) -> Value {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
|
fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
|
||||||
let expected = format!("Bearer {}", state.config.auth.auth_token);
|
let token = headers
|
||||||
|
|
||||||
let actual = headers
|
|
||||||
.get(header::AUTHORIZATION)
|
.get(header::AUTHORIZATION)
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.unwrap_or("");
|
.and_then(|value| {
|
||||||
|
value
|
||||||
|
.strip_prefix("Bearer ")
|
||||||
|
.or_else(|| value.strip_prefix("bearer "))
|
||||||
|
})
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".to_string()))?;
|
||||||
|
|
||||||
if actual != expected {
|
if token == state.config.auth.auth_token {
|
||||||
return Err(AppError::BadRequest("unauthorized".to_string()));
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let claims = validate_jwt(&state.config.auth.jwt_secret, token)
|
||||||
|
.map_err(|_| AppError::Unauthorized("Invalid admin token".to_string()))?;
|
||||||
|
if claims.role != "admin" {
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"Admin account is required for this route".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -273,7 +287,7 @@ async fn call_llm(state: &AppState, prompt: &str, system: &str) -> AppResult<Str
|
|||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
.post(url)
|
.post(url)
|
||||||
.bearer_auth(&state.config.auth.proxy_token)
|
.bearer_auth(state.runtime_proxy_token())
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"model": &state.config.comfyui.agent_model,
|
"model": &state.config.comfyui.agent_model,
|
||||||
"messages": [
|
"messages": [
|
||||||
@@ -319,7 +333,7 @@ async fn call_image_gen(state: &AppState, prompt: &str) -> AppResult<String> {
|
|||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
.post(url)
|
.post(url)
|
||||||
.bearer_auth(&state.config.auth.proxy_token)
|
.bearer_auth(state.runtime_proxy_token())
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"model": "gpt-image-2",
|
"model": "gpt-image-2",
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
@@ -362,7 +376,7 @@ async fn call_tts(state: &AppState, text: &str, voice: &str) -> AppResult<Vec<u8
|
|||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
.post(url)
|
.post(url)
|
||||||
.bearer_auth(&state.config.auth.proxy_token)
|
.bearer_auth(state.runtime_proxy_token())
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"model": "tts-1",
|
"model": "tts-1",
|
||||||
"input": text,
|
"input": text,
|
||||||
@@ -633,3 +647,101 @@ async fn full_workflow(
|
|||||||
}
|
}
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
auth::jwt::generate_jwt,
|
||||||
|
config::{AppConfig, AuthConfig, ComfyUIConfig, DbConfig, ProxyConfig, ServerConfig},
|
||||||
|
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
|
||||||
|
};
|
||||||
|
use axum::http::HeaderValue;
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
|
||||||
|
async fn test_state() -> AppState {
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.expect("sqlite pool");
|
||||||
|
|
||||||
|
AppState::new(
|
||||||
|
AppConfig {
|
||||||
|
server: ServerConfig {
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 4000,
|
||||||
|
log_level: "info".to_string(),
|
||||||
|
cors_origins: Vec::new(),
|
||||||
|
},
|
||||||
|
db: DbConfig {
|
||||||
|
url: "sqlite::memory:".to_string(),
|
||||||
|
max_connections: 1,
|
||||||
|
ssl: false,
|
||||||
|
runtime_db_url: None,
|
||||||
|
runtime_db_dialect: None,
|
||||||
|
runtime_db_ssl: None,
|
||||||
|
},
|
||||||
|
auth: AuthConfig {
|
||||||
|
jwt_secret: "test-jwt-secret".to_string(),
|
||||||
|
jwt_expiry_hours: 24,
|
||||||
|
auth_token: "test-admin-token".to_string(),
|
||||||
|
proxy_token: "sk-test-proxy".to_string(),
|
||||||
|
},
|
||||||
|
proxy: ProxyConfig {
|
||||||
|
upstream_timeout_secs: 30,
|
||||||
|
first_byte_timeout_secs: 30,
|
||||||
|
max_retries: 1,
|
||||||
|
proxy_log_retention_days: 7,
|
||||||
|
template_proxy_url: None,
|
||||||
|
},
|
||||||
|
comfyui: ComfyUIConfig::default(),
|
||||||
|
},
|
||||||
|
pool,
|
||||||
|
BuiltinKeys {
|
||||||
|
comfyui_agent: (0, "sk-comfyui-test".to_string()),
|
||||||
|
},
|
||||||
|
RuntimeAuthSettings::from_token_for_test("sk-test-proxy"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bearer_headers(token: &str) -> HeaderMap {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::AUTHORIZATION,
|
||||||
|
HeaderValue::from_str(&format!("Bearer {token}")).expect("valid bearer header"),
|
||||||
|
);
|
||||||
|
headers
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn legacy_video_agent_auth_accepts_admin_jwt() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let token = generate_jwt(
|
||||||
|
&state.config.auth.jwt_secret,
|
||||||
|
"42",
|
||||||
|
"admin",
|
||||||
|
state.config.auth.jwt_expiry_hours,
|
||||||
|
)
|
||||||
|
.expect("admin jwt");
|
||||||
|
|
||||||
|
check_auth(&bearer_headers(&token), &state)
|
||||||
|
.expect("admin jwt can access legacy video agent routes");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn legacy_video_agent_auth_rejects_user_jwt() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let token = generate_jwt(
|
||||||
|
&state.config.auth.jwt_secret,
|
||||||
|
"43",
|
||||||
|
"user",
|
||||||
|
state.config.auth.jwt_expiry_hours,
|
||||||
|
)
|
||||||
|
.expect("user jwt");
|
||||||
|
|
||||||
|
let result = check_auth(&bearer_headers(&token), &state);
|
||||||
|
|
||||||
|
assert!(matches!(result, Err(AppError::Forbidden(_))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+2629
-222
File diff suppressed because it is too large
Load Diff
+152
-17
@@ -7,12 +7,13 @@ use axum::{
|
|||||||
body::Body,
|
body::Body,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::{header, HeaderMap, Response, StatusCode},
|
http::{header, HeaderMap, Response, StatusCode},
|
||||||
routing::get,
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::path::PathBuf;
|
use std::{collections::HashSet, path::PathBuf};
|
||||||
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
enum WorkspaceCaller {
|
enum WorkspaceCaller {
|
||||||
@@ -33,9 +34,19 @@ struct AssetQuery {
|
|||||||
page_size: Option<i64>,
|
page_size: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BatchDeleteAssetsRequest {
|
||||||
|
#[serde(default, alias = "publicIds")]
|
||||||
|
public_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/workspace/assets", get(list_admin_assets))
|
.route("/api/workspace/assets", get(list_admin_assets))
|
||||||
|
.route(
|
||||||
|
"/api/workspace/assets/batch-delete",
|
||||||
|
post(batch_delete_admin_assets),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/workspace/assets/{public_id}",
|
"/api/workspace/assets/{public_id}",
|
||||||
get(get_admin_asset).delete(delete_admin_asset),
|
get(get_admin_asset).delete(delete_admin_asset),
|
||||||
@@ -45,6 +56,10 @@ pub fn routes() -> Router<AppState> {
|
|||||||
get(get_admin_asset_file),
|
get(get_admin_asset_file),
|
||||||
)
|
)
|
||||||
.route("/api/user/workspace/assets", get(list_user_assets))
|
.route("/api/user/workspace/assets", get(list_user_assets))
|
||||||
|
.route(
|
||||||
|
"/api/user/workspace/assets/batch-delete",
|
||||||
|
post(batch_delete_user_assets),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/user/workspace/assets/{public_id}",
|
"/api/user/workspace/assets/{public_id}",
|
||||||
get(get_user_asset).delete(delete_user_asset),
|
get(get_user_asset).delete(delete_user_asset),
|
||||||
@@ -76,7 +91,8 @@ fn resolve_caller(
|
|||||||
let token = bearer(headers)
|
let token = bearer(headers)
|
||||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||||
|
|
||||||
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
|
let proxy_token = state.runtime_proxy_token();
|
||||||
|
if token == state.config.auth.auth_token || token == proxy_token {
|
||||||
return Ok(WorkspaceCaller::Admin);
|
return Ok(WorkspaceCaller::Admin);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +173,20 @@ fn row_to_json(row: sqlx::sqlite::SqliteRow) -> Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WORKSPACE_OUTPUT_FILTER_SQL: &str = r#"
|
||||||
|
AND status = 'completed'
|
||||||
|
AND (
|
||||||
|
json_extract(metadata, '$.workspaceAsset.role') = 'output'
|
||||||
|
OR json_extract(metadata, '$.workflowUi.type') = 'output'
|
||||||
|
OR (
|
||||||
|
source IN ('picture-agent', 'workflow-studio')
|
||||||
|
AND json_type(metadata, '$.responseItem') IS NOT NULL
|
||||||
|
)
|
||||||
|
OR json_type(metadata, '$.moneyPrinter') IS NOT NULL
|
||||||
|
OR json_type(metadata, '$.digitalHuman') IS NOT NULL
|
||||||
|
)
|
||||||
|
"#;
|
||||||
|
|
||||||
async fn list_assets(
|
async fn list_assets(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
caller: WorkspaceCaller,
|
caller: WorkspaceCaller,
|
||||||
@@ -167,12 +197,14 @@ async fn list_assets(
|
|||||||
WorkspaceCaller::Admin => requested_user_id,
|
WorkspaceCaller::Admin => requested_user_id,
|
||||||
WorkspaceCaller::User { user_id } => Some(user_id),
|
WorkspaceCaller::User { user_id } => Some(user_id),
|
||||||
};
|
};
|
||||||
|
let is_admin = matches!(caller, WorkspaceCaller::Admin);
|
||||||
|
|
||||||
let items = sqlx::query(
|
let list_sql = format!(
|
||||||
"SELECT *
|
"SELECT *
|
||||||
FROM workspace_assets
|
FROM workspace_assets
|
||||||
WHERE deleted_at IS NULL
|
WHERE deleted_at IS NULL
|
||||||
AND (? IS NULL OR user_id = ?)
|
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
|
||||||
|
{WORKSPACE_OUTPUT_FILTER_SQL}
|
||||||
AND (? IS NULL OR source = ?)
|
AND (? IS NULL OR source = ?)
|
||||||
AND (? IS NULL OR kind = ?)
|
AND (? IS NULL OR kind = ?)
|
||||||
AND (
|
AND (
|
||||||
@@ -183,10 +215,14 @@ async fn list_assets(
|
|||||||
OR source LIKE ?
|
OR source LIKE ?
|
||||||
)
|
)
|
||||||
ORDER BY datetime(created_at) DESC, id DESC
|
ORDER BY datetime(created_at) DESC, id DESC
|
||||||
LIMIT ? OFFSET ?",
|
LIMIT ? OFFSET ?"
|
||||||
)
|
);
|
||||||
|
let items = sqlx::query(&list_sql)
|
||||||
|
.bind(is_admin)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
|
.bind(is_admin)
|
||||||
|
.bind(scoped_user_id)
|
||||||
.bind(&source)
|
.bind(&source)
|
||||||
.bind(&source)
|
.bind(&source)
|
||||||
.bind(&kind)
|
.bind(&kind)
|
||||||
@@ -202,11 +238,12 @@ async fn list_assets(
|
|||||||
.fetch_all(&*state.db)
|
.fetch_all(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let total: i64 = sqlx::query_scalar(
|
let count_sql = format!(
|
||||||
"SELECT COUNT(*)
|
"SELECT COUNT(*)
|
||||||
FROM workspace_assets
|
FROM workspace_assets
|
||||||
WHERE deleted_at IS NULL
|
WHERE deleted_at IS NULL
|
||||||
AND (? IS NULL OR user_id = ?)
|
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
|
||||||
|
{WORKSPACE_OUTPUT_FILTER_SQL}
|
||||||
AND (? IS NULL OR source = ?)
|
AND (? IS NULL OR source = ?)
|
||||||
AND (? IS NULL OR kind = ?)
|
AND (? IS NULL OR kind = ?)
|
||||||
AND (
|
AND (
|
||||||
@@ -215,10 +252,14 @@ async fn list_assets(
|
|||||||
OR prompt LIKE ?
|
OR prompt LIKE ?
|
||||||
OR model LIKE ?
|
OR model LIKE ?
|
||||||
OR source LIKE ?
|
OR source LIKE ?
|
||||||
)",
|
)"
|
||||||
)
|
);
|
||||||
|
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||||
|
.bind(is_admin)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
|
.bind(is_admin)
|
||||||
|
.bind(scoped_user_id)
|
||||||
.bind(&source)
|
.bind(&source)
|
||||||
.bind(&source)
|
.bind(&source)
|
||||||
.bind(&kind)
|
.bind(&kind)
|
||||||
@@ -249,17 +290,21 @@ async fn get_asset_row(
|
|||||||
WorkspaceCaller::Admin => None,
|
WorkspaceCaller::Admin => None,
|
||||||
WorkspaceCaller::User { user_id } => Some(user_id),
|
WorkspaceCaller::User { user_id } => Some(user_id),
|
||||||
};
|
};
|
||||||
|
let is_admin = matches!(caller, WorkspaceCaller::Admin);
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT *
|
"SELECT *
|
||||||
FROM workspace_assets
|
FROM workspace_assets
|
||||||
WHERE public_id = ?
|
WHERE public_id = ?
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
AND (? IS NULL OR user_id = ?)
|
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
|
||||||
LIMIT 1",
|
LIMIT 1",
|
||||||
)
|
)
|
||||||
.bind(public_id)
|
.bind(&public_id)
|
||||||
|
.bind(is_admin)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
|
.bind(is_admin)
|
||||||
|
.bind(scoped_user_id)
|
||||||
.fetch_optional(&*state.db)
|
.fetch_optional(&*state.db)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Workspace asset not found".into()))?;
|
.ok_or_else(|| AppError::NotFound("Workspace asset not found".into()))?;
|
||||||
@@ -286,24 +331,90 @@ async fn delete_asset(
|
|||||||
WorkspaceCaller::Admin => None,
|
WorkspaceCaller::Admin => None,
|
||||||
WorkspaceCaller::User { user_id } => Some(user_id),
|
WorkspaceCaller::User { user_id } => Some(user_id),
|
||||||
};
|
};
|
||||||
|
let is_admin = matches!(caller, WorkspaceCaller::Admin);
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
"UPDATE workspace_assets
|
"UPDATE workspace_assets
|
||||||
SET deleted_at = datetime('now'), updated_at = datetime('now')
|
SET deleted_at = datetime('now'), updated_at = datetime('now')
|
||||||
WHERE public_id = ?
|
WHERE public_id = ?
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
AND (? IS NULL OR user_id = ?)",
|
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))",
|
||||||
)
|
)
|
||||||
.bind(public_id)
|
.bind(&public_id)
|
||||||
|
.bind(is_admin)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
|
.bind(is_admin)
|
||||||
|
.bind(scoped_user_id)
|
||||||
.execute(&*state.db)
|
.execute(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
return Err(AppError::NotFound("Workspace asset not found".into()));
|
return Err(AppError::NotFound("Workspace asset not found".into()));
|
||||||
}
|
}
|
||||||
|
if let Err(err) = crate::services::workspace::project_deleted_asset(&state, &public_id).await {
|
||||||
|
tracing::warn!(%err, %public_id, "failed to update workspace asset delete projection");
|
||||||
|
}
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn delete_assets_batch(
|
||||||
|
state: AppState,
|
||||||
|
caller: WorkspaceCaller,
|
||||||
|
public_ids: Vec<String>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let public_ids = public_ids
|
||||||
|
.into_iter()
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.filter(|value| seen.insert(value.clone()))
|
||||||
|
.take(200)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if public_ids.is_empty() {
|
||||||
|
return Err(AppError::BadRequest("No workspace assets selected".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let scoped_user_id = match caller {
|
||||||
|
WorkspaceCaller::Admin => None,
|
||||||
|
WorkspaceCaller::User { user_id } => Some(user_id),
|
||||||
|
};
|
||||||
|
let is_admin = matches!(caller, WorkspaceCaller::Admin);
|
||||||
|
let mut deleted_ids = Vec::new();
|
||||||
|
let mut failed_ids = Vec::new();
|
||||||
|
|
||||||
|
for public_id in public_ids {
|
||||||
|
let result = sqlx::query(
|
||||||
|
"UPDATE workspace_assets
|
||||||
|
SET deleted_at = datetime('now'), updated_at = datetime('now')
|
||||||
|
WHERE public_id = ?
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))",
|
||||||
|
)
|
||||||
|
.bind(&public_id)
|
||||||
|
.bind(is_admin)
|
||||||
|
.bind(scoped_user_id)
|
||||||
|
.bind(scoped_user_id)
|
||||||
|
.bind(is_admin)
|
||||||
|
.bind(scoped_user_id)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await?;
|
||||||
|
if result.rows_affected() > 0 {
|
||||||
|
if let Err(err) = crate::services::workspace::project_deleted_asset(&state, &public_id).await {
|
||||||
|
tracing::warn!(%err, %public_id, "failed to update workspace asset batch delete projection");
|
||||||
|
}
|
||||||
|
deleted_ids.push(public_id);
|
||||||
|
} else {
|
||||||
|
failed_ids.push(public_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"deletedCount": deleted_ids.len(),
|
||||||
|
"deletedIds": deleted_ids,
|
||||||
|
"failedIds": failed_ids,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_asset_file(
|
async fn get_asset_file(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
caller: WorkspaceCaller,
|
caller: WorkspaceCaller,
|
||||||
@@ -318,7 +429,11 @@ async fn get_asset_file(
|
|||||||
.filter(|value| !value.trim().is_empty())
|
.filter(|value| !value.trim().is_empty())
|
||||||
.ok_or_else(|| AppError::NotFound("Workspace asset file is not archived locally".into()))?;
|
.ok_or_else(|| AppError::NotFound("Workspace asset file is not archived locally".into()))?;
|
||||||
let path = PathBuf::from(file_path);
|
let path = PathBuf::from(file_path);
|
||||||
let bytes = tokio::fs::read(&path)
|
let file = tokio::fs::File::open(&path)
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError::NotFound("Workspace asset file not found".into()))?;
|
||||||
|
let metadata = file
|
||||||
|
.metadata()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::NotFound("Workspace asset file not found".into()))?;
|
.map_err(|_| AppError::NotFound("Workspace asset file not found".into()))?;
|
||||||
let mime_type = row
|
let mime_type = row
|
||||||
@@ -334,11 +449,13 @@ async fn get_asset_file(
|
|||||||
Response::builder()
|
Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, mime_type)
|
.header(header::CONTENT_TYPE, mime_type)
|
||||||
|
.header(header::CONTENT_LENGTH, metadata.len().to_string())
|
||||||
|
.header(header::ACCEPT_RANGES, "bytes")
|
||||||
.header(
|
.header(
|
||||||
header::CONTENT_DISPOSITION,
|
header::CONTENT_DISPOSITION,
|
||||||
format!("inline; filename=\"{}\"", file_name.replace('"', "")),
|
format!("inline; filename=\"{}\"", file_name.replace('"', "")),
|
||||||
)
|
)
|
||||||
.body(Body::from(bytes))
|
.body(Body::from_stream(ReaderStream::new(file)))
|
||||||
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))
|
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,6 +495,15 @@ async fn delete_admin_asset(
|
|||||||
delete_asset(state, caller, public_id).await
|
delete_asset(state, caller, public_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn batch_delete_admin_assets(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(payload): Json<BatchDeleteAssetsRequest>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let caller = resolve_caller(&headers, &state, true)?;
|
||||||
|
delete_assets_batch(state, caller, payload.public_ids).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_user_assets(
|
async fn list_user_assets(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -413,3 +539,12 @@ async fn delete_user_asset(
|
|||||||
let caller = resolve_caller(&headers, &state, false)?;
|
let caller = resolve_caller(&headers, &state, false)?;
|
||||||
delete_asset(state, caller, public_id).await
|
delete_asset(state, caller, public_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn batch_delete_user_assets(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(payload): Json<BatchDeleteAssetsRequest>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let caller = resolve_caller(&headers, &state, false)?;
|
||||||
|
delete_assets_batch(state, caller, payload.public_ids).await
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
export function parseBatchApiKeys(input) {
|
|
||||||
if (Array.isArray(input)) {
|
|
||||||
return Array.from(new Set(input
|
|
||||||
.map((item) => String(item || '').trim())
|
|
||||||
.filter((item) => item.length > 0)));
|
|
||||||
}
|
|
||||||
const raw = String(input || '').trim();
|
|
||||||
if (!raw)
|
|
||||||
return [];
|
|
||||||
return Array.from(new Set(raw
|
|
||||||
.split(/[\r\n,,;;\s]+/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter((item) => item.length > 0)));
|
|
||||||
}
|
|
||||||
export function buildBatchApiKeyConnectionName(baseName, index, total) {
|
|
||||||
const normalized = String(baseName || '').trim();
|
|
||||||
if (!normalized)
|
|
||||||
return '';
|
|
||||||
if (total <= 1)
|
|
||||||
return normalized;
|
|
||||||
return `${normalized} #${index + 1}`;
|
|
||||||
}
|
|
||||||
@@ -1,779 +0,0 @@
|
|||||||
const BRAND_DEFINITIONS = [
|
|
||||||
{
|
|
||||||
name: 'OpenAI',
|
|
||||||
icon: 'openai',
|
|
||||||
color: 'linear-gradient(135deg, #10a37f, #1a7f5a)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'gpt', mode: 'startsWith' },
|
|
||||||
{ keyword: 'chatgpt', mode: 'startsWith' },
|
|
||||||
{ keyword: 'dall-e', mode: 'startsWith' },
|
|
||||||
{ keyword: 'whisper', mode: 'startsWith' },
|
|
||||||
{ keyword: 'text-embedding', mode: 'startsWith' },
|
|
||||||
{ keyword: 'text-moderation', mode: 'startsWith' },
|
|
||||||
{ keyword: 'davinci', mode: 'startsWith' },
|
|
||||||
{ keyword: 'babbage', mode: 'startsWith' },
|
|
||||||
{ keyword: 'codex-mini', mode: 'startsWith' },
|
|
||||||
{ keyword: 'o1', mode: 'startsWith' },
|
|
||||||
{ keyword: 'o3', mode: 'startsWith' },
|
|
||||||
{ keyword: 'o4', mode: 'startsWith' },
|
|
||||||
{ keyword: 'tts', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Anthropic',
|
|
||||||
icon: 'claude-color',
|
|
||||||
color: 'linear-gradient(135deg, #d4a574, #c4956a)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'claude', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Google',
|
|
||||||
icon: 'gemini-color',
|
|
||||||
color: 'linear-gradient(135deg, #4285f4, #34a853)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'gemini', mode: 'includes' },
|
|
||||||
{ keyword: 'gemma', mode: 'includes' },
|
|
||||||
{ keyword: 'google/', mode: 'includes' },
|
|
||||||
{ keyword: 'palm', mode: 'includes' },
|
|
||||||
{ keyword: 'paligemma', mode: 'includes' },
|
|
||||||
{ keyword: 'shieldgemma', mode: 'includes' },
|
|
||||||
{ keyword: 'recurrentgemma', mode: 'includes' },
|
|
||||||
{ keyword: 'deplot', mode: 'includes' },
|
|
||||||
{ keyword: 'codegemma', mode: 'includes' },
|
|
||||||
{ keyword: 'imagen', mode: 'includes' },
|
|
||||||
{ keyword: 'learnlm', mode: 'includes' },
|
|
||||||
{ keyword: 'aqa', mode: 'includes' },
|
|
||||||
{ keyword: 'veo', mode: 'startsWith' },
|
|
||||||
{ keyword: 'google/', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'DeepSeek',
|
|
||||||
icon: 'deepseek-color',
|
|
||||||
color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'deepseek', mode: 'includes' },
|
|
||||||
{ keyword: 'ds-chat', mode: 'segment' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '通义千问',
|
|
||||||
icon: 'qwen-color',
|
|
||||||
color: 'linear-gradient(135deg, #615cf7, #9b8afb)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'qwen', mode: 'includes' },
|
|
||||||
{ keyword: 'qwq', mode: 'includes' },
|
|
||||||
{ keyword: 'tongyi', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '智谱 AI',
|
|
||||||
icon: 'zhipu-color',
|
|
||||||
color: 'linear-gradient(135deg, #3b6cf5, #6366f1)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'glm', mode: 'includes' },
|
|
||||||
{ keyword: 'chatglm', mode: 'includes' },
|
|
||||||
{ keyword: 'codegeex', mode: 'includes' },
|
|
||||||
{ keyword: 'cogview', mode: 'includes' },
|
|
||||||
{ keyword: 'cogvideo', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Meta',
|
|
||||||
icon: 'meta-color',
|
|
||||||
color: 'linear-gradient(135deg, #0668E1, #1877f2)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'llama', mode: 'includes' },
|
|
||||||
{ keyword: 'code-llama', mode: 'includes' },
|
|
||||||
{ keyword: 'codellama', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Mistral',
|
|
||||||
icon: 'mistral-color',
|
|
||||||
color: 'linear-gradient(135deg, #f7d046, #f2a900)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'mistral', mode: 'includes' },
|
|
||||||
{ keyword: 'mixtral', mode: 'includes' },
|
|
||||||
{ keyword: 'codestral', mode: 'includes' },
|
|
||||||
{ keyword: 'pixtral', mode: 'includes' },
|
|
||||||
{ keyword: 'ministral', mode: 'includes' },
|
|
||||||
{ keyword: 'voxtral', mode: 'includes' },
|
|
||||||
{ keyword: 'magistral', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Moonshot',
|
|
||||||
icon: 'moonshot',
|
|
||||||
color: 'linear-gradient(135deg, #000000, #333333)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'moonshot', mode: 'includes' },
|
|
||||||
{ keyword: 'kimi', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '零一万物',
|
|
||||||
icon: 'yi-color',
|
|
||||||
color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'yi-', mode: 'startsWith' },
|
|
||||||
{ keyword: 'yi', mode: 'boundary' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '文心一言',
|
|
||||||
icon: 'wenxin-color',
|
|
||||||
color: 'linear-gradient(135deg, #2932e1, #4468f2)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'ernie', mode: 'includes' },
|
|
||||||
{ keyword: 'eb-', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '讯飞星火',
|
|
||||||
icon: 'spark-color',
|
|
||||||
color: 'linear-gradient(135deg, #0070f3, #00d4ff)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'spark', mode: 'includes' },
|
|
||||||
{ keyword: 'generalv', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '腾讯混元',
|
|
||||||
icon: 'hunyuan-color',
|
|
||||||
color: 'linear-gradient(135deg, #00b7ff, #0052d9)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'hunyuan', mode: 'includes' },
|
|
||||||
{ keyword: 'tencent-hunyuan', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '豆包',
|
|
||||||
icon: 'doubao-color',
|
|
||||||
color: 'linear-gradient(135deg, #3b5bdb, #7048e8)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'doubao', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'MiniMax',
|
|
||||||
icon: 'minimax-color',
|
|
||||||
color: 'linear-gradient(135deg, #6366f1, #818cf8)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'minimax', mode: 'includes' },
|
|
||||||
{ keyword: 'abab', mode: 'includes' },
|
|
||||||
{ keyword: 'mini2.1', mode: 'segment' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cohere',
|
|
||||||
icon: 'cohere-color',
|
|
||||||
color: 'linear-gradient(135deg, #39594d, #5ba77f)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'command', mode: 'includes' },
|
|
||||||
{ keyword: 'c4ai-', mode: 'includes' },
|
|
||||||
{ keyword: 'aya', mode: 'includes' },
|
|
||||||
{ keyword: 'embed-', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Microsoft',
|
|
||||||
icon: 'microsoft-color',
|
|
||||||
color: 'linear-gradient(135deg, #00bcf2, #0078d4)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'microsoft/', mode: 'includes' },
|
|
||||||
{ keyword: 'phi-', mode: 'includes' },
|
|
||||||
{ keyword: 'kosmos', mode: 'includes' },
|
|
||||||
{ keyword: 'phi4', mode: 'segment' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'xAI',
|
|
||||||
icon: 'xai',
|
|
||||||
color: 'linear-gradient(135deg, #111, #444)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'grok', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '阶跃星辰',
|
|
||||||
icon: 'stepfun-color',
|
|
||||||
color: 'linear-gradient(135deg, #0066ff, #3399ff)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'stepfun', mode: 'includes' },
|
|
||||||
{ keyword: 'step-', mode: 'startsWith' },
|
|
||||||
{ keyword: 'step3', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '百川智能',
|
|
||||||
icon: 'baichuan-color',
|
|
||||||
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'baichuan', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AI21 Labs',
|
|
||||||
icon: 'ai21-brand-color',
|
|
||||||
color: 'linear-gradient(135deg, #7c3aed, #a855f7)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'ai21', mode: 'includes' },
|
|
||||||
{ keyword: 'jamba', mode: 'startsWith' },
|
|
||||||
{ keyword: 'jamba', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AI2',
|
|
||||||
icon: 'ai2-color',
|
|
||||||
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'allenai', mode: 'includes' },
|
|
||||||
{ keyword: 'olmo', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Amazon Nova',
|
|
||||||
icon: 'nova',
|
|
||||||
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'amazon/nova', mode: 'startsWith' },
|
|
||||||
{ keyword: 'amazon.nova', mode: 'includes' },
|
|
||||||
{ keyword: 'us.amazon.nova', mode: 'includes' },
|
|
||||||
{ keyword: 'nova-', mode: 'startsWith' },
|
|
||||||
{ keyword: 'nova-lite', mode: 'startsWith' },
|
|
||||||
{ keyword: 'nova-pro', mode: 'startsWith' },
|
|
||||||
{ keyword: 'nova-micro', mode: 'startsWith' },
|
|
||||||
{ keyword: 'nova-canvas', mode: 'startsWith' },
|
|
||||||
{ keyword: 'nova-reel', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Stability',
|
|
||||||
icon: 'stability-color',
|
|
||||||
color: 'linear-gradient(135deg, #8b5cf6, #a855f7)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'flux', mode: 'includes' },
|
|
||||||
{ keyword: 'stablediffusion', mode: 'includes' },
|
|
||||||
{ keyword: 'stable-diffusion', mode: 'includes' },
|
|
||||||
{ keyword: 'sdxl', mode: 'includes' },
|
|
||||||
{ keyword: 'sd3', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'NVIDIA',
|
|
||||||
icon: 'nvidia-color',
|
|
||||||
color: 'linear-gradient(135deg, #76b900, #4a8c0b)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'nvidia/', mode: 'includes' },
|
|
||||||
{ keyword: 'nvclip', mode: 'includes' },
|
|
||||||
{ keyword: 'nemotron', mode: 'includes' },
|
|
||||||
{ keyword: 'nemoretriever', mode: 'includes' },
|
|
||||||
{ keyword: 'neva', mode: 'includes' },
|
|
||||||
{ keyword: 'riva-translate', mode: 'includes' },
|
|
||||||
{ keyword: 'cosmos', mode: 'includes' },
|
|
||||||
{ keyword: 'nv-', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'IBM',
|
|
||||||
icon: 'ibm',
|
|
||||||
color: 'linear-gradient(135deg, #0f62fe, #4589ff)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'ibm/', mode: 'includes' },
|
|
||||||
{ keyword: 'granite', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'BAAI',
|
|
||||||
icon: 'baai',
|
|
||||||
color: 'linear-gradient(135deg, #111827, #374151)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'baai/', mode: 'includes' },
|
|
||||||
{ keyword: 'bge-', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ByteDance',
|
|
||||||
icon: 'bytedance-color',
|
|
||||||
color: 'linear-gradient(135deg, #325ab4, #0f66ff)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'bytedance', mode: 'includes' },
|
|
||||||
{ keyword: 'seed-oss', mode: 'includes' },
|
|
||||||
{ keyword: 'kolors', mode: 'includes' },
|
|
||||||
{ keyword: 'kwai', mode: 'includes' },
|
|
||||||
{ keyword: 'kwaipilot', mode: 'includes' },
|
|
||||||
{ keyword: 'wan-', mode: 'startsWith' },
|
|
||||||
{ keyword: 'kat-', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'InternLM',
|
|
||||||
icon: 'internlm-color',
|
|
||||||
color: 'linear-gradient(135deg, #1b3882, #4063c5)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'internlm', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Midjourney',
|
|
||||||
icon: 'midjourney',
|
|
||||||
color: 'linear-gradient(135deg, #4c6ef5, #748ffc)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'midjourney', mode: 'includes' },
|
|
||||||
{ keyword: 'mj_', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'DeepL',
|
|
||||||
icon: 'deepl-color',
|
|
||||||
color: 'linear-gradient(135deg, #0f2b46, #21476f)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'deepl-', mode: 'startsWith' },
|
|
||||||
{ keyword: 'deepl/', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Jina AI',
|
|
||||||
icon: 'jina',
|
|
||||||
color: 'linear-gradient(135deg, #111827, #4b5563)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'jina', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Relace',
|
|
||||||
icon: 'relace',
|
|
||||||
color: 'linear-gradient(135deg, #7c3aed, #6366f1)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'relace', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Arcee',
|
|
||||||
icon: 'arcee-color',
|
|
||||||
color: 'linear-gradient(135deg, #2563eb, #60a5fa)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'arcee-ai', mode: 'includes' },
|
|
||||||
{ keyword: 'arcee', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AionLabs',
|
|
||||||
icon: 'aionlabs-color',
|
|
||||||
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'aion-labs', mode: 'includes' },
|
|
||||||
{ keyword: 'aionlabs', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'DeepCogito',
|
|
||||||
icon: 'deepcogito-color',
|
|
||||||
color: 'linear-gradient(135deg, #2563eb, #7c3aed)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'deepcogito', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Essential AI',
|
|
||||||
icon: 'essentialai-color',
|
|
||||||
color: 'linear-gradient(135deg, #0f172a, #334155)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'essentialai', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Inception',
|
|
||||||
icon: 'inception',
|
|
||||||
color: 'linear-gradient(135deg, #7c3aed, #ec4899)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'inception', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Inflection',
|
|
||||||
icon: 'inflection',
|
|
||||||
color: 'linear-gradient(135deg, #1d4ed8, #2563eb)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'inflection', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Liquid AI',
|
|
||||||
icon: 'liquid',
|
|
||||||
color: 'linear-gradient(135deg, #0f172a, #475569)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'liquid', mode: 'includes' },
|
|
||||||
{ keyword: 'lfm-', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'LongCat',
|
|
||||||
icon: 'longcat-color',
|
|
||||||
color: 'linear-gradient(135deg, #f97316, #fb7185)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'longcat', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Morph',
|
|
||||||
icon: 'morph-color',
|
|
||||||
color: 'linear-gradient(135deg, #4f46e5, #8b5cf6)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'morph/', mode: 'includes' },
|
|
||||||
{ keyword: 'morph-', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Nous Research',
|
|
||||||
icon: 'nousresearch',
|
|
||||||
color: 'linear-gradient(135deg, #111827, #4b5563)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'nousresearch', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Upstage',
|
|
||||||
icon: 'upstage-color',
|
|
||||||
color: 'linear-gradient(135deg, #2563eb, #14b8a6)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'upstage', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Xiaomi MiMo',
|
|
||||||
icon: 'xiaomimimo',
|
|
||||||
color: 'linear-gradient(135deg, #f97316, #fb923c)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'xiaomi/mimo', mode: 'includes' },
|
|
||||||
{ keyword: 'xiaomimimo', mode: 'includes' },
|
|
||||||
{ keyword: 'mimo-v', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Z.ai',
|
|
||||||
icon: 'zai',
|
|
||||||
color: 'linear-gradient(135deg, #0f172a, #2563eb)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: '2zai', mode: 'startsWith' },
|
|
||||||
{ keyword: 'z-ai', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'SenseNova',
|
|
||||||
icon: 'sensenova-brand-color',
|
|
||||||
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'sensenova', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Perplexity',
|
|
||||||
icon: 'perplexity-color',
|
|
||||||
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'perplexity', mode: 'includes' },
|
|
||||||
{ keyword: 'pplx-', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'OpenRouter',
|
|
||||||
icon: 'openrouter',
|
|
||||||
color: 'linear-gradient(135deg, #7c3aed, #2563eb)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'openrouter', mode: 'includes' },
|
|
||||||
{ keyword: 'openrouter-', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Groq',
|
|
||||||
icon: 'groq',
|
|
||||||
color: 'linear-gradient(135deg, #111827, #374151)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'groq', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Fireworks',
|
|
||||||
icon: 'fireworks-color',
|
|
||||||
color: 'linear-gradient(135deg, #fb7185, #f97316)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'fireworks-ai', mode: 'includes' },
|
|
||||||
{ keyword: 'fireworks', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'DeepInfra',
|
|
||||||
icon: 'deepinfra-color',
|
|
||||||
color: 'linear-gradient(135deg, #4f46e5, #7c3aed)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'deepinfra', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Together AI',
|
|
||||||
icon: 'together-brand-color',
|
|
||||||
color: 'linear-gradient(135deg, #7c3aed, #ec4899)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'together.ai', mode: 'includes' },
|
|
||||||
{ keyword: 'together', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Replicate',
|
|
||||||
icon: 'replicate-brand',
|
|
||||||
color: 'linear-gradient(135deg, #111827, #6366f1)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'replicate', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'SambaNova',
|
|
||||||
icon: 'sambanova-color',
|
|
||||||
color: 'linear-gradient(135deg, #2563eb, #06b6d4)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'sambanova', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cerebras',
|
|
||||||
icon: 'cerebras-brand-color',
|
|
||||||
color: 'linear-gradient(135deg, #0f766e, #65a30d)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'cerebras', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Ollama',
|
|
||||||
icon: 'ollama',
|
|
||||||
color: 'linear-gradient(135deg, #1f2937, #4b5563)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'ollama', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ModelScope',
|
|
||||||
icon: 'modelscope-color',
|
|
||||||
color: 'linear-gradient(135deg, #2563eb, #60a5fa)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'modelscope', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'SiliconCloud',
|
|
||||||
icon: 'siliconcloud-color',
|
|
||||||
color: 'linear-gradient(135deg, #0ea5e9, #22d3ee)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'siliconcloud', mode: 'includes' },
|
|
||||||
{ keyword: 'siliconflow', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Azure AI',
|
|
||||||
icon: 'azureai-color',
|
|
||||||
color: 'linear-gradient(135deg, #0284c7, #2563eb)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'azureai', mode: 'includes' },
|
|
||||||
{ keyword: 'azure-openai', mode: 'includes' },
|
|
||||||
{ keyword: 'azure/openai', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AWS Bedrock',
|
|
||||||
icon: 'bedrock-color',
|
|
||||||
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'bedrock', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Vertex AI',
|
|
||||||
icon: 'vertexai-color',
|
|
||||||
color: 'linear-gradient(135deg, #2563eb, #6366f1)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'vertexai', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Google Cloud',
|
|
||||||
icon: 'googlecloud-brand-color',
|
|
||||||
color: 'linear-gradient(135deg, #4285f4, #34a853)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'googlecloud', mode: 'includes' },
|
|
||||||
{ keyword: 'google-cloud', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '百度智能云',
|
|
||||||
icon: 'baiducloud-color',
|
|
||||||
color: 'linear-gradient(135deg, #2563eb, #38bdf8)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'baiducloud', mode: 'includes' },
|
|
||||||
{ keyword: 'qianfan', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '百炼',
|
|
||||||
icon: 'bailian-color',
|
|
||||||
color: 'linear-gradient(135deg, #7c3aed, #2563eb)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'bailian', mode: 'includes' },
|
|
||||||
{ keyword: 'dashscope', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '阿里云',
|
|
||||||
icon: 'alibabacloud-color',
|
|
||||||
color: 'linear-gradient(135deg, #f97316, #fb923c)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'alibabacloud', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '火山引擎',
|
|
||||||
icon: 'volcengine-color',
|
|
||||||
color: 'linear-gradient(135deg, #325ab4, #0f66ff)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'volcengine', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '七牛云',
|
|
||||||
icon: 'qiniu-color',
|
|
||||||
color: 'linear-gradient(135deg, #06b6d4, #0891b2)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'qiniu', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
function normalizeInput(value) {
|
|
||||||
return String(value || '').trim().toLowerCase();
|
|
||||||
}
|
|
||||||
export function stripCommonWrappers(value) {
|
|
||||||
return value
|
|
||||||
.replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '')
|
|
||||||
.replace(/^re:\s*/g, '')
|
|
||||||
.replace(/^\^+/, '')
|
|
||||||
.replace(/\$+$/, '')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
export function collectBrandCandidates(modelName) {
|
|
||||||
const queue = [];
|
|
||||||
const seen = new Set();
|
|
||||||
const push = (value) => {
|
|
||||||
const normalized = normalizeInput(value);
|
|
||||||
if (!normalized || seen.has(normalized))
|
|
||||||
return;
|
|
||||||
seen.add(normalized);
|
|
||||||
queue.push(normalized);
|
|
||||||
};
|
|
||||||
push(modelName);
|
|
||||||
for (let index = 0; index < queue.length; index += 1) {
|
|
||||||
const candidate = queue[index];
|
|
||||||
const cleaned = stripCommonWrappers(candidate);
|
|
||||||
push(cleaned);
|
|
||||||
if (cleaned.includes('/')) {
|
|
||||||
for (const part of cleaned.split('/'))
|
|
||||||
push(part);
|
|
||||||
}
|
|
||||||
if (cleaned.includes(':')) {
|
|
||||||
for (const part of cleaned.split(':'))
|
|
||||||
push(part);
|
|
||||||
}
|
|
||||||
if (cleaned.includes(',')) {
|
|
||||||
for (const part of cleaned.split(','))
|
|
||||||
push(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return queue;
|
|
||||||
}
|
|
||||||
function buildMatchContext(modelName) {
|
|
||||||
const candidates = collectBrandCandidates(modelName);
|
|
||||||
const raw = candidates[0] || normalizeInput(modelName);
|
|
||||||
const cleaned = stripCommonWrappers(raw);
|
|
||||||
const segments = Array.from(new Set(candidates
|
|
||||||
.flatMap((candidate) => candidate.split(/[/:,\s]+/g))
|
|
||||||
.map((segment) => segment.trim())
|
|
||||||
.filter(Boolean)));
|
|
||||||
return {
|
|
||||||
raw,
|
|
||||||
cleaned,
|
|
||||||
segments,
|
|
||||||
candidates,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function matchesRule(context, rule) {
|
|
||||||
switch (rule.mode) {
|
|
||||||
case 'includes':
|
|
||||||
return context.raw.includes(rule.keyword)
|
|
||||||
|| context.cleaned.includes(rule.keyword)
|
|
||||||
|| context.candidates.some((candidate) => candidate.includes(rule.keyword));
|
|
||||||
case 'startsWith':
|
|
||||||
return context.raw.startsWith(rule.keyword)
|
|
||||||
|| context.cleaned.startsWith(rule.keyword)
|
|
||||||
|| context.segments.some((segment) => segment.startsWith(rule.keyword))
|
|
||||||
|| context.candidates.some((candidate) => candidate.startsWith(rule.keyword));
|
|
||||||
case 'segment':
|
|
||||||
return context.segments.includes(rule.keyword);
|
|
||||||
case 'boundary': {
|
|
||||||
const pattern = new RegExp(`(^|[/:_\\-\\s])${escapeRegExp(rule.keyword)}(?=$|[/:_\\-\\s])`);
|
|
||||||
return pattern.test(context.raw)
|
|
||||||
|| pattern.test(context.cleaned)
|
|
||||||
|| context.candidates.some((candidate) => pattern.test(candidate));
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function escapeRegExp(value) {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
const BRAND_FALLBACK_BOUNDARY_RULES = BRAND_DEFINITIONS.map((brand) => ({
|
|
||||||
brand,
|
|
||||||
boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(brand.name.toLowerCase())}(?=$|[^a-z0-9])`),
|
|
||||||
}));
|
|
||||||
export function getAllBrands() {
|
|
||||||
return BRAND_DEFINITIONS.map(({ name, icon, color }) => ({ name, icon, color }));
|
|
||||||
}
|
|
||||||
export function getAllBrandNames() {
|
|
||||||
return BRAND_DEFINITIONS.map((brand) => brand.name);
|
|
||||||
}
|
|
||||||
function toBrandInfo(brand) {
|
|
||||||
return {
|
|
||||||
name: brand.name,
|
|
||||||
icon: brand.icon,
|
|
||||||
color: brand.color,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
export function getMatchingBrands(modelName) {
|
|
||||||
const context = buildMatchContext(modelName);
|
|
||||||
const matches = [];
|
|
||||||
const seen = new Set();
|
|
||||||
const add = (brand) => {
|
|
||||||
if (seen.has(brand.name))
|
|
||||||
return;
|
|
||||||
seen.add(brand.name);
|
|
||||||
matches.push(toBrandInfo(brand));
|
|
||||||
};
|
|
||||||
for (const definition of BRAND_DEFINITIONS) {
|
|
||||||
if (definition.rules.some((rule) => matchesRule(context, rule))) {
|
|
||||||
add(definition);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const candidate of context.candidates) {
|
|
||||||
for (const rule of BRAND_FALLBACK_BOUNDARY_RULES) {
|
|
||||||
if (rule.boundaryRegex.test(candidate)) {
|
|
||||||
add(rule.brand);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
export function getMatchingBrandNames(modelName) {
|
|
||||||
return getMatchingBrands(modelName).map((brand) => brand.name);
|
|
||||||
}
|
|
||||||
export function getBrand(modelName) {
|
|
||||||
return getMatchingBrands(modelName)[0] || null;
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
|
|
||||||
function normalizeString(value) {
|
|
||||||
return String(value || '').trim();
|
|
||||||
}
|
|
||||||
function normalizeDigest(value) {
|
|
||||||
const digest = normalizeString(value);
|
|
||||||
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : '';
|
|
||||||
}
|
|
||||||
function normalizeStableTag(value) {
|
|
||||||
return normalizeStableVersion(value);
|
|
||||||
}
|
|
||||||
function hasSameImageTag(left, right) {
|
|
||||||
const rawLeft = normalizeString(left);
|
|
||||||
const rawRight = normalizeString(right);
|
|
||||||
if (!rawLeft || !rawRight)
|
|
||||||
return false;
|
|
||||||
const stableLeft = normalizeStableTag(left);
|
|
||||||
const stableRight = normalizeStableTag(right);
|
|
||||||
if (stableLeft && stableRight) {
|
|
||||||
return stableLeft === stableRight;
|
|
||||||
}
|
|
||||||
return rawLeft === rawRight;
|
|
||||||
}
|
|
||||||
export function normalizeStableVersion(value) {
|
|
||||||
const raw = normalizeString(value);
|
|
||||||
if (!raw)
|
|
||||||
return '';
|
|
||||||
const match = raw.match(STABLE_SEMVER_PATTERN);
|
|
||||||
if (!match)
|
|
||||||
return '';
|
|
||||||
return [
|
|
||||||
Number.parseInt(match[1], 10),
|
|
||||||
Number.parseInt(match[2], 10),
|
|
||||||
Number.parseInt(match[3], 10),
|
|
||||||
].join('.');
|
|
||||||
}
|
|
||||||
export function compareStableVersions(left, right) {
|
|
||||||
const normalizedLeft = normalizeStableVersion(left);
|
|
||||||
const normalizedRight = normalizeStableVersion(right);
|
|
||||||
if (!normalizedLeft || !normalizedRight)
|
|
||||||
return null;
|
|
||||||
const leftParts = normalizedLeft.split('.').map((item) => Number.parseInt(item, 10));
|
|
||||||
const rightParts = normalizedRight.split('.').map((item) => Number.parseInt(item, 10));
|
|
||||||
for (let index = 0; index < 3; index += 1) {
|
|
||||||
if (leftParts[index] === rightParts[index])
|
|
||||||
continue;
|
|
||||||
return leftParts[index] < rightParts[index] ? -1 : 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
export function isSameImageTarget(current, target) {
|
|
||||||
const currentDigest = normalizeDigest(current?.imageDigest);
|
|
||||||
const targetDigest = normalizeDigest(target.digest);
|
|
||||||
if (currentDigest && targetDigest) {
|
|
||||||
return currentDigest === targetDigest;
|
|
||||||
}
|
|
||||||
return hasSameImageTag(current?.imageTag, target.tag);
|
|
||||||
}
|
|
||||||
export function buildUpdateReminderCandidateKey(source, candidate) {
|
|
||||||
const tagName = normalizeString(candidate.tagName);
|
|
||||||
const digest = normalizeDigest(candidate.digest);
|
|
||||||
if (!tagName)
|
|
||||||
return '';
|
|
||||||
if (source === 'docker-hub-tag' && digest) {
|
|
||||||
return `${source}:${tagName}@${digest}`;
|
|
||||||
}
|
|
||||||
return `${source}:${tagName}`;
|
|
||||||
}
|
|
||||||
export function resolveUpdateReminderCandidate(input) {
|
|
||||||
const githubCandidateVersion = normalizeString(input.githubRelease?.normalizedVersion);
|
|
||||||
const githubTag = normalizeString(input.githubRelease?.tagName || githubCandidateVersion);
|
|
||||||
if (githubTag) {
|
|
||||||
const githubTargetVersion = githubCandidateVersion || githubTag;
|
|
||||||
const versionCompare = compareStableVersions(input.currentVersion, githubTargetVersion);
|
|
||||||
const helperVersionCompare = compareStableVersions(input.helper?.imageTag, githubTargetVersion);
|
|
||||||
if (versionCompare === -1 && (helperVersionCompare == null || helperVersionCompare === -1)) {
|
|
||||||
return {
|
|
||||||
source: 'github-release',
|
|
||||||
kind: 'new-version',
|
|
||||||
candidateKey: buildUpdateReminderCandidateKey('github-release', { tagName: githubTag, digest: null }),
|
|
||||||
displayVersion: normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion || githubTag),
|
|
||||||
tagName: githubTag,
|
|
||||||
digest: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const dockerCandidateVersion = normalizeString(input.dockerHubTag?.normalizedVersion);
|
|
||||||
const dockerTag = normalizeString(input.dockerHubTag?.tagName || dockerCandidateVersion);
|
|
||||||
const dockerDigest = normalizeDigest(input.dockerHubTag?.digest);
|
|
||||||
if (!dockerTag)
|
|
||||||
return null;
|
|
||||||
if (isSameImageTarget(input.helper, { tag: dockerTag, digest: dockerDigest })) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const dockerVersionCompare = compareStableVersions(input.currentVersion, dockerCandidateVersion || dockerTag);
|
|
||||||
if (dockerVersionCompare === -1) {
|
|
||||||
return {
|
|
||||||
source: 'docker-hub-tag',
|
|
||||||
kind: 'new-version',
|
|
||||||
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest || null }),
|
|
||||||
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
|
|
||||||
tagName: dockerTag,
|
|
||||||
digest: dockerDigest || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const helperDigest = normalizeDigest(input.helper?.imageDigest);
|
|
||||||
if (dockerDigest && helperDigest && hasSameImageTag(input.helper?.imageTag, dockerTag) && helperDigest !== dockerDigest) {
|
|
||||||
return {
|
|
||||||
source: 'docker-hub-tag',
|
|
||||||
kind: 'new-digest',
|
|
||||||
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest }),
|
|
||||||
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
|
|
||||||
tagName: dockerTag,
|
|
||||||
digest: dockerDigest,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
export type UpdateVersionCandidateLike = {
|
|
||||||
normalizedVersion?: string | null;
|
|
||||||
displayVersion?: string | null;
|
|
||||||
tagName?: string | null;
|
|
||||||
digest?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateHelperRuntimeLike = {
|
|
||||||
imageTag?: string | null;
|
|
||||||
imageDigest?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateReminderCandidate = {
|
|
||||||
source: 'github-release' | 'docker-hub-tag';
|
|
||||||
kind: 'new-version' | 'new-digest';
|
|
||||||
candidateKey: string;
|
|
||||||
displayVersion: string;
|
|
||||||
tagName: string;
|
|
||||||
digest: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
|
|
||||||
|
|
||||||
function normalizeString(value?: string | null): string {
|
|
||||||
return String(value || '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDigest(value?: string | null): string {
|
|
||||||
const digest = normalizeString(value);
|
|
||||||
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeStableTag(value?: string | null): string {
|
|
||||||
return normalizeStableVersion(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasSameImageTag(left?: string | null, right?: string | null): boolean {
|
|
||||||
const rawLeft = normalizeString(left);
|
|
||||||
const rawRight = normalizeString(right);
|
|
||||||
if (!rawLeft || !rawRight) return false;
|
|
||||||
|
|
||||||
const stableLeft = normalizeStableTag(left);
|
|
||||||
const stableRight = normalizeStableTag(right);
|
|
||||||
if (stableLeft && stableRight) {
|
|
||||||
return stableLeft === stableRight;
|
|
||||||
}
|
|
||||||
|
|
||||||
return rawLeft === rawRight;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeStableVersion(value?: string | null): string {
|
|
||||||
const raw = normalizeString(value);
|
|
||||||
if (!raw) return '';
|
|
||||||
const match = raw.match(STABLE_SEMVER_PATTERN);
|
|
||||||
if (!match) return '';
|
|
||||||
return [
|
|
||||||
Number.parseInt(match[1], 10),
|
|
||||||
Number.parseInt(match[2], 10),
|
|
||||||
Number.parseInt(match[3], 10),
|
|
||||||
].join('.');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function compareStableVersions(left?: string | null, right?: string | null): number | null {
|
|
||||||
const normalizedLeft = normalizeStableVersion(left);
|
|
||||||
const normalizedRight = normalizeStableVersion(right);
|
|
||||||
if (!normalizedLeft || !normalizedRight) return null;
|
|
||||||
|
|
||||||
const leftParts = normalizedLeft.split('.').map((item) => Number.parseInt(item, 10));
|
|
||||||
const rightParts = normalizedRight.split('.').map((item) => Number.parseInt(item, 10));
|
|
||||||
for (let index = 0; index < 3; index += 1) {
|
|
||||||
if (leftParts[index] === rightParts[index]) continue;
|
|
||||||
return leftParts[index] < rightParts[index] ? -1 : 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSameImageTarget(
|
|
||||||
current: UpdateHelperRuntimeLike | null | undefined,
|
|
||||||
target: { tag?: string | null; digest?: string | null },
|
|
||||||
): boolean {
|
|
||||||
const currentDigest = normalizeDigest(current?.imageDigest);
|
|
||||||
const targetDigest = normalizeDigest(target.digest);
|
|
||||||
if (currentDigest && targetDigest) {
|
|
||||||
return currentDigest === targetDigest;
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasSameImageTag(current?.imageTag, target.tag);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildUpdateReminderCandidateKey(
|
|
||||||
source: 'github-release' | 'docker-hub-tag',
|
|
||||||
candidate: { tagName?: string | null; digest?: string | null },
|
|
||||||
): string {
|
|
||||||
const tagName = normalizeString(candidate.tagName);
|
|
||||||
const digest = normalizeDigest(candidate.digest);
|
|
||||||
if (!tagName) return '';
|
|
||||||
if (source === 'docker-hub-tag' && digest) {
|
|
||||||
return `${source}:${tagName}@${digest}`;
|
|
||||||
}
|
|
||||||
return `${source}:${tagName}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveUpdateReminderCandidate(input: {
|
|
||||||
currentVersion?: string | null;
|
|
||||||
helper: UpdateHelperRuntimeLike | null | undefined;
|
|
||||||
githubRelease: UpdateVersionCandidateLike | null | undefined;
|
|
||||||
dockerHubTag: UpdateVersionCandidateLike | null | undefined;
|
|
||||||
}): UpdateReminderCandidate | null {
|
|
||||||
const githubCandidateVersion = normalizeString(input.githubRelease?.normalizedVersion);
|
|
||||||
const githubTag = normalizeString(input.githubRelease?.tagName || githubCandidateVersion);
|
|
||||||
if (githubTag) {
|
|
||||||
const githubTargetVersion = githubCandidateVersion || githubTag;
|
|
||||||
const versionCompare = compareStableVersions(input.currentVersion, githubTargetVersion);
|
|
||||||
const helperVersionCompare = compareStableVersions(input.helper?.imageTag, githubTargetVersion);
|
|
||||||
if (versionCompare === -1 && (helperVersionCompare == null || helperVersionCompare === -1)) {
|
|
||||||
return {
|
|
||||||
source: 'github-release',
|
|
||||||
kind: 'new-version',
|
|
||||||
candidateKey: buildUpdateReminderCandidateKey('github-release', { tagName: githubTag, digest: null }),
|
|
||||||
displayVersion: normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion || githubTag),
|
|
||||||
tagName: githubTag,
|
|
||||||
digest: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dockerCandidateVersion = normalizeString(input.dockerHubTag?.normalizedVersion);
|
|
||||||
const dockerTag = normalizeString(input.dockerHubTag?.tagName || dockerCandidateVersion);
|
|
||||||
const dockerDigest = normalizeDigest(input.dockerHubTag?.digest);
|
|
||||||
if (!dockerTag) return null;
|
|
||||||
|
|
||||||
if (isSameImageTarget(input.helper, { tag: dockerTag, digest: dockerDigest })) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dockerVersionCompare = compareStableVersions(input.currentVersion, dockerCandidateVersion || dockerTag);
|
|
||||||
if (dockerVersionCompare === -1) {
|
|
||||||
return {
|
|
||||||
source: 'docker-hub-tag',
|
|
||||||
kind: 'new-version',
|
|
||||||
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest || null }),
|
|
||||||
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
|
|
||||||
tagName: dockerTag,
|
|
||||||
digest: dockerDigest || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const helperDigest = normalizeDigest(input.helper?.imageDigest);
|
|
||||||
if (dockerDigest && helperDigest && hasSameImageTag(input.helper?.imageTag, dockerTag) && helperDigest !== dockerDigest) {
|
|
||||||
return {
|
|
||||||
source: 'docker-hub-tag',
|
|
||||||
kind: 'new-digest',
|
|
||||||
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest }),
|
|
||||||
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
|
|
||||||
tagName: dockerTag,
|
|
||||||
digest: dockerDigest,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
+832
-4
@@ -2,8 +2,10 @@ use crate::{
|
|||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
services::shared::{enforce_key_request_limit, increment_key_usage},
|
services::shared::{enforce_key_request_limit, increment_key_usage},
|
||||||
};
|
};
|
||||||
|
use chrono::Timelike;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{FromRow, SqlitePool};
|
use serde_json::{json, Value};
|
||||||
|
use sqlx::{FromRow, Row, SqlitePool};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -21,6 +23,23 @@ pub struct UsageSummary {
|
|||||||
pub total_cost: f64,
|
pub total_cost: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ComfyTaskBillingEvent {
|
||||||
|
pub key_id: Option<i64>,
|
||||||
|
pub model: String,
|
||||||
|
pub task_type: &'static str,
|
||||||
|
pub client_family: &'static str,
|
||||||
|
pub client_app_id: &'static str,
|
||||||
|
pub client_app_name: &'static str,
|
||||||
|
pub prompt_tokens: i64,
|
||||||
|
pub completion_tokens: i64,
|
||||||
|
pub cost: f64,
|
||||||
|
pub content_summary: Option<String>,
|
||||||
|
pub request_content: Option<String>,
|
||||||
|
pub response_content: Option<String>,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
impl UsageTracker {
|
impl UsageTracker {
|
||||||
pub fn new(db: Arc<SqlitePool>) -> Self {
|
pub fn new(db: Arc<SqlitePool>) -> Self {
|
||||||
Self { db }
|
Self { db }
|
||||||
@@ -108,13 +127,122 @@ impl UsageTracker {
|
|||||||
return Err(AppError::Forbidden("API key is disabled".into()));
|
return Err(AppError::Forbidden("API key is disabled".into()));
|
||||||
}
|
}
|
||||||
if let Some(max_cost) = max_cost {
|
if let Some(max_cost) = max_cost {
|
||||||
if used_cost.unwrap_or(0.0) >= max_cost {
|
if max_cost <= 0.0 || used_cost.unwrap_or(0.0) >= max_cost {
|
||||||
return Err(AppError::RateLimited);
|
return Err(AppError::RateLimitedWithRetry {
|
||||||
|
message: "下游密钥成本额度已用完".into(),
|
||||||
|
retry_after_secs: 60,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn record_comfy_task_success(
|
||||||
|
&self,
|
||||||
|
event: ComfyTaskBillingEvent,
|
||||||
|
) -> AppResult<Value> {
|
||||||
|
Self::ensure_schema(&self.db).await?;
|
||||||
|
Self::ensure_proxy_log_task_columns(&self.db).await?;
|
||||||
|
let prompt_tokens = event.prompt_tokens.max(0);
|
||||||
|
let completion_tokens = event.completion_tokens.max(0);
|
||||||
|
let total_tokens = prompt_tokens + completion_tokens;
|
||||||
|
let cost = event.cost.max(0.0);
|
||||||
|
let billing_details = comfy_task_billing_details(&event, "success", cost);
|
||||||
|
|
||||||
|
let mut tx = self.db.begin().await?;
|
||||||
|
if let Some(key_id) = event.key_id {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO usage_logs
|
||||||
|
(downstream_key_id, model, prompt_tokens, completion_tokens, total_tokens, cost)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(key_id)
|
||||||
|
.bind(&event.model)
|
||||||
|
.bind(prompt_tokens)
|
||||||
|
.bind(completion_tokens)
|
||||||
|
.bind(total_tokens)
|
||||||
|
.bind(cost)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE downstream_api_keys
|
||||||
|
SET used_requests = COALESCE(used_requests, 0) + 1,
|
||||||
|
used_cost = COALESCE(used_cost, 0) + ?,
|
||||||
|
last_used_at = datetime('now'),
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(cost)
|
||||||
|
.bind(key_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO proxy_logs (
|
||||||
|
downstream_api_key_id, model_requested, model_actual, status, http_status,
|
||||||
|
prompt_tokens, completion_tokens, total_tokens, estimated_cost,
|
||||||
|
retry_count, is_stream, client_family, client_app_id, client_app_name,
|
||||||
|
client_confidence, billing_details, content_summary, request_content,
|
||||||
|
response_content
|
||||||
|
) VALUES (?, ?, ?, 'success', 200, ?, ?, ?, ?, 0, 0, ?, ?, ?, 'high', ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(event.key_id)
|
||||||
|
.bind(&event.model)
|
||||||
|
.bind(&event.model)
|
||||||
|
.bind(prompt_tokens)
|
||||||
|
.bind(completion_tokens)
|
||||||
|
.bind(total_tokens)
|
||||||
|
.bind(cost)
|
||||||
|
.bind(event.client_family)
|
||||||
|
.bind(event.client_app_id)
|
||||||
|
.bind(event.client_app_name)
|
||||||
|
.bind(billing_details.to_string())
|
||||||
|
.bind(event.content_summary.as_deref())
|
||||||
|
.bind(event.request_content.as_deref())
|
||||||
|
.bind(event.response_content.as_deref())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(billing_details)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn record_comfy_task_failure(
|
||||||
|
&self,
|
||||||
|
event: ComfyTaskBillingEvent,
|
||||||
|
) -> AppResult<Value> {
|
||||||
|
Self::ensure_schema(&self.db).await?;
|
||||||
|
Self::ensure_proxy_log_task_columns(&self.db).await?;
|
||||||
|
let billing_details = comfy_task_billing_details(&event, "failed", 0.0);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO proxy_logs (
|
||||||
|
downstream_api_key_id, model_requested, model_actual, status, http_status,
|
||||||
|
prompt_tokens, completion_tokens, total_tokens, estimated_cost, error_message,
|
||||||
|
retry_count, is_stream, client_family, client_app_id, client_app_name,
|
||||||
|
client_confidence, billing_details, content_summary, request_content,
|
||||||
|
response_content
|
||||||
|
) VALUES (?, ?, ?, 'failed', 502, 0, 0, 0, 0.0, ?, 0, 0, ?, ?, ?, 'high', ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(event.key_id)
|
||||||
|
.bind(&event.model)
|
||||||
|
.bind(&event.model)
|
||||||
|
.bind(event.error_message.as_deref().unwrap_or("Comfy task failed"))
|
||||||
|
.bind(event.client_family)
|
||||||
|
.bind(event.client_app_id)
|
||||||
|
.bind(event.client_app_name)
|
||||||
|
.bind(billing_details.to_string())
|
||||||
|
.bind(event.content_summary.as_deref())
|
||||||
|
.bind(event.request_content.as_deref())
|
||||||
|
.bind(event.response_content.as_deref())
|
||||||
|
.execute(&*self.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(billing_details)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn create_daily_usage_snapshot(&self) -> AppResult<()> {
|
pub async fn create_daily_usage_snapshot(&self) -> AppResult<()> {
|
||||||
Self::create_daily_usage_snapshot_for_db(&self.db).await
|
Self::create_daily_usage_snapshot_for_db(&self.db).await
|
||||||
}
|
}
|
||||||
@@ -149,6 +277,215 @@ impl UsageTracker {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn project_proxy_usage_for_db(db: &SqlitePool) -> AppResult<i64> {
|
||||||
|
Self::ensure_schema(db).await?;
|
||||||
|
let last_id: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COALESCE(last_proxy_log_id, 0)
|
||||||
|
FROM analytics_projection_checkpoints
|
||||||
|
WHERE projector_key = 'usage-aggregates-v1'",
|
||||||
|
)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
l.id,
|
||||||
|
l.created_at,
|
||||||
|
l.status,
|
||||||
|
l.latency_ms,
|
||||||
|
l.total_tokens,
|
||||||
|
l.estimated_cost,
|
||||||
|
COALESCE(NULLIF(l.model_actual, ''), NULLIF(l.model_requested, ''), 'unknown') AS model,
|
||||||
|
a.site_id,
|
||||||
|
s.platform
|
||||||
|
FROM proxy_logs l
|
||||||
|
LEFT JOIN accounts a ON a.id = l.account_id
|
||||||
|
LEFT JOIN sites s ON s.id = a.site_id
|
||||||
|
WHERE l.id > ?
|
||||||
|
ORDER BY l.id ASC
|
||||||
|
LIMIT 5000
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(last_id)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if rows.is_empty() {
|
||||||
|
touch_projection_checkpoint(db, last_id, None).await?;
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut max_id = last_id;
|
||||||
|
let mut tx = db.begin().await?;
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let id: i64 = row.try_get("id").unwrap_or(0);
|
||||||
|
max_id = max_id.max(id);
|
||||||
|
let site_id: Option<i64> = row.try_get("site_id").ok().flatten();
|
||||||
|
let Some(site_id) = site_id.filter(|value| *value > 0) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let created_at: Option<String> = row.try_get("created_at").ok().flatten();
|
||||||
|
let Some(created_at) = created_at else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let local_day = sqlite_day_key(&created_at);
|
||||||
|
let bucket_start_utc = sqlite_hour_key(&created_at);
|
||||||
|
let status: Option<String> = row.try_get("status").ok().flatten();
|
||||||
|
let is_success = status
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.eq_ignore_ascii_case("success");
|
||||||
|
let latency_ms = non_negative_i64(row.try_get("latency_ms").ok().flatten());
|
||||||
|
let latency_count = i64::from(latency_ms > 0);
|
||||||
|
let total_tokens = non_negative_i64(row.try_get("total_tokens").ok().flatten());
|
||||||
|
let estimated_cost = non_negative_f64(row.try_get("estimated_cost").ok().flatten());
|
||||||
|
let platform: Option<String> = row.try_get("platform").ok().flatten();
|
||||||
|
let model: String = row
|
||||||
|
.try_get::<Option<String>, _>("model")
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let summary_spend = if estimated_cost > 0.0 {
|
||||||
|
estimated_cost
|
||||||
|
} else if platform
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("veloera")
|
||||||
|
{
|
||||||
|
total_tokens as f64 / 1_000_000.0
|
||||||
|
} else {
|
||||||
|
total_tokens as f64 / 500_000.0
|
||||||
|
};
|
||||||
|
let site_spend = if estimated_cost > 0.0 {
|
||||||
|
estimated_cost
|
||||||
|
} else {
|
||||||
|
fallback_token_cost(total_tokens, platform.as_deref())
|
||||||
|
};
|
||||||
|
let model_spend = if estimated_cost > 0.0 {
|
||||||
|
estimated_cost
|
||||||
|
} else {
|
||||||
|
total_tokens as f64 / 500_000.0
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO site_day_usage
|
||||||
|
(local_day, site_id, total_calls, success_calls, failed_calls,
|
||||||
|
total_tokens, total_summary_spend, total_site_spend,
|
||||||
|
total_latency_ms, latency_count, updated_at)
|
||||||
|
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(local_day, site_id) DO UPDATE SET
|
||||||
|
total_calls = total_calls + 1,
|
||||||
|
success_calls = success_calls + excluded.success_calls,
|
||||||
|
failed_calls = failed_calls + excluded.failed_calls,
|
||||||
|
total_tokens = total_tokens + excluded.total_tokens,
|
||||||
|
total_summary_spend = total_summary_spend + excluded.total_summary_spend,
|
||||||
|
total_site_spend = total_site_spend + excluded.total_site_spend,
|
||||||
|
total_latency_ms = total_latency_ms + excluded.total_latency_ms,
|
||||||
|
latency_count = latency_count + excluded.latency_count,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&local_day)
|
||||||
|
.bind(site_id)
|
||||||
|
.bind(i64::from(is_success))
|
||||||
|
.bind(i64::from(!is_success))
|
||||||
|
.bind(total_tokens)
|
||||||
|
.bind(summary_spend)
|
||||||
|
.bind(site_spend)
|
||||||
|
.bind(latency_ms)
|
||||||
|
.bind(latency_count)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO site_hour_usage
|
||||||
|
(bucket_start_utc, site_id, total_calls, success_calls, failed_calls,
|
||||||
|
total_tokens, total_summary_spend, total_site_spend,
|
||||||
|
total_latency_ms, latency_count, updated_at)
|
||||||
|
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(bucket_start_utc, site_id) DO UPDATE SET
|
||||||
|
total_calls = total_calls + 1,
|
||||||
|
success_calls = success_calls + excluded.success_calls,
|
||||||
|
failed_calls = failed_calls + excluded.failed_calls,
|
||||||
|
total_tokens = total_tokens + excluded.total_tokens,
|
||||||
|
total_summary_spend = total_summary_spend + excluded.total_summary_spend,
|
||||||
|
total_site_spend = total_site_spend + excluded.total_site_spend,
|
||||||
|
total_latency_ms = total_latency_ms + excluded.total_latency_ms,
|
||||||
|
latency_count = latency_count + excluded.latency_count,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&bucket_start_utc)
|
||||||
|
.bind(site_id)
|
||||||
|
.bind(i64::from(is_success))
|
||||||
|
.bind(i64::from(!is_success))
|
||||||
|
.bind(total_tokens)
|
||||||
|
.bind(summary_spend)
|
||||||
|
.bind(site_spend)
|
||||||
|
.bind(latency_ms)
|
||||||
|
.bind(latency_count)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO model_day_usage
|
||||||
|
(local_day, site_id, model, total_calls, success_calls, failed_calls,
|
||||||
|
total_tokens, total_spend, total_latency_ms, latency_count, updated_at)
|
||||||
|
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(local_day, site_id, model) DO UPDATE SET
|
||||||
|
total_calls = total_calls + 1,
|
||||||
|
success_calls = success_calls + excluded.success_calls,
|
||||||
|
failed_calls = failed_calls + excluded.failed_calls,
|
||||||
|
total_tokens = total_tokens + excluded.total_tokens,
|
||||||
|
total_spend = total_spend + excluded.total_spend,
|
||||||
|
total_latency_ms = total_latency_ms + excluded.total_latency_ms,
|
||||||
|
latency_count = latency_count + excluded.latency_count,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&local_day)
|
||||||
|
.bind(site_id)
|
||||||
|
.bind(&model)
|
||||||
|
.bind(i64::from(is_success))
|
||||||
|
.bind(i64::from(!is_success))
|
||||||
|
.bind(total_tokens)
|
||||||
|
.bind(model_spend)
|
||||||
|
.bind(latency_ms)
|
||||||
|
.bind(latency_count)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO analytics_projection_checkpoints
|
||||||
|
(projector_key, time_zone, last_proxy_log_id, watermark_created_at,
|
||||||
|
last_projected_at, last_successful_at, last_error, updated_at)
|
||||||
|
VALUES ('usage-aggregates-v1', 'Local', ?, datetime('now'), datetime('now'), datetime('now'), NULL, datetime('now'))
|
||||||
|
ON CONFLICT(projector_key) DO UPDATE SET
|
||||||
|
last_proxy_log_id = excluded.last_proxy_log_id,
|
||||||
|
watermark_created_at = excluded.watermark_created_at,
|
||||||
|
last_projected_at = excluded.last_projected_at,
|
||||||
|
last_successful_at = excluded.last_successful_at,
|
||||||
|
last_error = NULL,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(max_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(max_id - last_id)
|
||||||
|
}
|
||||||
|
|
||||||
async fn ensure_schema(db: &SqlitePool) -> AppResult<()> {
|
async fn ensure_schema(db: &SqlitePool) -> AppResult<()> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"CREATE TABLE IF NOT EXISTS usage_logs (
|
"CREATE TABLE IF NOT EXISTS usage_logs (
|
||||||
@@ -187,8 +524,283 @@ impl UsageTracker {
|
|||||||
)
|
)
|
||||||
.execute(db)
|
.execute(db)
|
||||||
.await?;
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS analytics_projection_checkpoints (
|
||||||
|
projector_key TEXT PRIMARY KEY NOT NULL,
|
||||||
|
time_zone TEXT NOT NULL DEFAULT 'Local',
|
||||||
|
last_proxy_log_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
watermark_created_at TEXT,
|
||||||
|
recompute_from_id INTEGER,
|
||||||
|
recompute_requested_at TEXT,
|
||||||
|
recompute_reason TEXT,
|
||||||
|
recompute_started_at TEXT,
|
||||||
|
recompute_completed_at TEXT,
|
||||||
|
last_projected_at TEXT,
|
||||||
|
last_successful_at TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
lease_owner TEXT,
|
||||||
|
lease_token TEXT,
|
||||||
|
lease_expires_at TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS model_day_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
local_day TEXT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
total_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
latency_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(local_day, site_id, model)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS site_day_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
local_day TEXT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
total_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_summary_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_site_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
latency_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(local_day, site_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS site_hour_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
bucket_start_utc TEXT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
total_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_summary_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_site_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
latency_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(bucket_start_utc, site_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn ensure_proxy_log_task_columns(db: &SqlitePool) -> AppResult<()> {
|
||||||
|
for (name, definition) in [
|
||||||
|
("client_family", "TEXT"),
|
||||||
|
("client_app_id", "TEXT"),
|
||||||
|
("client_app_name", "TEXT"),
|
||||||
|
("client_confidence", "TEXT"),
|
||||||
|
("is_stream", "INTEGER"),
|
||||||
|
("first_byte_latency_ms", "INTEGER"),
|
||||||
|
("billing_details", "TEXT"),
|
||||||
|
("content_summary", "TEXT"),
|
||||||
|
("request_content", "TEXT"),
|
||||||
|
("response_content", "TEXT"),
|
||||||
|
] {
|
||||||
|
ensure_table_column(db, "proxy_logs", name, definition).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_table_column(
|
||||||
|
db: &SqlitePool,
|
||||||
|
table: &str,
|
||||||
|
name: &str,
|
||||||
|
definition: &str,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let info_sql = format!("PRAGMA table_info({table})");
|
||||||
|
let rows = sqlx::query(&info_sql).fetch_all(db).await?;
|
||||||
|
if rows.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let exists = rows
|
||||||
|
.iter()
|
||||||
|
.filter_map(|row| row.try_get::<String, _>("name").ok())
|
||||||
|
.any(|column| column == name);
|
||||||
|
if exists {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let alter_sql = format!("ALTER TABLE {table} ADD COLUMN {name} {definition}");
|
||||||
|
sqlx::query(&alter_sql).execute(db).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn comfy_task_billing_details(
|
||||||
|
event: &ComfyTaskBillingEvent,
|
||||||
|
status: &str,
|
||||||
|
total_cost: f64,
|
||||||
|
) -> Value {
|
||||||
|
let prompt_tokens = event.prompt_tokens.max(0);
|
||||||
|
let completion_tokens = event.completion_tokens.max(0);
|
||||||
|
let total_tokens = prompt_tokens + completion_tokens;
|
||||||
|
json!({
|
||||||
|
"source": "comfy-native-task",
|
||||||
|
"taskType": event.task_type,
|
||||||
|
"status": status,
|
||||||
|
"currency": "USD",
|
||||||
|
"charged": status == "success" && event.key_id.is_some(),
|
||||||
|
"totalCost": total_cost,
|
||||||
|
"usage": {
|
||||||
|
"promptTokens": prompt_tokens,
|
||||||
|
"completionTokens": completion_tokens,
|
||||||
|
"totalTokens": total_tokens,
|
||||||
|
"cacheReadTokens": 0,
|
||||||
|
"cacheCreationTokens": 0,
|
||||||
|
"billablePromptTokens": prompt_tokens,
|
||||||
|
"promptTokensIncludeCache": false,
|
||||||
|
},
|
||||||
|
"pricing": {
|
||||||
|
"modelRatio": 1,
|
||||||
|
"completionRatio": 1,
|
||||||
|
"cacheRatio": 0,
|
||||||
|
"cacheCreationRatio": 0,
|
||||||
|
"groupRatio": 1,
|
||||||
|
},
|
||||||
|
"breakdown": {
|
||||||
|
"inputPerMillion": 0,
|
||||||
|
"outputPerMillion": 0,
|
||||||
|
"cacheReadPerMillion": 0,
|
||||||
|
"cacheCreationPerMillion": 0,
|
||||||
|
"inputCost": 0,
|
||||||
|
"outputCost": total_cost,
|
||||||
|
"cacheReadCost": 0,
|
||||||
|
"cacheCreationCost": 0,
|
||||||
|
"totalCost": total_cost,
|
||||||
|
},
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"name": "submit_workflow",
|
||||||
|
"status": status,
|
||||||
|
"model": event.model,
|
||||||
|
"cost": 0.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "generate_outputs",
|
||||||
|
"status": status,
|
||||||
|
"model": event.model,
|
||||||
|
"cost": total_cost,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "archive_outputs",
|
||||||
|
"status": status,
|
||||||
|
"model": event.model,
|
||||||
|
"cost": 0.0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn touch_projection_checkpoint(
|
||||||
|
db: &SqlitePool,
|
||||||
|
last_proxy_log_id: i64,
|
||||||
|
error: Option<&str>,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO analytics_projection_checkpoints
|
||||||
|
(projector_key, time_zone, last_proxy_log_id, last_projected_at,
|
||||||
|
last_successful_at, last_error, updated_at)
|
||||||
|
VALUES ('usage-aggregates-v1', 'Local', ?, datetime('now'), datetime('now'), ?, datetime('now'))
|
||||||
|
ON CONFLICT(projector_key) DO UPDATE SET
|
||||||
|
last_projected_at = datetime('now'),
|
||||||
|
last_successful_at = CASE WHEN ? IS NULL THEN datetime('now') ELSE last_successful_at END,
|
||||||
|
last_error = ?,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(last_proxy_log_id)
|
||||||
|
.bind(error)
|
||||||
|
.bind(error)
|
||||||
|
.bind(error)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_negative_i64(value: Option<i64>) -> i64 {
|
||||||
|
value.unwrap_or(0).max(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_negative_f64(value: Option<f64>) -> f64 {
|
||||||
|
value.unwrap_or(0.0).max(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sqlite_day_key(created_at: &str) -> String {
|
||||||
|
parse_sqlite_datetime(created_at)
|
||||||
|
.map(|dt| dt.format("%Y-%m-%d").to_string())
|
||||||
|
.unwrap_or_else(|| created_at.chars().take(10).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sqlite_hour_key(created_at: &str) -> String {
|
||||||
|
parse_sqlite_datetime(created_at)
|
||||||
|
.map(|dt| {
|
||||||
|
dt.with_minute(0)
|
||||||
|
.and_then(|dt| dt.with_second(0))
|
||||||
|
.and_then(|dt| dt.with_nanosecond(0))
|
||||||
|
.unwrap_or(dt)
|
||||||
|
.format("%Y-%m-%d %H:%M:%S")
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let prefix: String = created_at.chars().take(13).collect();
|
||||||
|
if prefix.len() == 13 {
|
||||||
|
format!("{prefix}:00:00")
|
||||||
|
} else {
|
||||||
|
created_at.to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_sqlite_datetime(value: &str) -> Option<chrono::NaiveDateTime> {
|
||||||
|
chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S")
|
||||||
|
.ok()
|
||||||
|
.or_else(|| {
|
||||||
|
chrono::DateTime::parse_from_rfc3339(value)
|
||||||
|
.ok()
|
||||||
|
.map(|dt| dt.naive_utc())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fallback_token_cost(tokens: i64, platform: Option<&str>) -> f64 {
|
||||||
|
if tokens <= 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
match platform
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"veloera" => tokens as f64 / 1_000_000.0,
|
||||||
|
_ => tokens as f64 / 500_000.0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -232,6 +844,56 @@ mod tests {
|
|||||||
pool
|
pool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn create_proxy_logs(pool: &SqlitePool) {
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE proxy_logs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
downstream_api_key_id INTEGER,
|
||||||
|
model_requested TEXT,
|
||||||
|
model_actual TEXT,
|
||||||
|
status TEXT,
|
||||||
|
http_status INTEGER,
|
||||||
|
prompt_tokens INTEGER,
|
||||||
|
completion_tokens INTEGER,
|
||||||
|
total_tokens INTEGER,
|
||||||
|
estimated_cost REAL,
|
||||||
|
error_message TEXT,
|
||||||
|
retry_count INTEGER DEFAULT 0,
|
||||||
|
is_stream INTEGER,
|
||||||
|
client_family TEXT,
|
||||||
|
client_app_id TEXT,
|
||||||
|
client_app_name TEXT,
|
||||||
|
client_confidence TEXT,
|
||||||
|
billing_details TEXT,
|
||||||
|
content_summary TEXT,
|
||||||
|
request_content TEXT,
|
||||||
|
response_content TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_comfy_event(cost: f64) -> ComfyTaskBillingEvent {
|
||||||
|
ComfyTaskBillingEvent {
|
||||||
|
key_id: Some(1),
|
||||||
|
model: "seedance-comfy".to_string(),
|
||||||
|
task_type: "money-printer-video",
|
||||||
|
client_family: "money-printer",
|
||||||
|
client_app_id: "money-printer",
|
||||||
|
client_app_name: "Money Printer",
|
||||||
|
prompt_tokens: 0,
|
||||||
|
completion_tokens: 0,
|
||||||
|
cost,
|
||||||
|
content_summary: Some("topic".to_string()),
|
||||||
|
request_content: Some(json!({ "topic": "topic" }).to_string()),
|
||||||
|
response_content: Some(json!({ "ok": true }).to_string()),
|
||||||
|
error_message: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn records_and_aggregates_usage() {
|
async fn records_and_aggregates_usage() {
|
||||||
let tracker = UsageTracker::new(Arc::new(pool().await));
|
let tracker = UsageTracker::new(Arc::new(pool().await));
|
||||||
@@ -260,7 +922,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
tracker.check_rate_limit(1).await,
|
tracker.check_rate_limit(1).await,
|
||||||
Err(AppError::RateLimited)
|
Err(AppError::RateLimitedWithRetry { .. })
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,4 +941,170 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 1);
|
assert_eq!(count, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn records_comfy_task_success_as_usage_and_proxy_log() {
|
||||||
|
let pool = pool().await;
|
||||||
|
create_proxy_logs(&pool).await;
|
||||||
|
let tracker = UsageTracker::new(Arc::new(pool));
|
||||||
|
|
||||||
|
let billing = tracker
|
||||||
|
.record_comfy_task_success(test_comfy_event(0.35))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(billing["source"], "comfy-native-task");
|
||||||
|
assert_eq!(billing["charged"], true);
|
||||||
|
assert_eq!(billing["totalCost"], 0.35);
|
||||||
|
|
||||||
|
let usage: (i64, f64) = sqlx::query_as(
|
||||||
|
"SELECT COUNT(*), COALESCE(SUM(cost), 0.0) FROM usage_logs WHERE downstream_key_id = 1",
|
||||||
|
)
|
||||||
|
.fetch_one(&*tracker.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(usage, (1, 0.35));
|
||||||
|
|
||||||
|
let key_usage: (i64, f64) =
|
||||||
|
sqlx::query_as("SELECT used_requests, used_cost FROM downstream_api_keys WHERE id = 1")
|
||||||
|
.fetch_one(&*tracker.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(key_usage, (1, 0.35));
|
||||||
|
|
||||||
|
let proxy_log: (String, f64, String, String) = sqlx::query_as(
|
||||||
|
"SELECT status, estimated_cost, client_app_id, billing_details FROM proxy_logs WHERE downstream_api_key_id = 1",
|
||||||
|
)
|
||||||
|
.fetch_one(&*tracker.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(proxy_log.0, "success");
|
||||||
|
assert_eq!(proxy_log.1, 0.35);
|
||||||
|
assert_eq!(proxy_log.2, "money-printer");
|
||||||
|
assert!(proxy_log.3.contains("\"steps\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn records_comfy_task_failure_without_debiting_usage() {
|
||||||
|
let pool = pool().await;
|
||||||
|
create_proxy_logs(&pool).await;
|
||||||
|
let tracker = UsageTracker::new(Arc::new(pool));
|
||||||
|
let mut event = test_comfy_event(0.35);
|
||||||
|
event.error_message = Some("Not found".to_string());
|
||||||
|
|
||||||
|
let billing = tracker.record_comfy_task_failure(event).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(billing["charged"], false);
|
||||||
|
assert_eq!(billing["totalCost"], 0.0);
|
||||||
|
|
||||||
|
let usage_count: i64 =
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM usage_logs WHERE downstream_key_id = 1")
|
||||||
|
.fetch_one(&*tracker.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(usage_count, 0);
|
||||||
|
|
||||||
|
let key_usage: (i64, f64) =
|
||||||
|
sqlx::query_as("SELECT used_requests, used_cost FROM downstream_api_keys WHERE id = 1")
|
||||||
|
.fetch_one(&*tracker.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(key_usage, (0, 0.0));
|
||||||
|
|
||||||
|
let proxy_log: (String, f64, String) = sqlx::query_as(
|
||||||
|
"SELECT status, estimated_cost, error_message FROM proxy_logs WHERE downstream_api_key_id = 1",
|
||||||
|
)
|
||||||
|
.fetch_one(&*tracker.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(proxy_log, ("failed".to_string(), 0.0, "Not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn projects_proxy_logs_to_site_and_model_usage() {
|
||||||
|
let pool = pool().await;
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE sites (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
platform TEXT
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE accounts (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
site_id INTEGER NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE proxy_logs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
account_id INTEGER,
|
||||||
|
model_requested TEXT,
|
||||||
|
model_actual TEXT,
|
||||||
|
status TEXT,
|
||||||
|
latency_ms INTEGER,
|
||||||
|
total_tokens INTEGER,
|
||||||
|
estimated_cost REAL,
|
||||||
|
created_at TEXT
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query("INSERT INTO sites (id, name, platform) VALUES (10, 'Site', 'new-api')")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query("INSERT INTO accounts (id, site_id) VALUES (20, 10)")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO proxy_logs
|
||||||
|
(id, account_id, model_requested, model_actual, status, latency_ms, total_tokens, estimated_cost, created_at)
|
||||||
|
VALUES
|
||||||
|
(1, 20, 'gpt-test', NULL, 'success', 120, 1000, 0.12, '2026-06-06 08:12:30')",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let projected = UsageTracker::project_proxy_usage_for_db(&pool).await.unwrap();
|
||||||
|
assert_eq!(projected, 1);
|
||||||
|
|
||||||
|
let model_usage: (i64, i64, f64, i64) = sqlx::query_as(
|
||||||
|
"SELECT total_calls, total_tokens, total_spend, total_latency_ms
|
||||||
|
FROM model_day_usage
|
||||||
|
WHERE site_id = 10 AND model = 'gpt-test'",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(model_usage, (1, 1000, 0.12, 120));
|
||||||
|
|
||||||
|
let site_usage: (i64, i64, f64) = sqlx::query_as(
|
||||||
|
"SELECT total_calls, success_calls, total_site_spend
|
||||||
|
FROM site_day_usage
|
||||||
|
WHERE site_id = 10 AND local_day = '2026-06-06'",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(site_usage, (1, 1, 0.12));
|
||||||
|
|
||||||
|
let hour_bucket: String = sqlx::query_scalar(
|
||||||
|
"SELECT bucket_start_utc FROM site_hour_usage WHERE site_id = 10",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(hour_bucket, "2026-06-06 08:00:00");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ impl ComfyUIProcess {
|
|||||||
let base_url = self.internal_base_url();
|
let base_url = self.internal_base_url();
|
||||||
let mode = if self.config.uses_docker_backend() {
|
let mode = if self.config.uses_docker_backend() {
|
||||||
"docker"
|
"docker"
|
||||||
|
} else if self.config.uses_cloud_backend() {
|
||||||
|
"cloud"
|
||||||
|
} else if self.config.uses_runninghub_backend() {
|
||||||
|
"runninghub"
|
||||||
} else if self.config.enabled {
|
} else if self.config.enabled {
|
||||||
"embedded"
|
"embedded"
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ impl CronScheduler {
|
|||||||
|
|
||||||
async fn aggregate_usage(db: Arc<SqlitePool>) -> AppResult<()> {
|
async fn aggregate_usage(db: Arc<SqlitePool>) -> AppResult<()> {
|
||||||
UsageTracker::create_daily_usage_snapshot_for_db(&db).await?;
|
UsageTracker::create_daily_usage_snapshot_for_db(&db).await?;
|
||||||
|
let projected = UsageTracker::project_proxy_usage_for_db(&db).await?;
|
||||||
|
if projected > 0 {
|
||||||
|
tracing::debug!(projected, "usage aggregates projected");
|
||||||
|
}
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE downstream_api_keys
|
"UPDATE downstream_api_keys
|
||||||
SET updated_at = datetime('now')
|
SET updated_at = datetime('now')
|
||||||
@@ -123,6 +127,7 @@ impl CronScheduler {
|
|||||||
)
|
)
|
||||||
.execute(&*db)
|
.execute(&*db)
|
||||||
.await?;
|
.await?;
|
||||||
|
crate::proxy::token_router::router::invalidate_route_cache();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -5,9 +5,11 @@ pub mod comfyui_process;
|
|||||||
pub mod cron;
|
pub mod cron;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
|
pub mod payment;
|
||||||
pub mod register_captcha;
|
pub mod register_captcha;
|
||||||
pub mod registration_limiter;
|
pub mod registration_limiter;
|
||||||
pub mod route_rebuild;
|
pub mod route_rebuild;
|
||||||
|
pub mod runtime_auth;
|
||||||
|
pub mod runtime_logs;
|
||||||
pub mod shared;
|
pub mod shared;
|
||||||
pub mod update_center;
|
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::error::AppResult;
|
use crate::error::{AppError, AppResult};
|
||||||
use chrono::{FixedOffset, SecondsFormat, TimeZone, Utc};
|
use chrono::{FixedOffset, SecondsFormat, TimeZone, Utc};
|
||||||
use lettre::{
|
use lettre::{
|
||||||
message::Mailbox, transport::smtp::authentication::Credentials, Message, SmtpTransport,
|
message::Mailbox, transport::smtp::authentication::Credentials, Message, SmtpTransport,
|
||||||
@@ -36,13 +36,19 @@ pub struct Notification {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
struct NotificationConfig {
|
struct NotificationConfig {
|
||||||
|
telegram_enabled: bool,
|
||||||
telegram_bot_token: Option<String>,
|
telegram_bot_token: Option<String>,
|
||||||
telegram_chat_id: Option<String>,
|
telegram_chat_id: Option<String>,
|
||||||
telegram_thread_id: Option<i64>,
|
telegram_thread_id: Option<i64>,
|
||||||
|
wecom_enabled: bool,
|
||||||
wecom_webhook: Option<String>,
|
wecom_webhook: Option<String>,
|
||||||
|
feishu_enabled: bool,
|
||||||
feishu_webhook: Option<String>,
|
feishu_webhook: Option<String>,
|
||||||
|
webhook_enabled: bool,
|
||||||
webhook_url: Option<String>,
|
webhook_url: Option<String>,
|
||||||
|
bark_enabled: bool,
|
||||||
bark_url: Option<String>,
|
bark_url: Option<String>,
|
||||||
|
serverchan_enabled: bool,
|
||||||
serverchan_key: Option<String>,
|
serverchan_key: Option<String>,
|
||||||
smtp: SmtpConfig,
|
smtp: SmtpConfig,
|
||||||
cooldown_sec: i64,
|
cooldown_sec: i64,
|
||||||
@@ -50,8 +56,10 @@ struct NotificationConfig {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
struct SmtpConfig {
|
struct SmtpConfig {
|
||||||
|
enabled: bool,
|
||||||
host: Option<String>,
|
host: Option<String>,
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
|
secure: Option<bool>,
|
||||||
user: Option<String>,
|
user: Option<String>,
|
||||||
pass: Option<String>,
|
pass: Option<String>,
|
||||||
from: Option<String>,
|
from: Option<String>,
|
||||||
@@ -100,6 +108,33 @@ impl NotificationService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn send_test(&self) -> AppResult<Vec<String>> {
|
||||||
|
Self::ensure_schema(&self.db).await?;
|
||||||
|
let config = self.load_config().await?;
|
||||||
|
let channels = self.enabled_channels(&config);
|
||||||
|
if channels.is_empty() {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"请至少配置并启用一个通知通道".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let notif = Notification {
|
||||||
|
title: "BossAI 通知测试".to_string(),
|
||||||
|
message: "通知通道配置正常。这条消息来自管理后台的测试发送。".to_string(),
|
||||||
|
level: NotificationLevel::Info,
|
||||||
|
channel: None,
|
||||||
|
signature: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
for channel in &channels {
|
||||||
|
self.send_channel(channel, ¬if, &config)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::BadRequest(format!("{channel} 通知测试失败: {err}")))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(channels)
|
||||||
|
}
|
||||||
|
|
||||||
async fn load_config(&self) -> AppResult<NotificationConfig> {
|
async fn load_config(&self) -> AppResult<NotificationConfig> {
|
||||||
let rows: Vec<(String, Option<String>)> =
|
let rows: Vec<(String, Option<String>)> =
|
||||||
sqlx::query_as("SELECT key, value FROM settings WHERE key LIKE 'notify_%'")
|
sqlx::query_as("SELECT key, value FROM settings WHERE key LIKE 'notify_%'")
|
||||||
@@ -112,18 +147,26 @@ impl NotificationService {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(NotificationConfig {
|
Ok(NotificationConfig {
|
||||||
|
telegram_enabled: setting_bool(&map, "notify_telegram_enabled", true),
|
||||||
telegram_bot_token: setting(&map, "notify_telegram_bot_token"),
|
telegram_bot_token: setting(&map, "notify_telegram_bot_token"),
|
||||||
telegram_chat_id: setting(&map, "notify_telegram_chat_id"),
|
telegram_chat_id: setting(&map, "notify_telegram_chat_id"),
|
||||||
telegram_thread_id: setting(&map, "notify_telegram_thread_id")
|
telegram_thread_id: setting(&map, "notify_telegram_thread_id")
|
||||||
.and_then(|value| value.parse().ok()),
|
.and_then(|value| value.parse().ok()),
|
||||||
|
wecom_enabled: setting_bool(&map, "notify_wecom_enabled", true),
|
||||||
wecom_webhook: setting(&map, "notify_wecom_webhook"),
|
wecom_webhook: setting(&map, "notify_wecom_webhook"),
|
||||||
|
feishu_enabled: setting_bool(&map, "notify_feishu_enabled", true),
|
||||||
feishu_webhook: setting(&map, "notify_feishu_webhook"),
|
feishu_webhook: setting(&map, "notify_feishu_webhook"),
|
||||||
|
webhook_enabled: setting_bool(&map, "notify_webhook_enabled", true),
|
||||||
webhook_url: setting(&map, "notify_webhook_url"),
|
webhook_url: setting(&map, "notify_webhook_url"),
|
||||||
|
bark_enabled: setting_bool(&map, "notify_bark_enabled", true),
|
||||||
bark_url: setting(&map, "notify_bark_url"),
|
bark_url: setting(&map, "notify_bark_url"),
|
||||||
|
serverchan_enabled: setting_bool(&map, "notify_serverchan_enabled", true),
|
||||||
serverchan_key: setting(&map, "notify_serverchan_key"),
|
serverchan_key: setting(&map, "notify_serverchan_key"),
|
||||||
smtp: SmtpConfig {
|
smtp: SmtpConfig {
|
||||||
|
enabled: setting_bool(&map, "notify_smtp_enabled", true),
|
||||||
host: setting(&map, "notify_smtp_host"),
|
host: setting(&map, "notify_smtp_host"),
|
||||||
port: setting(&map, "notify_smtp_port").and_then(|value| value.parse().ok()),
|
port: setting(&map, "notify_smtp_port").and_then(|value| value.parse().ok()),
|
||||||
|
secure: setting(&map, "notify_smtp_secure").map(|value| parse_bool(&value)),
|
||||||
user: setting(&map, "notify_smtp_user"),
|
user: setting(&map, "notify_smtp_user"),
|
||||||
pass: setting(&map, "notify_smtp_pass"),
|
pass: setting(&map, "notify_smtp_pass"),
|
||||||
from: setting(&map, "notify_smtp_from"),
|
from: setting(&map, "notify_smtp_from"),
|
||||||
@@ -188,25 +231,28 @@ impl NotificationService {
|
|||||||
|
|
||||||
fn enabled_channels(&self, config: &NotificationConfig) -> Vec<String> {
|
fn enabled_channels(&self, config: &NotificationConfig) -> Vec<String> {
|
||||||
let mut channels = Vec::new();
|
let mut channels = Vec::new();
|
||||||
if config.telegram_bot_token.is_some() && config.telegram_chat_id.is_some() {
|
if config.telegram_enabled
|
||||||
|
&& config.telegram_bot_token.is_some()
|
||||||
|
&& config.telegram_chat_id.is_some()
|
||||||
|
{
|
||||||
channels.push("telegram".to_string());
|
channels.push("telegram".to_string());
|
||||||
}
|
}
|
||||||
if config.wecom_webhook.is_some() {
|
if config.wecom_enabled && config.wecom_webhook.is_some() {
|
||||||
channels.push("wecom".to_string());
|
channels.push("wecom".to_string());
|
||||||
}
|
}
|
||||||
if config.feishu_webhook.is_some() {
|
if config.feishu_enabled && config.feishu_webhook.is_some() {
|
||||||
channels.push("feishu".to_string());
|
channels.push("feishu".to_string());
|
||||||
}
|
}
|
||||||
if config.webhook_url.is_some() {
|
if config.webhook_enabled && config.webhook_url.is_some() {
|
||||||
channels.push("webhook".to_string());
|
channels.push("webhook".to_string());
|
||||||
}
|
}
|
||||||
if config.bark_url.is_some() {
|
if config.bark_enabled && config.bark_url.is_some() {
|
||||||
channels.push("bark".to_string());
|
channels.push("bark".to_string());
|
||||||
}
|
}
|
||||||
if config.serverchan_key.is_some() {
|
if config.serverchan_enabled && config.serverchan_key.is_some() {
|
||||||
channels.push("serverchan".to_string());
|
channels.push("serverchan".to_string());
|
||||||
}
|
}
|
||||||
if config.smtp.is_configured() {
|
if config.smtp.enabled && config.smtp.is_configured() {
|
||||||
channels.push("smtp".to_string());
|
channels.push("smtp".to_string());
|
||||||
}
|
}
|
||||||
channels
|
channels
|
||||||
@@ -385,6 +431,7 @@ impl SmtpConfig {
|
|||||||
fn send(self, notif: &Notification) -> anyhow::Result<()> {
|
fn send(self, notif: &Notification) -> anyhow::Result<()> {
|
||||||
let host = required(self.host.as_deref(), "smtp host")?;
|
let host = required(self.host.as_deref(), "smtp host")?;
|
||||||
let port = self.port.unwrap_or(587);
|
let port = self.port.unwrap_or(587);
|
||||||
|
let secure = self.secure.unwrap_or(true);
|
||||||
let user = required(self.user.as_deref(), "smtp user")?;
|
let user = required(self.user.as_deref(), "smtp user")?;
|
||||||
let pass = required(self.pass.as_deref(), "smtp pass")?;
|
let pass = required(self.pass.as_deref(), "smtp pass")?;
|
||||||
let from = required(self.from.as_deref(), "smtp from")?;
|
let from = required(self.from.as_deref(), "smtp from")?;
|
||||||
@@ -400,7 +447,12 @@ impl SmtpConfig {
|
|||||||
))
|
))
|
||||||
.body(format_notification_text(notif))?;
|
.body(format_notification_text(notif))?;
|
||||||
|
|
||||||
let mailer = SmtpTransport::relay(host)?
|
let builder = if secure {
|
||||||
|
SmtpTransport::relay(host)?
|
||||||
|
} else {
|
||||||
|
SmtpTransport::builder_dangerous(host)
|
||||||
|
};
|
||||||
|
let mailer = builder
|
||||||
.port(port)
|
.port(port)
|
||||||
.credentials(Credentials::new(user.to_string(), pass.to_string()))
|
.credentials(Credentials::new(user.to_string(), pass.to_string()))
|
||||||
.build();
|
.build();
|
||||||
@@ -413,6 +465,17 @@ fn setting(map: &HashMap<String, String>, key: &str) -> Option<String> {
|
|||||||
map.get(key).map(|value| value.trim().to_string())
|
map.get(key).map(|value| value.trim().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn setting_bool(map: &HashMap<String, String>, key: &str, default: bool) -> bool {
|
||||||
|
map.get(key).map(|value| parse_bool(value)).unwrap_or(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_bool(value: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
value.trim().to_ascii_lowercase().as_str(),
|
||||||
|
"1" | "true" | "yes" | "on" | "enabled"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn required<'a>(value: Option<&'a str>, name: &str) -> anyhow::Result<&'a str> {
|
fn required<'a>(value: Option<&'a str>, name: &str) -> anyhow::Result<&'a str> {
|
||||||
value.ok_or_else(|| anyhow::anyhow!("{name} is not configured"))
|
value.ok_or_else(|| anyhow::anyhow!("{name} is not configured"))
|
||||||
}
|
}
|
||||||
|
|||||||
+520
-47
@@ -1,13 +1,16 @@
|
|||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::{json, Map, Value};
|
||||||
use sqlx::{FromRow, SqlitePool};
|
use sqlx::{FromRow, SqlitePool};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub const SYSTEM_PROXY_URL_SETTING_KEY: &str = "runtime.system_proxy_url";
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct OAuthService {
|
pub struct OAuthService {
|
||||||
db: Arc<SqlitePool>,
|
db: Arc<SqlitePool>,
|
||||||
client: reqwest::Client,
|
#[cfg(test)]
|
||||||
|
client_override: Option<reqwest::Client>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
@@ -26,6 +29,7 @@ pub struct OAuthRouteUnit {
|
|||||||
pub struct RefreshedToken {
|
pub struct RefreshedToken {
|
||||||
pub account_id: i64,
|
pub account_id: i64,
|
||||||
pub access_token: String,
|
pub access_token: String,
|
||||||
|
pub refresh_token: Option<String>,
|
||||||
pub expires_at: Option<String>,
|
pub expires_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +37,16 @@ impl OAuthService {
|
|||||||
pub fn new(db: Arc<SqlitePool>) -> Self {
|
pub fn new(db: Arc<SqlitePool>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
db,
|
db,
|
||||||
client: reqwest::Client::new(),
|
#[cfg(test)]
|
||||||
|
client_override: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn with_client(db: Arc<SqlitePool>, client: reqwest::Client) -> Self {
|
||||||
|
Self {
|
||||||
|
db,
|
||||||
|
client_override: Some(client),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +68,7 @@ impl OAuthService {
|
|||||||
return Ok(RefreshedToken {
|
return Ok(RefreshedToken {
|
||||||
account_id,
|
account_id,
|
||||||
access_token: current_access_token,
|
access_token: current_access_token,
|
||||||
|
refresh_token,
|
||||||
expires_at: token_expiry(extra_config.as_deref()),
|
expires_at: token_expiry(extra_config.as_deref()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -63,21 +77,24 @@ impl OAuthService {
|
|||||||
.filter(|token| !token.trim().is_empty())
|
.filter(|token| !token.trim().is_empty())
|
||||||
.ok_or_else(|| AppError::BadRequest("OAuth account has no refresh token".into()))?;
|
.ok_or_else(|| AppError::BadRequest("OAuth account has no refresh token".into()))?;
|
||||||
|
|
||||||
let mut refreshed = self.perform_refresh(site_id, &refresh_token).await?;
|
let (proxy_url, use_system_proxy) = oauth_proxy_settings(extra_config.as_deref());
|
||||||
|
let client = self
|
||||||
|
.configured_http_client(proxy_url.as_deref(), use_system_proxy)
|
||||||
|
.await?;
|
||||||
|
let mut refreshed = self
|
||||||
|
.perform_refresh(&client, site_id, &refresh_token)
|
||||||
|
.await?;
|
||||||
refreshed.account_id = account_id;
|
refreshed.account_id = account_id;
|
||||||
|
let next_extra_config =
|
||||||
|
merge_refreshed_extra_config(extra_config.as_deref(), &refreshed, &refresh_token)?;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE accounts
|
"UPDATE accounts
|
||||||
SET access_token = ?, extra_config = ?, updated_at = datetime('now')
|
SET access_token = ?, api_token = ?, extra_config = ?, updated_at = datetime('now')
|
||||||
WHERE id = ? AND site_id = ?",
|
WHERE id = ? AND site_id = ?",
|
||||||
)
|
)
|
||||||
.bind(&refreshed.access_token)
|
.bind(&refreshed.access_token)
|
||||||
.bind(
|
.bind(refreshed.refresh_token.as_deref().unwrap_or(&refresh_token))
|
||||||
serde_json::to_string(&serde_json::json!({
|
.bind(next_extra_config)
|
||||||
"expires_at": refreshed.expires_at,
|
|
||||||
"refreshed_at": chrono::Utc::now().to_rfc3339(),
|
|
||||||
}))
|
|
||||||
.map_err(|err| AppError::Internal(err.into()))?,
|
|
||||||
)
|
|
||||||
.bind(account_id)
|
.bind(account_id)
|
||||||
.bind(site_id)
|
.bind(site_id)
|
||||||
.execute(&*self.db)
|
.execute(&*self.db)
|
||||||
@@ -90,6 +107,19 @@ impl OAuthService {
|
|||||||
token_expired(extra_config)
|
token_expired(extra_config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn configured_http_client(
|
||||||
|
&self,
|
||||||
|
proxy_url: Option<&str>,
|
||||||
|
use_system_proxy: bool,
|
||||||
|
) -> AppResult<reqwest::Client> {
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Some(client) = &self.client_override {
|
||||||
|
return Ok(client.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
oauth_http_client(&*self.db, proxy_url, use_system_proxy).await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_route_units(&self, site_id: Option<i64>) -> AppResult<Vec<OAuthRouteUnit>> {
|
pub async fn list_route_units(&self, site_id: Option<i64>) -> AppResult<Vec<OAuthRouteUnit>> {
|
||||||
if let Some(site_id) = site_id {
|
if let Some(site_id) = site_id {
|
||||||
sqlx::query_as("SELECT * FROM oauth_route_units WHERE site_id = ? ORDER BY id")
|
sqlx::query_as("SELECT * FROM oauth_route_units WHERE site_id = ? ORDER BY id")
|
||||||
@@ -132,29 +162,37 @@ impl OAuthService {
|
|||||||
|
|
||||||
async fn perform_refresh(
|
async fn perform_refresh(
|
||||||
&self,
|
&self,
|
||||||
|
client: &reqwest::Client,
|
||||||
site_id: i64,
|
site_id: i64,
|
||||||
refresh_token: &str,
|
refresh_token: &str,
|
||||||
) -> AppResult<RefreshedToken> {
|
) -> AppResult<RefreshedToken> {
|
||||||
let token_endpoint: Option<String> = sqlx::query_scalar(
|
let raw_custom_headers: Option<String> =
|
||||||
"SELECT json_extract(custom_headers, '$.oauth_token_url') FROM sites WHERE id = ?",
|
sqlx::query_scalar("SELECT custom_headers FROM sites WHERE id = ?")
|
||||||
)
|
|
||||||
.bind(site_id)
|
.bind(site_id)
|
||||||
.fetch_optional(&*self.db)
|
.fetch_optional(&*self.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let Some(token_endpoint) = token_endpoint.filter(|url| !url.trim().is_empty()) else {
|
let token_config = oauth_token_config(raw_custom_headers.as_deref())?;
|
||||||
|
let Some(token_endpoint) = token_config.token_url.filter(|url| !url.trim().is_empty())
|
||||||
|
else {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"OAuth token endpoint is not configured for this site".into(),
|
"OAuth token endpoint is not configured for this site".into(),
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
let mut form = vec![
|
||||||
|
("grant_type".to_string(), "refresh_token".to_string()),
|
||||||
|
("refresh_token".to_string(), refresh_token.to_string()),
|
||||||
|
];
|
||||||
|
if let Some(client_id) = token_config.client_id {
|
||||||
|
form.push(("client_id".to_string(), client_id));
|
||||||
|
}
|
||||||
|
if let Some(client_secret) = token_config.client_secret {
|
||||||
|
form.push(("client_secret".to_string(), client_secret));
|
||||||
|
}
|
||||||
|
|
||||||
let response: Value = self
|
let response: Value = client
|
||||||
.client
|
|
||||||
.post(token_endpoint)
|
.post(token_endpoint)
|
||||||
.form(&[
|
.form(&form)
|
||||||
("grant_type", "refresh_token"),
|
|
||||||
("refresh_token", refresh_token),
|
|
||||||
])
|
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::UpstreamError(format!("OAuth refresh failed: {err}")))?
|
.map_err(|err| AppError::UpstreamError(format!("OAuth refresh failed: {err}")))?
|
||||||
@@ -162,46 +200,481 @@ impl OAuthService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::UpstreamError(format!("Invalid OAuth response: {err}")))?;
|
.map_err(|err| AppError::UpstreamError(format!("Invalid OAuth response: {err}")))?;
|
||||||
|
|
||||||
let access_token = response
|
let access_token = string_field(&response, &["access_token", "accessToken"])
|
||||||
.get("access_token")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.ok_or_else(|| AppError::UpstreamError("OAuth response missing access_token".into()))?
|
.ok_or_else(|| AppError::UpstreamError("OAuth response missing access_token".into()))?
|
||||||
.to_string();
|
.to_string();
|
||||||
let expires_at = response
|
let refresh_token =
|
||||||
.get("expires_at")
|
string_field(&response, &["refresh_token", "refreshToken"]).map(str::to_string);
|
||||||
.and_then(Value::as_str)
|
let expires_at = token_expiry_from_response(&response);
|
||||||
.map(str::to_string);
|
|
||||||
|
|
||||||
Ok(RefreshedToken {
|
Ok(RefreshedToken {
|
||||||
account_id: 0,
|
account_id: 0,
|
||||||
access_token,
|
access_token,
|
||||||
|
refresh_token,
|
||||||
expires_at,
|
expires_at,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn system_proxy_url(db: &SqlitePool) -> AppResult<Option<String>> {
|
||||||
|
let raw: Option<String> = sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
|
||||||
|
.bind(SYSTEM_PROXY_URL_SETTING_KEY)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?;
|
||||||
|
Ok(raw
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn oauth_http_client(
|
||||||
|
db: &SqlitePool,
|
||||||
|
proxy_url: Option<&str>,
|
||||||
|
use_system_proxy: bool,
|
||||||
|
) -> AppResult<reqwest::Client> {
|
||||||
|
let resolved_proxy = proxy_url
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
|
let resolved_proxy = match resolved_proxy {
|
||||||
|
Some(proxy_url) => Some(proxy_url),
|
||||||
|
None if use_system_proxy => system_proxy_url(db).await?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut builder = reqwest::Client::builder();
|
||||||
|
if let Some(proxy_url) = resolved_proxy {
|
||||||
|
let proxy = reqwest::Proxy::all(&proxy_url)
|
||||||
|
.map_err(|err| AppError::BadRequest(format!("Invalid OAuth proxy URL: {err}")))?;
|
||||||
|
builder = builder.proxy(proxy);
|
||||||
|
}
|
||||||
|
builder
|
||||||
|
.build()
|
||||||
|
.map_err(|err| AppError::Internal(err.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
struct OAuthTokenConfig {
|
||||||
|
token_url: Option<String>,
|
||||||
|
client_id: Option<String>,
|
||||||
|
client_secret: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_field<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a str> {
|
||||||
|
keys.iter()
|
||||||
|
.find_map(|key| value.get(*key))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn oauth_token_config(raw_custom_headers: Option<&str>) -> AppResult<OAuthTokenConfig> {
|
||||||
|
let Some(raw) = raw_custom_headers
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
else {
|
||||||
|
return Ok(OAuthTokenConfig::default());
|
||||||
|
};
|
||||||
|
let value = serde_json::from_str::<Value>(raw)
|
||||||
|
.map_err(|err| AppError::BadRequest(format!("Invalid site custom_headers JSON: {err}")))?;
|
||||||
|
let object = value
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| AppError::BadRequest("site custom_headers must be a JSON object".into()))?;
|
||||||
|
Ok(OAuthTokenConfig {
|
||||||
|
token_url: [
|
||||||
|
"oauth_token_url",
|
||||||
|
"oauth_token_endpoint",
|
||||||
|
"token_url",
|
||||||
|
"tokenUrl",
|
||||||
|
"tokenEndpoint",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.find_map(|key| object.get(*key))
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
client_id: ["oauth_client_id", "client_id", "clientId", "oauthClientId"]
|
||||||
|
.iter()
|
||||||
|
.find_map(|key| object.get(*key))
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
client_secret: [
|
||||||
|
"oauth_client_secret",
|
||||||
|
"client_secret",
|
||||||
|
"clientSecret",
|
||||||
|
"oauthClientSecret",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.find_map(|key| object.get(*key))
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_expiry_from_response(response: &Value) -> Option<String> {
|
||||||
|
for key in [
|
||||||
|
"expires_at",
|
||||||
|
"expiresAt",
|
||||||
|
"tokenExpiresAt",
|
||||||
|
"token_expires_at",
|
||||||
|
] {
|
||||||
|
if let Some(expires_at) = response.get(key).and_then(expiry_datetime_from_value) {
|
||||||
|
return Some(expires_at.to_rfc3339());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let expires_in = response
|
||||||
|
.get("expires_in")
|
||||||
|
.or_else(|| response.get("expiresIn"))
|
||||||
|
.and_then(|value| {
|
||||||
|
value
|
||||||
|
.as_i64()
|
||||||
|
.or_else(|| value.as_str()?.trim().parse::<i64>().ok())
|
||||||
|
})
|
||||||
|
.filter(|value| *value > 0)?;
|
||||||
|
Some((chrono::Utc::now() + chrono::Duration::seconds(expires_in)).to_rfc3339())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expiry_datetime_from_value(value: &Value) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||||
|
if let Some(number) = value.as_i64() {
|
||||||
|
return expiry_datetime_from_unixish(number);
|
||||||
|
}
|
||||||
|
let text = value.as_str()?.trim();
|
||||||
|
if text.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(text) {
|
||||||
|
return Some(parsed.with_timezone(&chrono::Utc));
|
||||||
|
}
|
||||||
|
text.parse::<i64>()
|
||||||
|
.ok()
|
||||||
|
.and_then(expiry_datetime_from_unixish)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expiry_datetime_from_unixish(value: i64) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||||
|
if value <= 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let millis = if value < 10_000_000_000 {
|
||||||
|
value.checked_mul(1000)?
|
||||||
|
} else {
|
||||||
|
value
|
||||||
|
};
|
||||||
|
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(millis)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_extra_config_object(raw_extra_config: Option<&str>) -> AppResult<Map<String, Value>> {
|
||||||
|
let Some(raw) = raw_extra_config
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
else {
|
||||||
|
return Ok(Map::new());
|
||||||
|
};
|
||||||
|
let value = serde_json::from_str::<Value>(raw)
|
||||||
|
.map_err(|err| AppError::BadRequest(format!("Invalid extra_config JSON: {err}")))?;
|
||||||
|
value
|
||||||
|
.as_object()
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| AppError::BadRequest("extra_config must be a JSON object".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn oauth_proxy_settings(raw_extra_config: Option<&str>) -> (Option<String>, bool) {
|
||||||
|
let Ok(config) = parse_extra_config_object(raw_extra_config) else {
|
||||||
|
return (None, false);
|
||||||
|
};
|
||||||
|
let proxy_url = config
|
||||||
|
.get("proxyUrl")
|
||||||
|
.or_else(|| config.get("proxy_url"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
|
let use_system_proxy = config
|
||||||
|
.get("useSystemProxy")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.or_else(|| config.get("use_system_proxy").and_then(Value::as_bool))
|
||||||
|
.unwrap_or(false);
|
||||||
|
(proxy_url, use_system_proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_refreshed_extra_config(
|
||||||
|
raw_extra_config: Option<&str>,
|
||||||
|
refreshed: &RefreshedToken,
|
||||||
|
previous_refresh_token: &str,
|
||||||
|
) -> AppResult<Option<String>> {
|
||||||
|
let mut config = parse_extra_config_object(raw_extra_config)?;
|
||||||
|
config.insert(
|
||||||
|
"refreshed_at".to_string(),
|
||||||
|
json!(chrono::Utc::now().to_rfc3339()),
|
||||||
|
);
|
||||||
|
if let Some(expires_at) = refreshed.expires_at.as_deref() {
|
||||||
|
config.insert("expires_at".to_string(), json!(expires_at));
|
||||||
|
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(expires_at) {
|
||||||
|
config.insert(
|
||||||
|
"tokenExpiresAt".to_string(),
|
||||||
|
json!(parsed.timestamp_millis()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.insert(
|
||||||
|
"refreshToken".to_string(),
|
||||||
|
json!(refreshed
|
||||||
|
.refresh_token
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(previous_refresh_token)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if config.is_empty() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
serde_json::to_string(&Value::Object(config))
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|err| AppError::Internal(err.into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn token_expired(extra_config: Option<&str>) -> bool {
|
fn token_expired(extra_config: Option<&str>) -> bool {
|
||||||
let Some(extra_config) = extra_config else {
|
token_expiry_datetime(extra_config)
|
||||||
return true;
|
|
||||||
};
|
|
||||||
let Ok(value) = serde_json::from_str::<Value>(extra_config) else {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
let Some(expires_at) = value.get("expires_at").and_then(Value::as_str) else {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
chrono::DateTime::parse_from_rfc3339(expires_at)
|
|
||||||
.map(|expires_at| expires_at <= chrono::Utc::now() + chrono::Duration::minutes(5))
|
.map(|expires_at| expires_at <= chrono::Utc::now() + chrono::Duration::minutes(5))
|
||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn token_expiry(extra_config: Option<&str>) -> Option<String> {
|
fn token_expiry(extra_config: Option<&str>) -> Option<String> {
|
||||||
extra_config
|
token_expiry_datetime(extra_config).map(|expires_at| expires_at.to_rfc3339())
|
||||||
.and_then(|config| serde_json::from_str::<Value>(config).ok())
|
}
|
||||||
.and_then(|value| {
|
|
||||||
value
|
fn token_expiry_datetime(extra_config: Option<&str>) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||||
.get("expires_at")
|
let value = extra_config.and_then(|config| serde_json::from_str::<Value>(config).ok())?;
|
||||||
.and_then(Value::as_str)
|
if let Some(expires_at) = value
|
||||||
.map(str::to_string)
|
.get("expires_at")
|
||||||
})
|
.or_else(|| value.get("expiresAt"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
{
|
||||||
|
return chrono::DateTime::parse_from_rfc3339(expires_at)
|
||||||
|
.map(|value| value.with_timezone(&chrono::Utc))
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
let millis = value
|
||||||
|
.get("tokenExpiresAt")
|
||||||
|
.or_else(|| value.get("token_expires_at"))
|
||||||
|
.and_then(expiry_datetime_from_value)?;
|
||||||
|
Some(millis)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
use wiremock::matchers::{body_string_contains, method, path};
|
||||||
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
|
|
||||||
|
async fn test_pool() -> SqlitePool {
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.expect("sqlite pool");
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE settings (
|
||||||
|
key TEXT PRIMARY KEY NOT NULL,
|
||||||
|
value TEXT
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("create settings");
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE sites (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
custom_headers TEXT
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("create sites");
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE accounts (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
access_token TEXT NOT NULL,
|
||||||
|
api_token TEXT,
|
||||||
|
extra_config TEXT,
|
||||||
|
oauth_provider TEXT,
|
||||||
|
updated_at TEXT
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("create accounts");
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn refresh_token_preserves_extra_config_and_updates_refresh_token() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
let mock = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/oauth/token"))
|
||||||
|
.and(body_string_contains("grant_type=refresh_token"))
|
||||||
|
.and(body_string_contains("refresh_token=refresh-old"))
|
||||||
|
.and(body_string_contains("client_id=client-a"))
|
||||||
|
.and(body_string_contains("client_secret=secret-a"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||||
|
"access_token": "access-new",
|
||||||
|
"refresh_token": "refresh-new",
|
||||||
|
"expires_at": 1893456000_i64
|
||||||
|
})))
|
||||||
|
.mount(&mock)
|
||||||
|
.await;
|
||||||
|
sqlx::query("INSERT INTO sites (id, custom_headers) VALUES (1, ?)")
|
||||||
|
.bind(
|
||||||
|
json!({
|
||||||
|
"oauth_token_url": format!("{}/oauth/token", mock.uri()),
|
||||||
|
"oauth_client_id": "client-a",
|
||||||
|
"oauth_client_secret": "secret-a"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("insert site");
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO accounts (id, site_id, access_token, api_token, extra_config, oauth_provider)
|
||||||
|
VALUES (7, 1, 'access-old', 'refresh-old', ?, 'codex')",
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
json!({
|
||||||
|
"credentialMode": "oauth",
|
||||||
|
"proxyUrl": "socks5://127.0.0.1:1080",
|
||||||
|
"expires_at": "2000-01-01T00:00:00Z"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("insert account");
|
||||||
|
|
||||||
|
let service = OAuthService::with_client(Arc::new(pool.clone()), reqwest::Client::new());
|
||||||
|
let refreshed = service.refresh_token(7, 1).await.expect("refresh token");
|
||||||
|
assert_eq!(refreshed.access_token, "access-new");
|
||||||
|
assert_eq!(refreshed.refresh_token.as_deref(), Some("refresh-new"));
|
||||||
|
assert!(refreshed.expires_at.is_some());
|
||||||
|
|
||||||
|
let row: (String, Option<String>, Option<String>) = sqlx::query_as(
|
||||||
|
"SELECT access_token, api_token, extra_config FROM accounts WHERE id = 7",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("account row");
|
||||||
|
assert_eq!(row.0, "access-new");
|
||||||
|
assert_eq!(row.1.as_deref(), Some("refresh-new"));
|
||||||
|
let extra: Value =
|
||||||
|
serde_json::from_str(row.2.as_deref().expect("extra config")).expect("extra json");
|
||||||
|
assert_eq!(
|
||||||
|
extra.get("credentialMode").and_then(Value::as_str),
|
||||||
|
Some("oauth")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
extra.get("proxyUrl").and_then(Value::as_str),
|
||||||
|
Some("socks5://127.0.0.1:1080")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
extra.get("refreshToken").and_then(Value::as_str),
|
||||||
|
Some("refresh-new")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
extra.get("expires_at").and_then(Value::as_str),
|
||||||
|
Some("2030-01-01T00:00:00+00:00")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
extra.get("tokenExpiresAt").and_then(Value::as_i64),
|
||||||
|
Some(1893456000000_i64)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_expiry_from_response_accepts_string_token_expires_at() {
|
||||||
|
assert_eq!(
|
||||||
|
token_expiry_from_response(&json!({ "tokenExpiresAt": "1893456000" })).as_deref(),
|
||||||
|
Some("2030-01-01T00:00:00+00:00")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
token_expiry_from_response(&json!({ "token_expires_at": 1893456000000_i64 }))
|
||||||
|
.as_deref(),
|
||||||
|
Some("2030-01-01T00:00:00+00:00")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn refresh_token_keeps_current_token_when_not_expired() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
sqlx::query("INSERT INTO sites (id, custom_headers) VALUES (1, '{}')")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("insert site");
|
||||||
|
let future = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO accounts (id, site_id, access_token, api_token, extra_config, oauth_provider)
|
||||||
|
VALUES (7, 1, 'access-current', 'refresh-current', ?, 'codex')",
|
||||||
|
)
|
||||||
|
.bind(json!({ "expires_at": future }).to_string())
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("insert account");
|
||||||
|
|
||||||
|
let service = OAuthService::with_client(Arc::new(pool), reqwest::Client::new());
|
||||||
|
let refreshed = service.refresh_token(7, 1).await.expect("current token");
|
||||||
|
assert_eq!(refreshed.access_token, "access-current");
|
||||||
|
assert_eq!(refreshed.refresh_token.as_deref(), Some("refresh-current"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn refresh_token_keeps_current_token_when_token_expires_at_is_future() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
sqlx::query("INSERT INTO sites (id, custom_headers) VALUES (1, '{}')")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("insert site");
|
||||||
|
let future_millis = (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp_millis();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO accounts (id, site_id, access_token, api_token, extra_config, oauth_provider)
|
||||||
|
VALUES (7, 1, 'access-current', 'refresh-current', ?, 'codex')",
|
||||||
|
)
|
||||||
|
.bind(json!({ "tokenExpiresAt": future_millis }).to_string())
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("insert account");
|
||||||
|
|
||||||
|
let service = OAuthService::with_client(Arc::new(pool), reqwest::Client::new());
|
||||||
|
let refreshed = service.refresh_token(7, 1).await.expect("current token");
|
||||||
|
assert_eq!(refreshed.access_token, "access-current");
|
||||||
|
assert_eq!(refreshed.refresh_token.as_deref(), Some("refresh-current"));
|
||||||
|
assert!(refreshed
|
||||||
|
.expires_at
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|value| value.contains('T')));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn system_proxy_url_reads_trimmed_runtime_setting() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
assert_eq!(system_proxy_url(&pool).await.unwrap(), None);
|
||||||
|
|
||||||
|
sqlx::query("INSERT INTO settings (key, value) VALUES (?, ?)")
|
||||||
|
.bind(SYSTEM_PROXY_URL_SETTING_KEY)
|
||||||
|
.bind(" http://127.0.0.1:7890 ")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("insert system proxy");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
system_proxy_url(&pool).await.unwrap().as_deref(),
|
||||||
|
Some("http://127.0.0.1:7890")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -179,7 +179,13 @@ mod tests {
|
|||||||
fn captcha_is_one_time() {
|
fn captcha_is_one_time() {
|
||||||
let store = RegisterCaptchaStore::new();
|
let store = RegisterCaptchaStore::new();
|
||||||
let challenge = store.issue(None);
|
let challenge = store.issue(None);
|
||||||
let answer = store.inner.lock().get(&challenge.id).unwrap().answer.clone();
|
let answer = store
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.get(&challenge.id)
|
||||||
|
.unwrap()
|
||||||
|
.answer
|
||||||
|
.clone();
|
||||||
|
|
||||||
store.verify(&challenge.id, &answer, None).unwrap();
|
store.verify(&challenge.id, &answer, None).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -192,7 +198,13 @@ mod tests {
|
|||||||
fn captcha_rejects_other_ip() {
|
fn captcha_rejects_other_ip() {
|
||||||
let store = RegisterCaptchaStore::new();
|
let store = RegisterCaptchaStore::new();
|
||||||
let challenge = store.issue(Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
|
let challenge = store.issue(Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
|
||||||
let answer = store.inner.lock().get(&challenge.id).unwrap().answer.clone();
|
let answer = store
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.get(&challenge.id)
|
||||||
|
.unwrap()
|
||||||
|
.answer
|
||||||
|
.clone();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store
|
store
|
||||||
|
|||||||
@@ -51,13 +51,16 @@ pub struct RebuildSummary {
|
|||||||
struct AvailRow {
|
struct AvailRow {
|
||||||
account_id: i64,
|
account_id: i64,
|
||||||
model_name: String,
|
model_name: String,
|
||||||
|
oauth_route_unit_id: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the rebuild against the live database. The whole thing runs inside a
|
/// Run the rebuild against the live database. The whole thing runs inside a
|
||||||
/// single transaction so a partial failure doesn't leave `token_routes` and
|
/// single transaction so a partial failure doesn't leave `token_routes` and
|
||||||
/// `route_channels` out of sync.
|
/// `route_channels` out of sync.
|
||||||
pub async fn rebuild_routes(state: &AppState) -> AppResult<RebuildSummary> {
|
pub async fn rebuild_routes(state: &AppState) -> AppResult<RebuildSummary> {
|
||||||
rebuild_routes_inner(&state.db).await
|
let summary = rebuild_routes_inner(&state.db).await?;
|
||||||
|
crate::proxy::token_router::router::invalidate_route_cache();
|
||||||
|
Ok(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pool-only entry point. Identical to `rebuild_routes`, just decoupled from
|
/// Pool-only entry point. Identical to `rebuild_routes`, just decoupled from
|
||||||
@@ -73,10 +76,11 @@ pub(crate) async fn rebuild_routes_inner(
|
|||||||
// lets us drop inactive accounts and apply site_disabled_models in one
|
// lets us drop inactive accounts and apply site_disabled_models in one
|
||||||
// query — keeping the in-memory set small for installs with hundreds of
|
// query — keeping the in-memory set small for installs with hundreds of
|
||||||
// accounts.
|
// accounts.
|
||||||
let rows: Vec<(i64, String)> = sqlx::query_as(
|
let rows: Vec<(i64, String, Option<i64>)> = sqlx::query_as(
|
||||||
"SELECT ma.account_id, ma.model_name
|
"SELECT ma.account_id, ma.model_name, rum.unit_id
|
||||||
FROM model_availability ma
|
FROM model_availability ma
|
||||||
JOIN accounts a ON a.id = ma.account_id
|
JOIN accounts a ON a.id = ma.account_id
|
||||||
|
LEFT JOIN oauth_route_unit_members rum ON rum.account_id = a.id
|
||||||
WHERE COALESCE(ma.available, 0) = 1
|
WHERE COALESCE(ma.available, 0) = 1
|
||||||
AND a.status = 'active'
|
AND a.status = 'active'
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
@@ -91,9 +95,10 @@ pub(crate) async fn rebuild_routes_inner(
|
|||||||
|
|
||||||
let avail: Vec<AvailRow> = rows
|
let avail: Vec<AvailRow> = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(account_id, model_name)| AvailRow {
|
.map(|(account_id, model_name, oauth_route_unit_id)| AvailRow {
|
||||||
account_id,
|
account_id,
|
||||||
model_name,
|
model_name,
|
||||||
|
oauth_route_unit_id,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -110,11 +115,16 @@ pub(crate) async fn rebuild_routes_inner(
|
|||||||
|
|
||||||
// 2. Group by model name. Each group becomes one token_routes row.
|
// 2. Group by model name. Each group becomes one token_routes row.
|
||||||
let mut by_model: HashMap<String, Vec<i64>> = HashMap::new();
|
let mut by_model: HashMap<String, Vec<i64>> = HashMap::new();
|
||||||
|
let mut route_unit_by_key: HashMap<(i64, String), Option<i64>> = HashMap::new();
|
||||||
for row in &avail {
|
for row in &avail {
|
||||||
by_model
|
by_model
|
||||||
.entry(row.model_name.clone())
|
.entry(row.model_name.clone())
|
||||||
.or_default()
|
.or_default()
|
||||||
.push(row.account_id);
|
.push(row.account_id);
|
||||||
|
route_unit_by_key.insert(
|
||||||
|
(row.account_id, row.model_name.clone()),
|
||||||
|
row.oauth_route_unit_id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Pre-load existing routes by model_pattern so we can reuse rows. The
|
// 3. Pre-load existing routes by model_pattern so we can reuse rows. The
|
||||||
@@ -209,21 +219,41 @@ pub(crate) async fn rebuild_routes_inner(
|
|||||||
.fetch_optional(&mut *tx)
|
.fetch_optional(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let oauth_route_unit_id = route_unit_by_key.get(key).copied().flatten();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO route_channels
|
"INSERT INTO route_channels
|
||||||
(route_id, account_id, token_id, source_model,
|
(route_id, account_id, token_id, source_model,
|
||||||
priority, weight, enabled, manual_override)
|
oauth_route_unit_id, priority, weight, enabled, manual_override)
|
||||||
VALUES (?, ?, ?, ?, 0, 10, 1, 0)",
|
VALUES (?, ?, ?, ?, ?, 0, 10, 1, 0)",
|
||||||
)
|
)
|
||||||
.bind(route_id)
|
.bind(route_id)
|
||||||
.bind(key.0)
|
.bind(key.0)
|
||||||
.bind(token_id)
|
.bind(token_id)
|
||||||
.bind(&key.1)
|
.bind(&key.1)
|
||||||
|
.bind(oauth_route_unit_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
summary.created_channels += 1;
|
summary.created_channels += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep OAuth route-unit annotations in sync for existing auto-managed channels.
|
||||||
|
for (key, ch_id) in &auto_by_key {
|
||||||
|
if !desired.contains(key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let oauth_route_unit_id = route_unit_by_key.get(key).copied().flatten();
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE route_channels
|
||||||
|
SET oauth_route_unit_id = ?
|
||||||
|
WHERE id = ?
|
||||||
|
AND COALESCE(manual_override, 0) = 0",
|
||||||
|
)
|
||||||
|
.bind(oauth_route_unit_id)
|
||||||
|
.bind(ch_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
// Delete auto channels that are no longer desired (account dropped the
|
// Delete auto channels that are no longer desired (account dropped the
|
||||||
// model, or the model is now in site_disabled_models).
|
// model, or the model is now in site_disabled_models).
|
||||||
for (key, ch_id) in &auto_by_key {
|
for (key, ch_id) in &auto_by_key {
|
||||||
@@ -377,6 +407,7 @@ mod tests {
|
|||||||
route_id INTEGER NOT NULL,
|
route_id INTEGER NOT NULL,
|
||||||
account_id INTEGER NOT NULL,
|
account_id INTEGER NOT NULL,
|
||||||
token_id INTEGER,
|
token_id INTEGER,
|
||||||
|
oauth_route_unit_id INTEGER,
|
||||||
source_model TEXT,
|
source_model TEXT,
|
||||||
priority INTEGER DEFAULT 0,
|
priority INTEGER DEFAULT 0,
|
||||||
weight INTEGER DEFAULT 10,
|
weight INTEGER DEFAULT 10,
|
||||||
@@ -389,6 +420,20 @@ mod tests {
|
|||||||
enabled INTEGER DEFAULT 1,
|
enabled INTEGER DEFAULT 1,
|
||||||
is_default INTEGER DEFAULT 0
|
is_default INTEGER DEFAULT 0
|
||||||
)",
|
)",
|
||||||
|
"CREATE TABLE oauth_route_units (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
strategy TEXT NOT NULL DEFAULT 'round_robin',
|
||||||
|
enabled INTEGER DEFAULT 1
|
||||||
|
)",
|
||||||
|
"CREATE TABLE oauth_route_unit_members (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
unit_id INTEGER NOT NULL,
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
sort_order INTEGER DEFAULT 0
|
||||||
|
)",
|
||||||
] {
|
] {
|
||||||
sqlx::query(ddl)
|
sqlx::query(ddl)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
@@ -522,6 +567,69 @@ mod tests {
|
|||||||
assert_eq!(route_count, 1);
|
assert_eq!(route_count, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rebuild_marks_auto_channels_with_oauth_route_unit_id() {
|
||||||
|
let pool = fixture_pool().await;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO accounts (id, site_id, status) VALUES
|
||||||
|
(1, 1, 'active'),
|
||||||
|
(2, 1, 'active')",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO model_availability (account_id, model_name, available) VALUES
|
||||||
|
(1, 'gpt-oauth', 1),
|
||||||
|
(2, 'gpt-oauth', 1)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO oauth_route_units (id, site_id, provider, name, strategy)
|
||||||
|
VALUES (9, 1, 'codex', 'Codex Pool', 'round_robin')",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO oauth_route_unit_members (unit_id, account_id, sort_order) VALUES
|
||||||
|
(9, 1, 0),
|
||||||
|
(9, 2, 1)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let summary = rebuild_routes_inner(&pool).await.expect("rebuild");
|
||||||
|
assert_eq!(summary.created_channels, 2);
|
||||||
|
|
||||||
|
let unit_ids: Vec<Option<i64>> = sqlx::query_scalar(
|
||||||
|
"SELECT oauth_route_unit_id FROM route_channels ORDER BY account_id",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(unit_ids, vec![Some(9), Some(9)]);
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM oauth_route_unit_members WHERE unit_id = 9")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let summary = rebuild_routes_inner(&pool).await.expect("second rebuild");
|
||||||
|
assert_eq!(summary.created_channels, 0);
|
||||||
|
|
||||||
|
let unit_ids: Vec<Option<i64>> = sqlx::query_scalar(
|
||||||
|
"SELECT oauth_route_unit_id FROM route_channels ORDER BY account_id",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(unit_ids, vec![None, None]);
|
||||||
|
}
|
||||||
|
|
||||||
/// User-reported bug: empty DB still shows hundreds of routes on the
|
/// User-reported bug: empty DB still shows hundreds of routes on the
|
||||||
/// `/routes` page, and clicking "rebuild" does nothing. Reproduces by
|
/// `/routes` page, and clicking "rebuild" does nothing. Reproduces by
|
||||||
/// pre-seeding `token_routes` rows that no account / availability row
|
/// pre-seeding `token_routes` rows that no account / availability row
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
|
||||||
|
pub const PROXY_TOKEN_SETTING_KEY: &str = "runtime.auth.proxy_token";
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RuntimeAuthSettings {
|
||||||
|
default_proxy_token: String,
|
||||||
|
proxy_token: RwLock<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeAuthSettings {
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn from_token_for_test(proxy_token: impl Into<String>) -> Self {
|
||||||
|
let proxy_token = proxy_token.into();
|
||||||
|
Self {
|
||||||
|
default_proxy_token: proxy_token.clone(),
|
||||||
|
proxy_token: RwLock::new(proxy_token),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load(pool: &SqlitePool, default_proxy_token: String) -> anyhow::Result<Self> {
|
||||||
|
let stored: Option<String> =
|
||||||
|
sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
|
||||||
|
.bind(PROXY_TOKEN_SETTING_KEY)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
let proxy_token = stored
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(default_proxy_token.trim())
|
||||||
|
.to_string();
|
||||||
|
Ok(Self {
|
||||||
|
default_proxy_token,
|
||||||
|
proxy_token: RwLock::new(proxy_token),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn proxy_token(&self) -> String {
|
||||||
|
self.proxy_token.read().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_proxy_token(
|
||||||
|
&self,
|
||||||
|
pool: &SqlitePool,
|
||||||
|
proxy_token: String,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
|
let normalized = normalize_proxy_token(&proxy_token)?;
|
||||||
|
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||||
|
.bind(PROXY_TOKEN_SETTING_KEY)
|
||||||
|
.bind(&normalized)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
*self.proxy_token.write() = normalized.clone();
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn proxy_token_masked(&self) -> String {
|
||||||
|
mask_token(&self.proxy_token())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn using_default_proxy_token(&self) -> bool {
|
||||||
|
self.proxy_token() == self.default_proxy_token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_proxy_token(value: &str) -> anyhow::Result<String> {
|
||||||
|
let token = value.trim();
|
||||||
|
if !token.starts_with("sk-") {
|
||||||
|
anyhow::bail!("下游访问令牌必须以 sk- 开头");
|
||||||
|
}
|
||||||
|
if token.chars().count() < 6 {
|
||||||
|
anyhow::bail!("下游访问令牌至少 6 位(含 sk-)");
|
||||||
|
}
|
||||||
|
Ok(token.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mask_token(value: &str) -> String {
|
||||||
|
let token = value.trim();
|
||||||
|
if token.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let suffix: String = token
|
||||||
|
.chars()
|
||||||
|
.rev()
|
||||||
|
.take(4)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.collect();
|
||||||
|
format!("••••{}", suffix)
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
use chrono::{SecondsFormat, Utc};
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::fmt;
|
||||||
|
use std::sync::atomic::{AtomicI64, Ordering};
|
||||||
|
use tracing::{
|
||||||
|
field::{Field, Visit},
|
||||||
|
Level, Subscriber,
|
||||||
|
};
|
||||||
|
use tracing_subscriber::{layer::Context, Layer};
|
||||||
|
|
||||||
|
const MAX_RUNTIME_LOGS: usize = 500;
|
||||||
|
|
||||||
|
static NEXT_ID: AtomicI64 = AtomicI64::new(1);
|
||||||
|
static RUNTIME_LOGS: Lazy<RwLock<VecDeque<RuntimeLogEntry>>> =
|
||||||
|
Lazy::new(|| RwLock::new(VecDeque::with_capacity(MAX_RUNTIME_LOGS)));
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RuntimeLogEntry {
|
||||||
|
pub id: i64,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub event_type: String,
|
||||||
|
pub title: String,
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub level: String,
|
||||||
|
#[serde(rename = "read")]
|
||||||
|
pub is_read: bool,
|
||||||
|
pub related_id: Option<i64>,
|
||||||
|
pub related_type: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct RuntimeLogLayer;
|
||||||
|
|
||||||
|
impl<S> Layer<S> for RuntimeLogLayer
|
||||||
|
where
|
||||||
|
S: Subscriber,
|
||||||
|
{
|
||||||
|
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
|
||||||
|
let metadata = event.metadata();
|
||||||
|
let level = metadata.level();
|
||||||
|
if !matches!(*level, Level::ERROR | Level::WARN | Level::INFO) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut visitor = LogFieldVisitor::default();
|
||||||
|
event.record(&mut visitor);
|
||||||
|
|
||||||
|
let title = visitor
|
||||||
|
.message
|
||||||
|
.unwrap_or_else(|| metadata.name().to_string());
|
||||||
|
let mut details = Vec::with_capacity(visitor.fields.len() + 1);
|
||||||
|
details.push(metadata.target().to_string());
|
||||||
|
details.extend(visitor.fields);
|
||||||
|
|
||||||
|
push(RuntimeLogEntry {
|
||||||
|
id: -NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
||||||
|
event_type: "runtime".to_string(),
|
||||||
|
title,
|
||||||
|
message: Some(details.join(" · ")).filter(|value| !value.trim().is_empty()),
|
||||||
|
level: level_name(level).to_string(),
|
||||||
|
is_read: true,
|
||||||
|
related_id: None,
|
||||||
|
related_type: None,
|
||||||
|
created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list() -> Vec<RuntimeLogEntry> {
|
||||||
|
RUNTIME_LOGS.read().iter().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear() {
|
||||||
|
RUNTIME_LOGS.write().clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(entry: RuntimeLogEntry) {
|
||||||
|
let mut logs = RUNTIME_LOGS.write();
|
||||||
|
if logs.len() >= MAX_RUNTIME_LOGS {
|
||||||
|
logs.pop_front();
|
||||||
|
}
|
||||||
|
logs.push_back(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn level_name(level: &Level) -> &'static str {
|
||||||
|
match *level {
|
||||||
|
Level::ERROR => "error",
|
||||||
|
Level::WARN => "warning",
|
||||||
|
_ => "info",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct LogFieldVisitor {
|
||||||
|
message: Option<String>,
|
||||||
|
fields: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogFieldVisitor {
|
||||||
|
fn record_value(&mut self, field: &Field, value: String) {
|
||||||
|
if field.name() == "message" {
|
||||||
|
self.message = Some(value);
|
||||||
|
} else {
|
||||||
|
self.fields.push(format!("{}={}", field.name(), value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Visit for LogFieldVisitor {
|
||||||
|
fn record_str(&mut self, field: &Field, value: &str) {
|
||||||
|
self.record_value(field, value.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_bool(&mut self, field: &Field, value: bool) {
|
||||||
|
self.record_value(field, value.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_i64(&mut self, field: &Field, value: i64) {
|
||||||
|
self.record_value(field, value.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_u64(&mut self, field: &Field, value: u64) {
|
||||||
|
self.record_value(field, value.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) {
|
||||||
|
self.record_value(field, value.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
|
||||||
|
self.record_value(field, normalize_debug_value(format!("{value:?}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_debug_value(value: String) -> String {
|
||||||
|
if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') {
|
||||||
|
value[1..value.len() - 1].replace("\\\"", "\"")
|
||||||
|
} else {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tracing_subscriber::prelude::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_log_layer_captures_info_events() {
|
||||||
|
clear();
|
||||||
|
let subscriber = tracing_subscriber::registry().with(RuntimeLogLayer);
|
||||||
|
|
||||||
|
tracing::subscriber::with_default(subscriber, || {
|
||||||
|
tracing::info!(key_id = 42, "Built-in key ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
let logs = list();
|
||||||
|
let entry = logs.last().expect("runtime log entry");
|
||||||
|
assert_eq!(entry.event_type, "runtime");
|
||||||
|
assert_eq!(entry.title, "Built-in key ready");
|
||||||
|
assert_eq!(entry.level, "info");
|
||||||
|
assert_eq!(entry.is_read, true);
|
||||||
|
assert!(entry
|
||||||
|
.message
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("key_id=42"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,8 +21,17 @@ pub async fn enforce_key_request_limit(
|
|||||||
return Err(AppError::Forbidden("API key is disabled".into()));
|
return Err(AppError::Forbidden("API key is disabled".into()));
|
||||||
}
|
}
|
||||||
if let Some(max_requests) = max_requests {
|
if let Some(max_requests) = max_requests {
|
||||||
|
if max_requests <= 0 {
|
||||||
|
return Err(AppError::RateLimitedWithRetry {
|
||||||
|
message: "下游密钥请求额度已用完".into(),
|
||||||
|
retry_after_secs: 60,
|
||||||
|
});
|
||||||
|
}
|
||||||
if used_requests.unwrap_or(0) + increment > max_requests {
|
if used_requests.unwrap_or(0) + increment > max_requests {
|
||||||
return Err(AppError::RateLimited);
|
return Err(AppError::RateLimitedWithRetry {
|
||||||
|
message: "下游密钥请求额度已用完".into(),
|
||||||
|
retry_after_secs: 60,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
use crate::error::{AppError, AppResult};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value;
|
|
||||||
use sqlx::SqlitePool;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
const UPDATE_CONFIG_KEY: &str = "update_center_config";
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct UpdateCenter {
|
|
||||||
db: Arc<SqlitePool>,
|
|
||||||
client: reqwest::Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct UpdateInfo {
|
|
||||||
pub current_version: String,
|
|
||||||
pub latest_version: Option<String>,
|
|
||||||
pub update_available: bool,
|
|
||||||
pub release_notes: Option<String>,
|
|
||||||
pub download_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UpdateCenter {
|
|
||||||
pub fn new(db: Arc<SqlitePool>) -> Self {
|
|
||||||
Self {
|
|
||||||
db,
|
|
||||||
client: reqwest::Client::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn check_for_updates(&self) -> AppResult<UpdateInfo> {
|
|
||||||
let config = self.load_config().await?;
|
|
||||||
let current_version = env!("CARGO_PKG_VERSION").to_string();
|
|
||||||
let Some(endpoint) = config
|
|
||||||
.get("check_url")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|url| !url.trim().is_empty())
|
|
||||||
else {
|
|
||||||
return Ok(UpdateInfo {
|
|
||||||
current_version,
|
|
||||||
latest_version: None,
|
|
||||||
update_available: false,
|
|
||||||
release_notes: None,
|
|
||||||
download_url: None,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
let payload: Value = self
|
|
||||||
.client
|
|
||||||
.get(endpoint)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|err| AppError::UpstreamError(format!("Update check failed: {err}")))?
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|err| AppError::UpstreamError(format!("Invalid update response: {err}")))?;
|
|
||||||
|
|
||||||
let latest_version = payload
|
|
||||||
.get("version")
|
|
||||||
.or_else(|| payload.get("latestVersion"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_string);
|
|
||||||
let update_available = latest_version
|
|
||||||
.as_deref()
|
|
||||||
.map(|latest| latest != current_version)
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
Ok(UpdateInfo {
|
|
||||||
current_version,
|
|
||||||
latest_version,
|
|
||||||
update_available,
|
|
||||||
release_notes: payload
|
|
||||||
.get("release_notes")
|
|
||||||
.or_else(|| payload.get("releaseNotes"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_string),
|
|
||||||
download_url: payload
|
|
||||||
.get("download_url")
|
|
||||||
.or_else(|| payload.get("downloadUrl"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_string),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn apply_update(&self, version: &str) -> AppResult<String> {
|
|
||||||
let task_id = format!("update-{}", uuid::Uuid::new_v4());
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO background_tasks
|
|
||||||
(id, task_type, title, status, message, dedupe_key, created_at, updated_at)
|
|
||||||
VALUES (?, 'update-center-deploy', ?, 'pending', ?, ?, datetime('now'), datetime('now'))",
|
|
||||||
)
|
|
||||||
.bind(&task_id)
|
|
||||||
.bind(format!("Apply BoosAPI {version}"))
|
|
||||||
.bind("Update request accepted; an external supervisor should perform download and restart")
|
|
||||||
.bind(format!("update-center:deploy:{version}"))
|
|
||||||
.execute(&*self.db)
|
|
||||||
.await?;
|
|
||||||
Ok(task_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_config(&self) -> AppResult<Value> {
|
|
||||||
let raw: Option<String> = sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
|
|
||||||
.bind(UPDATE_CONFIG_KEY)
|
|
||||||
.fetch_optional(&*self.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
raw.as_deref()
|
|
||||||
.map(serde_json::from_str)
|
|
||||||
.transpose()
|
|
||||||
.map_err(|err| AppError::BadRequest(format!("Invalid update center config: {err}")))?
|
|
||||||
.map(Ok)
|
|
||||||
.unwrap_or_else(|| Ok(Value::Object(Default::default())))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+110
-7
@@ -160,6 +160,23 @@ async fn insert_asset(
|
|||||||
.execute(&*state.db)
|
.execute(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if should_project_workspace_asset(status, &metadata) {
|
||||||
|
if let Err(err) = upsert_workspace_asset_daily(
|
||||||
|
state,
|
||||||
|
None,
|
||||||
|
input.user_id,
|
||||||
|
&input.source,
|
||||||
|
&input.kind,
|
||||||
|
size_bytes,
|
||||||
|
file_path.is_some(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(%err, "failed to update workspace asset projection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(StoredWorkspaceAsset {
|
Ok(StoredWorkspaceAsset {
|
||||||
public_id: public_id.to_string(),
|
public_id: public_id.to_string(),
|
||||||
file_name,
|
file_name,
|
||||||
@@ -169,6 +186,51 @@ async fn insert_asset(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn should_project_workspace_asset(status: &str, metadata: &Value) -> bool {
|
||||||
|
status == "completed"
|
||||||
|
&& (metadata.pointer("/workspaceAsset/role").and_then(Value::as_str) == Some("output")
|
||||||
|
|| metadata.pointer("/workflowUi/type").and_then(Value::as_str) == Some("output")
|
||||||
|
|| metadata.get("responseItem").is_some()
|
||||||
|
|| metadata.get("moneyPrinter").is_some()
|
||||||
|
|| metadata.get("digitalHuman").is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert_workspace_asset_daily(
|
||||||
|
state: &AppState,
|
||||||
|
local_day: Option<&str>,
|
||||||
|
user_id: Option<i64>,
|
||||||
|
source: &str,
|
||||||
|
kind: &str,
|
||||||
|
size_bytes: i64,
|
||||||
|
has_local_file: bool,
|
||||||
|
delta: i64,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let asset_count_delta = delta;
|
||||||
|
let local_file_delta = if has_local_file { delta } else { 0 };
|
||||||
|
let size_delta = size_bytes.max(0) * delta;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO workspace_asset_daily
|
||||||
|
(local_day, user_id, source, kind, asset_count, local_file_count, total_size_bytes, updated_at)
|
||||||
|
VALUES (COALESCE(?, date('now', 'localtime')), ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(local_day, user_id, source, kind) DO UPDATE SET
|
||||||
|
asset_count = MAX(0, asset_count + excluded.asset_count),
|
||||||
|
local_file_count = MAX(0, local_file_count + excluded.local_file_count),
|
||||||
|
total_size_bytes = MAX(0, total_size_bytes + excluded.total_size_bytes),
|
||||||
|
updated_at = datetime('now')",
|
||||||
|
)
|
||||||
|
.bind(local_day)
|
||||||
|
.bind(user_id.unwrap_or(0))
|
||||||
|
.bind(source)
|
||||||
|
.bind(kind)
|
||||||
|
.bind(asset_count_delta)
|
||||||
|
.bind(local_file_delta)
|
||||||
|
.bind(size_delta)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn record_remote_asset(
|
pub async fn record_remote_asset(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
input: WorkspaceAssetInput,
|
input: WorkspaceAssetInput,
|
||||||
@@ -188,11 +250,7 @@ pub async fn record_remote_asset(
|
|||||||
.await;
|
.await;
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
match state.http.get(url).send().await {
|
||||||
.timeout(std::time::Duration::from_secs(60))
|
|
||||||
.build()
|
|
||||||
.unwrap_or_else(|_| reqwest::Client::new());
|
|
||||||
match client.get(url).send().await {
|
|
||||||
Ok(response) if response.status().is_success() => {
|
Ok(response) if response.status().is_success() => {
|
||||||
let mime_type = response
|
let mime_type = response
|
||||||
.headers()
|
.headers()
|
||||||
@@ -307,6 +365,45 @@ pub async fn record_bytes_asset(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn project_deleted_asset(state: &AppState, public_id: &str) -> Result<(), AppError> {
|
||||||
|
use sqlx::Row;
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT user_id, source, kind, status, metadata, size_bytes, file_path, date(created_at, 'localtime') AS local_day
|
||||||
|
FROM workspace_assets
|
||||||
|
WHERE public_id = ?
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(public_id)
|
||||||
|
.fetch_optional(&*state.db)
|
||||||
|
.await?;
|
||||||
|
let Some(row) = row else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let status: String = row.try_get("status").unwrap_or_default();
|
||||||
|
let metadata_raw: String = row.try_get("metadata").unwrap_or_else(|_| "{}".to_string());
|
||||||
|
let metadata = serde_json::from_str::<Value>(&metadata_raw).unwrap_or_else(|_| json!({}));
|
||||||
|
if !should_project_workspace_asset(&status, &metadata) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let source: String = row.try_get("source").unwrap_or_default();
|
||||||
|
let kind: String = row.try_get("kind").unwrap_or_default();
|
||||||
|
let user_id: Option<i64> = row.try_get("user_id").ok().flatten();
|
||||||
|
let size_bytes: i64 = row.try_get("size_bytes").unwrap_or_default();
|
||||||
|
let file_path: Option<String> = row.try_get("file_path").ok().flatten();
|
||||||
|
let local_day: Option<String> = row.try_get("local_day").ok().flatten();
|
||||||
|
upsert_workspace_asset_daily(
|
||||||
|
state,
|
||||||
|
local_day.as_deref(),
|
||||||
|
user_id,
|
||||||
|
&source,
|
||||||
|
&kind,
|
||||||
|
size_bytes,
|
||||||
|
file_path.as_deref().is_some_and(|value| !value.trim().is_empty()),
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn record_image_assets_from_response(
|
pub async fn record_image_assets_from_response(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
user_id: Option<i64>,
|
user_id: Option<i64>,
|
||||||
@@ -338,7 +435,10 @@ pub async fn record_image_assets_from_response(
|
|||||||
prompt: prompt.clone(),
|
prompt: prompt.clone(),
|
||||||
model: model.clone(),
|
model: model.clone(),
|
||||||
remote_url: Some(url.trim().to_string()),
|
remote_url: Some(url.trim().to_string()),
|
||||||
metadata: json!({ "responseItem": item }),
|
metadata: json!({
|
||||||
|
"workspaceAsset": { "role": "output" },
|
||||||
|
"responseItem": item,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
match record_remote_asset(state, input).await {
|
match record_remote_asset(state, input).await {
|
||||||
Ok(asset) => stored_assets.push(asset),
|
Ok(asset) => stored_assets.push(asset),
|
||||||
@@ -357,7 +457,10 @@ pub async fn record_image_assets_from_response(
|
|||||||
prompt: prompt.clone(),
|
prompt: prompt.clone(),
|
||||||
model: model.clone(),
|
model: model.clone(),
|
||||||
remote_url: None,
|
remote_url: None,
|
||||||
metadata: json!({ "responseItem": item }),
|
metadata: json!({
|
||||||
|
"workspaceAsset": { "role": "output" },
|
||||||
|
"responseItem": item,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
match record_base64_asset(state, input, b64).await {
|
match record_base64_asset(state, input, b64).await {
|
||||||
Ok(asset) => stored_assets.push(asset),
|
Ok(asset) => stored_assets.push(asset),
|
||||||
|
|||||||
Vendored
-6
@@ -1,6 +0,0 @@
|
|||||||
export declare function parseBatchApiKeys(input: unknown): string[];
|
|
||||||
export declare function buildBatchApiKeyConnectionName(
|
|
||||||
baseName: string | null | undefined,
|
|
||||||
index: number,
|
|
||||||
total: number,
|
|
||||||
): string;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export {
|
|
||||||
buildBatchApiKeyConnectionName,
|
|
||||||
parseBatchApiKeys,
|
|
||||||
} from '../server/services/shared/apiKeyBatchCore.js';
|
|
||||||
Vendored
-40
@@ -1,40 +0,0 @@
|
|||||||
export type UpdateVersionCandidateLike = {
|
|
||||||
normalizedVersion?: string | null;
|
|
||||||
displayVersion?: string | null;
|
|
||||||
tagName?: string | null;
|
|
||||||
digest?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateHelperRuntimeLike = {
|
|
||||||
imageTag?: string | null;
|
|
||||||
imageDigest?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateReminderCandidate = {
|
|
||||||
source: 'github-release' | 'docker-hub-tag';
|
|
||||||
kind: 'new-version' | 'new-digest';
|
|
||||||
candidateKey: string;
|
|
||||||
displayVersion: string;
|
|
||||||
tagName: string;
|
|
||||||
digest: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export declare function normalizeStableVersion(value?: string | null): string;
|
|
||||||
export declare function compareStableVersions(
|
|
||||||
left?: string | null,
|
|
||||||
right?: string | null,
|
|
||||||
): number | null;
|
|
||||||
export declare function isSameImageTarget(
|
|
||||||
current: UpdateHelperRuntimeLike | null | undefined,
|
|
||||||
target: { tag?: string | null; digest?: string | null },
|
|
||||||
): boolean;
|
|
||||||
export declare function buildUpdateReminderCandidateKey(
|
|
||||||
source: 'github-release' | 'docker-hub-tag',
|
|
||||||
candidate: { tagName?: string | null; digest?: string | null },
|
|
||||||
): string;
|
|
||||||
export declare function resolveUpdateReminderCandidate(input: {
|
|
||||||
currentVersion?: string | null;
|
|
||||||
helper: UpdateHelperRuntimeLike | null | undefined;
|
|
||||||
githubRelease: UpdateVersionCandidateLike | null | undefined;
|
|
||||||
dockerHubTag: UpdateVersionCandidateLike | null | undefined;
|
|
||||||
}): UpdateReminderCandidate | null;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export {
|
|
||||||
buildUpdateReminderCandidateKey,
|
|
||||||
compareStableVersions,
|
|
||||||
isSameImageTarget,
|
|
||||||
normalizeStableVersion,
|
|
||||||
resolveUpdateReminderCandidate,
|
|
||||||
} from '../server/shared/updateCenterReminder.js';
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
use crate::config::AppConfig;
|
||||||
|
use reqwest::{Client, Proxy};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub fn client(config: &AppConfig, timeout_secs: u64) -> Client {
|
||||||
|
let mut builder = Client::builder().timeout(Duration::from_secs(timeout_secs));
|
||||||
|
if let Some(proxy_url) = template_proxy_url(config) {
|
||||||
|
match Proxy::all(&proxy_url) {
|
||||||
|
Ok(proxy) => {
|
||||||
|
builder = builder.proxy(proxy);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(%err, %proxy_url, "invalid template proxy url; falling back to direct template request");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.build().unwrap_or_else(|_| Client::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn template_proxy_url(config: &AppConfig) -> Option<String> {
|
||||||
|
config
|
||||||
|
.proxy
|
||||||
|
.template_proxy_url
|
||||||
|
.as_deref()
|
||||||
|
.and_then(non_empty)
|
||||||
|
.map(str::to_string)
|
||||||
|
.or_else(|| std::env::var("BOOSAPI_TEMPLATE_PROXY_URL").ok().and_then(|value| non_empty_owned(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_empty(value: &str) -> Option<&str> {
|
||||||
|
let value = value.trim();
|
||||||
|
(!value.is_empty()).then_some(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_empty_owned(value: String) -> Option<String> {
|
||||||
|
let value = value.trim().to_string();
|
||||||
|
(!value.is_empty()).then_some(value)
|
||||||
|
}
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { act } from 'react';
|
||||||
import { create, type ReactTestInstance } from 'react-test-renderer';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
|
||||||
|
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { clearSession, commitSession, getSessionToken } from './authSession.js';
|
||||||
|
import App from './App.js';
|
||||||
import AdminLoginPage from './pages/auth/AdminLoginPage.js';
|
import AdminLoginPage from './pages/auth/AdminLoginPage.js';
|
||||||
|
import UserLoginPage from './pages/auth/UserLoginPage.js';
|
||||||
|
import UserRegister from './pages/UserRegister.js';
|
||||||
|
|
||||||
|
vi.mock('./pages/Dashboard.js', () => ({
|
||||||
|
default: () => <div>Dashboard Page</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
function collectText(node: ReactTestInstance): string {
|
function collectText(node: ReactTestInstance): string {
|
||||||
return (node.children || []).map((child) => {
|
return (node.children || []).map((child) => {
|
||||||
@@ -10,6 +21,77 @@ function collectText(node: ReactTestInstance): string {
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createMemoryStorage() {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
getItem(key: string) {
|
||||||
|
return store.has(key) ? store.get(key)! : null;
|
||||||
|
},
|
||||||
|
setItem(key: string, value: string) {
|
||||||
|
store.set(key, value);
|
||||||
|
},
|
||||||
|
removeItem(key: string) {
|
||||||
|
store.delete(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsignedJwt(payload: Record<string, unknown>): string {
|
||||||
|
const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf8')
|
||||||
|
.toString('base64url');
|
||||||
|
return `test.${encodedPayload}.signature`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PathProbe({ onPath }: { onPath: (path: string) => void }) {
|
||||||
|
const location = useLocation();
|
||||||
|
onPath(location.pathname);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubAppRuntime(storage: ReturnType<typeof createMemoryStorage>) {
|
||||||
|
const matchMedia = (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
onchange: null,
|
||||||
|
});
|
||||||
|
const documentElement = {
|
||||||
|
setAttribute: vi.fn(),
|
||||||
|
getAttribute: vi.fn(() => null),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.stubGlobal('localStorage', storage);
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
innerWidth: 1280,
|
||||||
|
matchMedia,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('document', {
|
||||||
|
body: { style: {}, dataset: {} },
|
||||||
|
documentElement,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
getElementById: vi.fn(() => null),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('Node', { TEXT_NODE: 3, ELEMENT_NODE: 1 });
|
||||||
|
vi.stubGlobal('MutationObserver', class {
|
||||||
|
observe = vi.fn();
|
||||||
|
disconnect = vi.fn();
|
||||||
|
takeRecords = vi.fn(() => []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearSession('admin');
|
||||||
|
clearSession('user');
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
describe('Login surface', () => {
|
describe('Login surface', () => {
|
||||||
it('renders a poster-style hero with a floating admin login panel', () => {
|
it('renders a poster-style hero with a floating admin login panel', () => {
|
||||||
const root = create(
|
const root = create(
|
||||||
@@ -36,13 +118,13 @@ describe('Login surface', () => {
|
|||||||
&& node.props.className.includes('brand-mark-canvas')
|
&& node.props.className.includes('brand-mark-canvas')
|
||||||
));
|
));
|
||||||
|
|
||||||
expect(pageText).toContain('BoosAPI');
|
expect(pageText).toContain('BossAI');
|
||||||
expect(pageText).toContain('中转站的中转站');
|
expect(pageText).toContain('AI 应用平台');
|
||||||
expect(pageText).not.toContain('一个 API Key,一个入口');
|
expect(pageText).not.toContain('一个 API Key,一个入口');
|
||||||
expect(pageText).toContain('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API');
|
expect(pageText).toContain('兼容 OpenAI / Claude Messages / Comfy Cloud / RunningHub / 火山方舟等多类上游');
|
||||||
expect(pageText).toContain('统一代理网关');
|
expect(pageText).toContain('统一 API 网关');
|
||||||
expect(pageText).toContain('智能路由引擎');
|
expect(pageText).toContain('智能路由引擎');
|
||||||
expect(pageText).toContain('自动模型发现');
|
expect(pageText).toContain('工作流生产台');
|
||||||
expect(lightBrandPanel).toBeTruthy();
|
expect(lightBrandPanel).toBeTruthy();
|
||||||
expect(authStage).toBeTruthy();
|
expect(authStage).toBeTruthy();
|
||||||
expect(brandMarkCanvas).toBeTruthy();
|
expect(brandMarkCanvas).toBeTruthy();
|
||||||
@@ -50,15 +132,376 @@ describe('Login surface', () => {
|
|||||||
node.type === 'input'
|
node.type === 'input'
|
||||||
&& node.props.placeholder === '管理员令牌'
|
&& node.props.placeholder === '管理员令牌'
|
||||||
));
|
));
|
||||||
|
expect(tokenInput.props.type).toBe('password');
|
||||||
|
expect(root.root.findAll((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('login-mode-tab')
|
||||||
|
))).toHaveLength(0);
|
||||||
|
expect(pageText).toContain('仅允许使用管理员 API Token 进入控制台。');
|
||||||
|
expect(pageText).toContain('管理员后台不接受账号密码或用户 API Key 登录。');
|
||||||
|
expect(root.root.findAllByProps({ id: 'admin-email-input' })).toHaveLength(0);
|
||||||
|
expect(root.root.findAllByProps({ id: 'admin-password-input' })).toHaveLength(0);
|
||||||
|
expect(root.root.findAllByProps({ id: 'admin-api-key-input' })).toHaveLength(0);
|
||||||
|
const form = root.root.findByProps({ id: 'admin-login-panel-token' });
|
||||||
|
expect(form.props.onSubmit).toBeTypeOf('function');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only accepts the admin token on /boos and clears stale user session', async () => {
|
||||||
|
const storage = createMemoryStorage();
|
||||||
|
stubAppRuntime(storage);
|
||||||
|
commitSession('user', 'stale-user-jwt', undefined, storage);
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
text: async () => JSON.stringify({ enabled: true, mode: 'admin-token' }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const paths: string[] = [];
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/boos']}>
|
||||||
|
<App />
|
||||||
|
<PathProbe onPath={(path) => paths.push(path)} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.root.findByProps({ id: 'admin-token-input' }).props.onChange({
|
||||||
|
target: { value: 'admin-config-token' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await root.root.findByProps({ id: 'admin-login-panel-token' }).props.onSubmit({
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/settings/auth/info', expect.objectContaining({
|
||||||
|
headers: { Authorization: 'Bearer admin-config-token' },
|
||||||
|
}));
|
||||||
|
expect(fetchMock).not.toHaveBeenCalledWith('/api/auth/login-api-key', expect.anything());
|
||||||
|
expect(fetchMock).not.toHaveBeenCalledWith('/api/auth/login', expect.anything());
|
||||||
|
expect(getSessionToken('admin', storage)).toBe('admin-config-token');
|
||||||
|
expect(getSessionToken('user', storage)).toBeNull();
|
||||||
|
expect(paths.at(-1)).toBe('/dashboard');
|
||||||
|
} finally {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not expose account password or API Key controls on the admin login page', async () => {
|
||||||
|
const storage = createMemoryStorage();
|
||||||
|
vi.stubGlobal('localStorage', storage);
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const onLogin = vi.fn();
|
||||||
|
const root = create(
|
||||||
|
<MemoryRouter>
|
||||||
|
<AdminLoginPage t={(text) => text} onLogin={onLogin} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(root.root.findAllByProps({ id: 'admin-email-input' })).toHaveLength(0);
|
||||||
|
expect(root.root.findAllByProps({ id: 'admin-password-input' })).toHaveLength(0);
|
||||||
|
expect(root.root.findAllByProps({ id: 'admin-api-key-input' })).toHaveLength(0);
|
||||||
|
expect(collectText(root.root)).not.toContain('该 API Key 不属于管理员账号。');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(onLogin).not.toHaveBeenCalled();
|
||||||
|
expect(getSessionToken('admin', storage)).toBeNull();
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('connects user login mode tabs to the active login form panel', () => {
|
||||||
|
const root = create(
|
||||||
|
<MemoryRouter>
|
||||||
|
<UserLoginPage t={(text) => text} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
const loginModeTabs = root.root.findAll((node) => (
|
const loginModeTabs = root.root.findAll((node) => (
|
||||||
node.type === 'button'
|
node.type === 'button'
|
||||||
&& typeof node.props.className === 'string'
|
&& typeof node.props.className === 'string'
|
||||||
&& node.props.className.includes('login-mode-tab')
|
&& node.props.className.includes('login-mode-tab')
|
||||||
));
|
));
|
||||||
expect(tokenInput.props.type).toBe('password');
|
expect(loginModeTabs.map((node) => collectText(node))).toEqual(['邮箱密码', 'API Key']);
|
||||||
expect(loginModeTabs.map((node) => collectText(node))).toEqual(['Auth Token', '账号密码']);
|
expect(loginModeTabs.every((node) => node.props.role === 'tab')).toBe(true);
|
||||||
|
expect(loginModeTabs[0].props['aria-selected']).toBe(true);
|
||||||
|
expect(loginModeTabs[0].props['aria-controls']).toBe('user-login-panel-account');
|
||||||
|
|
||||||
|
const activePanel = root.root.find((node) => node.props.role === 'tabpanel');
|
||||||
|
expect(activePanel.props.id).toBe('user-login-panel-account');
|
||||||
|
expect(activePanel.props['aria-labelledby']).toBe('user-login-tab-account');
|
||||||
} finally {
|
} finally {
|
||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects admin API Key login on the user login page', async () => {
|
||||||
|
const storage = createMemoryStorage();
|
||||||
|
vi.stubGlobal('localStorage', storage);
|
||||||
|
commitSession('user', 'stale-user-jwt', undefined, storage);
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
token: 'jwt-admin-from-key',
|
||||||
|
user: {
|
||||||
|
role: 'admin',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const onLogin = vi.fn();
|
||||||
|
const paths: string[] = [];
|
||||||
|
const root = create(
|
||||||
|
<MemoryRouter initialEntries={['/user/login']}>
|
||||||
|
<UserLoginPage t={(text) => text} onLogin={onLogin} />
|
||||||
|
<PathProbe onPath={(path) => paths.push(path)} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiKeyTab = root.root.findAll((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('login-mode-tab')
|
||||||
|
))[1];
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
apiKeyTab.props.onClick();
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = root.root.findByProps({ id: 'user-api-key-input' });
|
||||||
|
await act(async () => {
|
||||||
|
input.props.onChange({ target: { value: 'sk-admin-user-key' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = root.root.findByProps({ id: 'user-login-panel-apiKey' });
|
||||||
|
await act(async () => {
|
||||||
|
await form.props.onSubmit({ preventDefault: vi.fn() });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/users/login-api-key', expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ apiKey: 'sk-admin-user-key' }),
|
||||||
|
}));
|
||||||
|
expect(getSessionToken('admin', storage)).toBeNull();
|
||||||
|
expect(getSessionToken('user', storage)).toBe('stale-user-jwt');
|
||||||
|
expect(onLogin).not.toHaveBeenCalled();
|
||||||
|
expect(collectText(root.root)).toContain('管理员请使用管理员 API Token 登录。');
|
||||||
|
expect(paths.at(-1)).toBe('/user/login');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects API Key login when JWT role falls back to admin', async () => {
|
||||||
|
const storage = createMemoryStorage();
|
||||||
|
vi.stubGlobal('localStorage', storage);
|
||||||
|
const adminJwt = unsignedJwt({ sub: '5', role: 'admin', exp: 9_999_999_999 });
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
token: adminJwt,
|
||||||
|
user: {
|
||||||
|
id: 5,
|
||||||
|
email: 'admin@example.test',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const onLogin = vi.fn();
|
||||||
|
const paths: string[] = [];
|
||||||
|
const root = create(
|
||||||
|
<MemoryRouter initialEntries={['/user/login']}>
|
||||||
|
<UserLoginPage t={(text) => text} onLogin={onLogin} />
|
||||||
|
<PathProbe onPath={(path) => paths.push(path)} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiKeyTab = root.root.findAll((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('login-mode-tab')
|
||||||
|
))[1];
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
apiKeyTab.props.onClick();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.root.findByProps({ id: 'user-api-key-input' }).props.onChange({
|
||||||
|
target: { value: 'sk-admin-bound' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await root.root.findByProps({ id: 'user-login-panel-apiKey' }).props.onSubmit({
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getSessionToken('admin', storage)).toBeNull();
|
||||||
|
expect(getSessionToken('user', storage)).toBeNull();
|
||||||
|
expect(onLogin).not.toHaveBeenCalled();
|
||||||
|
expect(collectText(root.root)).toContain('管理员请使用管理员 API Token 登录。');
|
||||||
|
expect(paths.at(-1)).toBe('/user/login');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the admin dashboard on public user auth pages when both sessions exist', () => {
|
||||||
|
const storage = createMemoryStorage();
|
||||||
|
stubAppRuntime(storage);
|
||||||
|
commitSession('admin', 'jwt-admin', undefined, storage);
|
||||||
|
commitSession('user', 'jwt-user-stale', undefined, storage);
|
||||||
|
vi.stubGlobal('fetch', vi.fn());
|
||||||
|
|
||||||
|
const paths: string[] = [];
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
|
||||||
|
try {
|
||||||
|
act(() => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/user/login']}>
|
||||||
|
<App />
|
||||||
|
<PathProbe onPath={(path) => paths.push(path)} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(paths.at(-1)).toBe('/dashboard');
|
||||||
|
} finally {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders OAuth provider entries as configured placeholders on user login', () => {
|
||||||
|
const root = create(
|
||||||
|
<MemoryRouter>
|
||||||
|
<UserLoginPage t={(text) => text} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const providers = root.root.findAll((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('login-oauth-provider')
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(providers.map((node) => node.props['data-provider'])).toEqual(['google', 'github', 'wechat']);
|
||||||
|
expect(providers.every((node) => node.props.disabled === true)).toBe(true);
|
||||||
|
expect(collectText(root.root)).toContain('待配置');
|
||||||
|
|
||||||
|
const wechatProvider = providers.find((node) => node.props['data-provider'] === 'wechat');
|
||||||
|
const wechatIcons = wechatProvider?.findAll((node) => (
|
||||||
|
node.type === 'svg'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('login-oauth-wechat-glyph')
|
||||||
|
));
|
||||||
|
expect(wechatIcons).toHaveLength(1);
|
||||||
|
expect(wechatIcons?.[0]?.props.width).toBe('30');
|
||||||
|
expect(wechatIcons?.[0]?.props.height).toBe('30');
|
||||||
|
expect(wechatIcons?.[0]?.props.viewBox).toBe('0 0 64 64');
|
||||||
|
expect(wechatProvider?.findAll((node) => node.type === 'img')).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a complete local WeChat glyph on the register OAuth placeholder', () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
id: 'captcha-test',
|
||||||
|
image: 'data:image/png;base64,test',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const root = create(
|
||||||
|
<MemoryRouter>
|
||||||
|
<UserRegister />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const oauthSlot = root.root.find((node) => node.props['data-testid'] === 'oauth-slot');
|
||||||
|
const wechatProvider = oauthSlot.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& node.props['data-provider'] === 'wechat'
|
||||||
|
));
|
||||||
|
const wechatIcon = wechatProvider.find((node) => (
|
||||||
|
node.type === 'svg'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('login-oauth-wechat-glyph')
|
||||||
|
));
|
||||||
|
const vectorParts = wechatIcon.findAll((node) => node.type === 'path' || node.type === 'circle');
|
||||||
|
|
||||||
|
expect(oauthSlot.props['data-mode']).toBe('register');
|
||||||
|
expect(wechatProvider.props.disabled).toBe(true);
|
||||||
|
expect(wechatProvider.props.onClick).toBeUndefined();
|
||||||
|
expect(wechatIcon.props.width).toBe('30');
|
||||||
|
expect(wechatIcon.props.height).toBe('30');
|
||||||
|
expect(wechatIcon.props.viewBox).toBe('0 0 64 64');
|
||||||
|
expect(wechatIcon.props['aria-hidden']).toBe('true');
|
||||||
|
expect(vectorParts).toHaveLength(6);
|
||||||
|
expect(wechatProvider.findAll((node) => node.type === 'img')).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
vi.stubGlobal('fetch', originalFetch);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the local WeChat OAuth mark unclipped in CSS', () => {
|
||||||
|
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||||
|
|
||||||
|
expect(css).toContain('.login-oauth-provider[data-provider="wechat"] .login-oauth-provider-mark {\n');
|
||||||
|
expect(css).toContain(' overflow: visible;\n box-shadow:\n inset 0 1px 0 rgba(255, 255, 255, 0.72),');
|
||||||
|
expect(css).toContain('.login-oauth-wechat-glyph {\n display: block;\n width: 30px;\n height: 30px;\n flex: 0 0 30px;\n overflow: visible;\n}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps login brand panels bounded on short and mobile viewports', () => {
|
||||||
|
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||||
|
|
||||||
|
expect(css).not.toContain('min-height: 650px;');
|
||||||
|
expect(css).not.toContain('min-height: 580px;');
|
||||||
|
expect(css).toContain('.login-brand-panel {\n min-height: min(650px, calc(100dvh - 92px));');
|
||||||
|
expect(css).toContain('.login-shell[data-variant="user"] .login-brand-panel {\n min-height: min(580px, calc(100dvh - 92px));');
|
||||||
|
expect(css).toContain('@media (max-height: 760px) and (min-width: 1181px) {');
|
||||||
|
expect(css).toContain('.login-brand-panel,\n .login-shell[data-variant="user"] .login-brand-panel {\n min-height: auto;');
|
||||||
|
expect(css).toContain('@media (max-width: 1180px) {');
|
||||||
|
expect(css).toContain('.login-shell[data-variant="user"] .login-brand-panel {\n min-height: auto;');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps admin and user login columns shrink-safe before the single-column breakpoint', () => {
|
||||||
|
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||||
|
|
||||||
|
expect(css).toContain('grid-template-columns: minmax(min(620px, 100%), 1fr) minmax(min(360px, 100%), 420px);');
|
||||||
|
expect(css).toContain('grid-template-columns: minmax(min(520px, 100%), 1.08fr) minmax(min(380px, 100%), 0.92fr);');
|
||||||
|
expect(css).toContain('grid-template-columns: minmax(min(440px, 100%), 0.95fr) minmax(min(380px, 100%), 1.05fr);');
|
||||||
|
expect(css).not.toContain('grid-template-columns: minmax(620px, 1fr) minmax(360px, 420px);');
|
||||||
|
expect(css).not.toContain('grid-template-columns: minmax(520px, 1.08fr) minmax(380px, 0.92fr);');
|
||||||
|
expect(css).not.toContain('grid-template-columns: minmax(440px, 0.95fr) minmax(380px, 1.05fr);');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
describe('App route transition feedback', () => {
|
||||||
|
it('tracks pathname and query changes without remounting page content for search-only navigation', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
||||||
|
|
||||||
|
expect(source).toContain('const routeTransitionKey = `${location.pathname}${location.search}`;');
|
||||||
|
expect(source).toContain('const previousRouteTransitionKeyRef = useRef(routeTransitionKey);');
|
||||||
|
expect(source).toContain('const sidebarRef = useRef<HTMLElement>(null);');
|
||||||
|
expect(source).toContain('const mainRef = useRef<HTMLElement>(null);');
|
||||||
|
expect(source).toContain("import { shouldFocusMainOnRouteChange } from './pages/helpers/routeFocus.js';");
|
||||||
|
expect(source).toContain('previousRouteTransitionKeyRef.current === routeTransitionKey');
|
||||||
|
expect(source).toContain('className="skip-link"');
|
||||||
|
expect(source).toContain('href="#main-content"');
|
||||||
|
expect(source).toContain('mainRef.current?.focus({ preventScroll: true });');
|
||||||
|
expect(source).toContain("{t('跳到主内容')}");
|
||||||
|
expect(source).toContain('ref={mainRef}');
|
||||||
|
expect(source).toContain('id="main-content"');
|
||||||
|
expect(source).toContain('tabIndex={-1}');
|
||||||
|
expect(source).toContain("aria-label={t('主内容')}");
|
||||||
|
expect(source).toContain('const pageMotionKey = `${location.pathname}${location.search}`;');
|
||||||
|
expect(source).toContain('key={location.pathname} motionKey={pageMotionKey}');
|
||||||
|
expect(source).toContain('<Suspense fallback={null}>');
|
||||||
|
expect(source).not.toContain('function RouteLoadingFallback');
|
||||||
|
expect(source).not.toContain('fallback={<RouteLoadingFallback />}');
|
||||||
|
expect(source).not.toContain('routeTransitioning');
|
||||||
|
expect(source).not.toContain('is-route-transitioning');
|
||||||
|
expect(source).toContain('className="sr-only"');
|
||||||
|
expect(source).toContain('aria-live="polite"');
|
||||||
|
expect(source).toContain('aria-atomic="true"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves focus to the main region on shell-driven route changes without stealing field focus', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
||||||
|
|
||||||
|
expect(source).toContain('const shouldFocusMain = shouldFocusMainOnRouteChange(document.activeElement, {');
|
||||||
|
expect(source).toContain('body: document.body,');
|
||||||
|
expect(source).toContain('topbar: topbarRef.current,');
|
||||||
|
expect(source).toContain('sidebar: sidebarRef.current,');
|
||||||
|
expect(source).not.toContain('const shouldFocusMain = !activeElement');
|
||||||
|
expect(source).toContain('let focusFrameId: number | null = null;');
|
||||||
|
expect(source).toContain("const schedule = typeof window.requestAnimationFrame === 'function'");
|
||||||
|
expect(source).toContain('focusFrameId = schedule(() => {');
|
||||||
|
expect(source).toContain('mainRef.current?.focus({ preventScroll: true });');
|
||||||
|
expect(source).toContain("typeof window.cancelAnimationFrame === 'function'");
|
||||||
|
expect(source).toContain('window.cancelAnimationFrame(focusFrameId);');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render a blocking shell loading frame while lazy routes resolve', () => {
|
||||||
|
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
||||||
|
|
||||||
|
expect(css).toContain('.skip-link');
|
||||||
|
expect(css).toContain('.skip-link:focus,\n.skip-link:focus-visible');
|
||||||
|
expect(css).toContain('.sr-only');
|
||||||
|
expect(css).toContain('.skip-link,\n .skip-link:focus,\n .skip-link:focus-visible');
|
||||||
|
expect(source).not.toContain('RouteLoadingFallback');
|
||||||
|
expect(source).not.toContain('animate-fade-in');
|
||||||
|
expect(source).not.toContain('aria-busy={routeTransitioning}');
|
||||||
|
expect(css).not.toContain('.route-progress');
|
||||||
|
expect(css).not.toContain('@keyframes route-progress-sweep');
|
||||||
|
expect(css).not.toContain('.main-content.is-route-transitioning');
|
||||||
|
});
|
||||||
|
});
|
||||||
+175
-4
@@ -11,17 +11,166 @@ describe('App sidebar config', () => {
|
|||||||
expect(source).not.toContain("{ to: '/tokens', label: '令牌管理'");
|
expect(source).not.toContain("{ to: '/tokens', label: '令牌管理'");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('places downstream key navigation under 控制台 instead of 系统', () => {
|
it('places downstream key navigation under 接入管理 instead of 系统', () => {
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||||
const consoleGroupIndex = source.indexOf("label: '控制台'");
|
const accessGroupIndex = source.indexOf("label: '接入管理'");
|
||||||
const downstreamIndex = source.indexOf("{ to: '/downstream-keys', label: '下游密钥'");
|
const downstreamIndex = source.indexOf("{ to: '/downstream-keys', label: '下游密钥'");
|
||||||
const systemGroupIndex = source.indexOf("label: '系统'");
|
const systemGroupIndex = source.indexOf("label: '系统'");
|
||||||
|
|
||||||
expect(consoleGroupIndex).toBeGreaterThanOrEqual(0);
|
expect(accessGroupIndex).toBeGreaterThanOrEqual(0);
|
||||||
expect(downstreamIndex).toBeGreaterThan(consoleGroupIndex);
|
expect(downstreamIndex).toBeGreaterThan(accessGroupIndex);
|
||||||
expect(systemGroupIndex).toBeGreaterThan(downstreamIndex);
|
expect(systemGroupIndex).toBeGreaterThan(downstreamIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps access navigation flat and non-collapsible', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||||
|
const accessGroupIndex = source.indexOf("label: '接入管理'");
|
||||||
|
const creativeGroupIndex = source.indexOf("label: '创作应用'");
|
||||||
|
const accessGroupSource = source.slice(accessGroupIndex, creativeGroupIndex);
|
||||||
|
|
||||||
|
expect(accessGroupIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(source).not.toContain("label: '模型接入'");
|
||||||
|
expect(source).not.toContain("label: '渠道管理'");
|
||||||
|
expect(source).not.toContain("label: '接入配置'");
|
||||||
|
expect(source).not.toContain("label: '接入与路由'");
|
||||||
|
expect(accessGroupSource).not.toContain('children:');
|
||||||
|
expect(accessGroupSource).not.toContain('collapsed');
|
||||||
|
expect(accessGroupSource).not.toContain('expanded');
|
||||||
|
expect(accessGroupSource).not.toContain('aria-expanded');
|
||||||
|
expect(accessGroupSource).toContain("{ to: '/sites', label: '站点管理'");
|
||||||
|
expect(accessGroupSource).toContain("{ to: '/routes', label: '路由管理'");
|
||||||
|
expect(accessGroupSource).toContain("{ to: '/ip-management', label: 'IP 管理'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds an accessible collapsible desktop sidebar', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||||
|
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
|
||||||
|
|
||||||
|
expect(source).toContain("const BOOSAPI_SIDEBAR_COLLAPSED_KEY = 'boosapi.sidebar.collapsed';");
|
||||||
|
expect(source).toContain('function resolveStoredSidebarCollapsed(): boolean {');
|
||||||
|
expect(source).toContain('const [sidebarCollapsed, setSidebarCollapsed] = useState(() => resolveStoredSidebarCollapsed());');
|
||||||
|
expect(source).toContain('className={`app-layout ${sidebarCollapsed ? \'sidebar-is-collapsed\' : \'\'}`.trim()}');
|
||||||
|
expect(source).toContain('<aside ref={sidebarRef} className="sidebar" data-collapsed={sidebarCollapsed ? \'true\' : \'false\'}>');
|
||||||
|
expect(source).toContain('className="sidebar-collapse-button"');
|
||||||
|
expect(source).toContain("aria-label={sidebarCollapsed ? t('展开侧边栏') : t('收起侧边栏')}");
|
||||||
|
expect(source).toContain('aria-controls={sidebarNavId}');
|
||||||
|
expect(source).toContain('aria-expanded={!sidebarCollapsed}');
|
||||||
|
expect(source).toContain('onClick={() => setSidebarCollapsed((value) => !value)}');
|
||||||
|
expect(css).toContain('.app-layout.sidebar-is-collapsed .sidebar {');
|
||||||
|
expect(css).toContain('flex-basis: 72px;');
|
||||||
|
expect(css).toContain('.sidebar-collapse-button {');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses flat sidebar selection styling without tree-like rails', () => {
|
||||||
|
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
|
||||||
|
const sidebarCss = css.slice(css.indexOf('/* ===== Sidebar ===== */'));
|
||||||
|
const sidebarActiveBeforeRules = Array.from(
|
||||||
|
sidebarCss.matchAll(/\.sidebar-item\.active::before\s*\{[^}]*\}/g),
|
||||||
|
(match) => match[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sidebarActiveBeforeRules.length).toBeGreaterThan(0);
|
||||||
|
sidebarActiveBeforeRules.forEach((rule) => {
|
||||||
|
expect(rule).toContain('content: none;');
|
||||||
|
expect(rule).toContain('display: none;');
|
||||||
|
expect(rule).not.toContain('left: 6px;');
|
||||||
|
expect(rule).not.toContain('width: 3px;');
|
||||||
|
expect(rule).not.toContain('height: 16px;');
|
||||||
|
});
|
||||||
|
const mobileActiveBeforeRules = Array.from(
|
||||||
|
sidebarCss.matchAll(/\.mobile-nav-item\.active::before\s*\{[^}]*\}/g),
|
||||||
|
(match) => match[0],
|
||||||
|
);
|
||||||
|
expect(mobileActiveBeforeRules.length).toBeGreaterThan(0);
|
||||||
|
mobileActiveBeforeRules.forEach((rule) => {
|
||||||
|
expect(rule).toContain('content: none;');
|
||||||
|
expect(rule).toContain('display: none;');
|
||||||
|
});
|
||||||
|
const mobileLabelRules = Array.from(
|
||||||
|
sidebarCss.matchAll(/\.mobile-nav-label\s*\{[^}]*\}/g),
|
||||||
|
(match) => match[0],
|
||||||
|
);
|
||||||
|
expect(mobileLabelRules.length).toBeGreaterThan(0);
|
||||||
|
mobileLabelRules.forEach((rule) => {
|
||||||
|
expect(rule).toContain('text-transform: none;');
|
||||||
|
expect(rule).toContain('letter-spacing: 0;');
|
||||||
|
});
|
||||||
|
expect(sidebarCss).not.toContain('inset 4px 0 0');
|
||||||
|
expect(sidebarCss).not.toContain('sidebar-submenu');
|
||||||
|
expect(sidebarCss).not.toContain('mobile-nav-parent');
|
||||||
|
const sidebarGroupRules = Array.from(
|
||||||
|
css.matchAll(/\.sidebar-group(?:\:last-of-type)?\s*\{[^}]*\}/g),
|
||||||
|
(match) => match[0],
|
||||||
|
);
|
||||||
|
expect(sidebarGroupRules.length).toBeGreaterThan(0);
|
||||||
|
const hasNonZeroDeclaration = (rule: string, property: string) => (
|
||||||
|
Array.from(rule.matchAll(new RegExp(`${property}\\s*:\\s*([^;]+)`, 'g')))
|
||||||
|
.some((match) => match[1]?.trim() !== '0')
|
||||||
|
);
|
||||||
|
const treeLikeSidebarGroupRules = sidebarGroupRules.filter((rule) => (
|
||||||
|
hasNonZeroDeclaration(rule, 'border-left')
|
||||||
|
|| hasNonZeroDeclaration(rule, 'padding-left')
|
||||||
|
|| hasNonZeroDeclaration(rule, 'margin-left')
|
||||||
|
));
|
||||||
|
expect(treeLikeSidebarGroupRules).toEqual([]);
|
||||||
|
expect(sidebarGroupRules.some((rule) => rule.includes('border-left: 0;'))).toBe(true);
|
||||||
|
expect(sidebarGroupRules.some((rule) => rule.includes('padding-left: 0;'))).toBe(true);
|
||||||
|
|
||||||
|
const mobileFlatRules = Array.from(
|
||||||
|
css.matchAll(/\.mobile-nav-group,\s*\.mobile-nav-label,\s*\.mobile-nav-item\s*\{[^}]*\}/g),
|
||||||
|
(match) => match[0],
|
||||||
|
);
|
||||||
|
expect(mobileFlatRules.length).toBeGreaterThan(0);
|
||||||
|
mobileFlatRules.forEach((rule) => {
|
||||||
|
expect(rule).toContain('margin-left: 0;');
|
||||||
|
expect(rule).toContain('border-left: 0;');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps internal usage log navigation and removes external monitor from admin shell', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||||
|
const adminGroupsSource = source.slice(
|
||||||
|
source.indexOf('export const sidebarGroups'),
|
||||||
|
source.indexOf('export const userSidebarGroups'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(adminGroupsSource).toContain("{ to: '/logs', label: '使用日志'");
|
||||||
|
expect(adminGroupsSource).toContain("{ to: '/events', label: '系统日志'");
|
||||||
|
expect(adminGroupsSource).not.toContain("{ to: '/monitor', label: '外部监控'");
|
||||||
|
expect(adminGroupsSource).not.toContain("{ to: '/events', label: '程序日志'");
|
||||||
|
expect(adminGroupsSource).not.toContain('children:');
|
||||||
|
expect(adminGroupsSource).not.toContain('aria-expanded');
|
||||||
|
expect(adminGroupsSource).not.toContain('sidebar-submenu');
|
||||||
|
expect(adminGroupsSource).not.toContain('mobile-nav-parent');
|
||||||
|
expect(adminGroupsSource).toContain("label: '接入管理'");
|
||||||
|
expect(adminGroupsSource).toContain("{ to: '/ops', label: '运维监控'");
|
||||||
|
const accessGroupSource = adminGroupsSource.slice(
|
||||||
|
adminGroupsSource.indexOf("label: '接入管理'"),
|
||||||
|
adminGroupsSource.indexOf("label: '创作应用'"),
|
||||||
|
);
|
||||||
|
const systemGroupSource = adminGroupsSource.slice(adminGroupsSource.indexOf("label: '系统'"));
|
||||||
|
expect(accessGroupSource).toContain("{ to: '/logs', label: '使用日志'");
|
||||||
|
expect(accessGroupSource).not.toContain("{ to: '/events', label: '系统日志'");
|
||||||
|
expect(systemGroupSource).toContain("{ to: '/events', label: '系统日志'");
|
||||||
|
expect(systemGroupSource).not.toContain("{ to: '/logs', label: '使用日志'");
|
||||||
|
expect(source).toContain('<Route path="/logs" element={<ProxyLogs />} />');
|
||||||
|
expect(source).not.toContain("const Monitors = lazy(() => import('./pages/Monitors.js'));");
|
||||||
|
expect(source).not.toContain('<Route path="/monitor"');
|
||||||
|
expect(source).toContain('<Route path="/events" element={<ProgramLogs />} />');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps user-side usage records navigation and route intact', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||||
|
const userGroupsSource = source.slice(
|
||||||
|
source.indexOf('export const userSidebarGroups'),
|
||||||
|
source.indexOf('const topNavItems'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(userGroupsSource).toContain("{ to: '/user/usage', label: '使用记录'");
|
||||||
|
expect(source).toContain("const UsageRecords = lazy(() => import('./pages/UsageRecords.js'));");
|
||||||
|
expect(source).toContain('<Route path="/user/usage" element={<UsageRecords />} />');
|
||||||
|
});
|
||||||
|
|
||||||
it('adds standalone OAuth 管理 navigation entry', () => {
|
it('adds standalone OAuth 管理 navigation entry', () => {
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||||
|
|
||||||
@@ -29,4 +178,26 @@ describe('App sidebar config', () => {
|
|||||||
expect(source).toContain("const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));");
|
expect(source).toContain("const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));");
|
||||||
expect(source).toContain('<Route path="/oauth" element={<OAuthManagement />} />');
|
expect(source).toContain('<Route path="/oauth" element={<OAuthManagement />} />');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('registers the model tester route used by route diagnostics', () => {
|
||||||
|
const appSource = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||||
|
const tokenRoutesSource = readFileSync(resolve(process.cwd(), 'src/web/pages/TokenRoutes.tsx'), 'utf8');
|
||||||
|
const topNavSource = appSource.slice(
|
||||||
|
appSource.indexOf('const topNavItems'),
|
||||||
|
appSource.indexOf('function isExactSidebarPath'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(appSource).toContain("const ModelTester = lazy(() => import('./pages/ModelTester.js'));");
|
||||||
|
expect(topNavSource).toContain("{ label: '模型测试', to: '/model-tester' }");
|
||||||
|
expect(appSource).toContain('<Route path="/model-tester" element={<ModelTester />} />');
|
||||||
|
expect(tokenRoutesSource).toContain('navigate(`/model-tester?model=${encodeURIComponent(model)}`)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a system-internal recharge page to the user workspace', () => {
|
||||||
|
const appSource = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||||
|
|
||||||
|
expect(appSource).toContain("const UserRecharge = lazy(() => import('./pages/UserRecharge.js'));");
|
||||||
|
expect(appSource).toContain("{ to: '/user/recharge', label: '账户充值'");
|
||||||
|
expect(appSource).toContain('<Route path="/user/recharge" element={<UserRecharge />} />');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
function readAppSource(): string {
|
||||||
|
return readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('App topbar menu accessibility', () => {
|
||||||
|
it('exposes theme and account dropdowns as controlled menus', () => {
|
||||||
|
const source = readAppSource();
|
||||||
|
|
||||||
|
expect(source).toContain('const themeMenuId = useId();');
|
||||||
|
expect(source).toContain('const userMenuId = useId();');
|
||||||
|
expect(source).toContain('const userTriggerRef = useRef<HTMLButtonElement>(null);');
|
||||||
|
expect(source).toContain('const themeTriggerRef = useRef<HTMLButtonElement>(null);');
|
||||||
|
expect(source).toContain('const searchDialogId = useId();');
|
||||||
|
expect(source).toContain('const notificationPanelId = useId();');
|
||||||
|
expect(source).toContain('function openSearch() {');
|
||||||
|
expect(source).toContain('aria-haspopup="menu"');
|
||||||
|
expect(source).toContain('aria-haspopup="dialog"');
|
||||||
|
expect(source).toContain('aria-expanded={showSearch}');
|
||||||
|
expect(source).toContain('aria-controls={showSearch ? searchDialogId : undefined}');
|
||||||
|
expect(source).toContain('aria-controls={themeMenuPresence.shouldRender ? themeMenuId : undefined}');
|
||||||
|
expect(source).toContain('aria-controls={userMenuPresence.shouldRender ? userMenuId : undefined}');
|
||||||
|
expect(source).toContain('aria-controls={showNotifications ? notificationPanelId : undefined}');
|
||||||
|
expect(source).toContain('role="menu"');
|
||||||
|
expect(source).toContain('aria-label={t(\'主题菜单\')}');
|
||||||
|
expect(source).toContain('aria-label={t(\'账户菜单\')}');
|
||||||
|
expect(source).toContain('role="menuitemradio"');
|
||||||
|
expect(source).toContain('role="menuitem"');
|
||||||
|
expect(source).toContain('dialogId={searchDialogId}');
|
||||||
|
expect(source).toContain("type ThemePreset = 'clearops' | 'terminal' | 'studio';");
|
||||||
|
expect(source).toContain('const THEME_PRESETS: Array<{ value: ThemePreset; label: string; description: string }> = [');
|
||||||
|
expect(source).toContain("const [themePreset, setThemePreset] = useState<ThemePreset>(() => resolveStoredThemePreset());");
|
||||||
|
expect(source).toContain("document.documentElement.setAttribute('data-theme-preset', themePreset);");
|
||||||
|
expect(source).toContain("document.documentElement.setAttribute('data-theme-tone', resolvedThemeTone);");
|
||||||
|
expect(source).toContain('className={`theme-preset-swatch theme-preset-swatch-${preset.value}`}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps closing dropdown actions out of the tab order and closes menus on Escape', () => {
|
||||||
|
const source = readAppSource();
|
||||||
|
|
||||||
|
expect(source).toContain('tabIndex={themeMenuPresence.isVisible ? 0 : -1}');
|
||||||
|
expect(source).toContain('tabIndex={userMenuPresence.isVisible ? 0 : -1}');
|
||||||
|
expect(source).toContain('aria-hidden={!themeMenuPresence.isVisible}');
|
||||||
|
expect(source).toContain('aria-hidden={!userMenuPresence.isVisible}');
|
||||||
|
expect(source).toContain('if (!showThemeMenu && !showUserMenu) return undefined;');
|
||||||
|
expect(source).toContain("if (e.key !== 'Escape') return;");
|
||||||
|
expect(source).toContain('setShowThemeMenu(false);');
|
||||||
|
expect(source).toContain('setShowUserMenu(false);');
|
||||||
|
expect(source).toContain('setShowNotifications(false);');
|
||||||
|
expect(source).toContain('setShowSearch(false);');
|
||||||
|
expect(source).toContain('triggerRef.current?.focus({ preventScroll: true });');
|
||||||
|
expect(source).toContain('themeTriggerRef.current?.focus({ preventScroll: true });');
|
||||||
|
expect(source).toContain('if (e.defaultPrevented) return;');
|
||||||
|
expect(source).toContain("e.key.toLowerCase() === 'k'");
|
||||||
|
expect(source).toContain("document.addEventListener('keydown', handler);");
|
||||||
|
expect(source).toContain("return () => document.removeEventListener('keydown', handler);");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports keyboard opening and roving focus inside topbar menus', () => {
|
||||||
|
const source = readAppSource();
|
||||||
|
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||||
|
|
||||||
|
expect(source).toContain("type MenuFocusEdge = 'first' | 'last';");
|
||||||
|
expect(source).toContain("type PendingMenuFocus = { menu: 'theme' | 'user'; edge: MenuFocusEdge } | null;");
|
||||||
|
expect(source).toContain('const [pendingMenuFocus, setPendingMenuFocus] = useState<PendingMenuFocus>(null);');
|
||||||
|
expect(source).toContain('const focusMenuItem = (menuRef: React.RefObject<HTMLDivElement>, edge: MenuFocusEdge = \'first\') => {');
|
||||||
|
expect(source).toContain('target?.focus({ preventScroll: true });');
|
||||||
|
expect(source).toContain('if (!pendingMenuFocus) return;');
|
||||||
|
expect(source).toContain('const menuRef = pendingMenuFocus.menu === \'theme\' ? themeMenuRef : userMenuRef;');
|
||||||
|
expect(source).toContain('setPendingMenuFocus(null);');
|
||||||
|
expect(source).toContain('const handleTopbarMenuTriggerKeyDown = (');
|
||||||
|
expect(source).toContain("if (!['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) return;");
|
||||||
|
expect(source).toContain("const focusEdge: MenuFocusEdge = e.key === 'ArrowUp' ? 'last' : 'first';");
|
||||||
|
expect(source).toContain("setPendingMenuFocus({ menu: 'theme', edge: focusEdge });");
|
||||||
|
expect(source).toContain("setPendingMenuFocus({ menu: 'user', edge: focusEdge });");
|
||||||
|
expect(source).toContain('const handleTopbarMenuKeyDown = (');
|
||||||
|
expect(source).toContain("if (e.key === 'Escape') {\n e.preventDefault();\n setShowThemeMenu(false);");
|
||||||
|
expect(source).toContain("if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {");
|
||||||
|
expect(source).toContain('items[nextIndex]?.focus({ preventScroll: true });');
|
||||||
|
expect(source).toContain("if (e.key === 'Home') {");
|
||||||
|
expect(source).toContain('items[0]?.focus({ preventScroll: true });');
|
||||||
|
expect(source).toContain("if (e.key === 'End') {");
|
||||||
|
expect(source).toContain('items[items.length - 1]?.focus({ preventScroll: true });');
|
||||||
|
expect(source).toContain('target?.focus({ preventScroll: true });');
|
||||||
|
expect(source).toContain("onKeyDown={(e) => handleTopbarMenuTriggerKeyDown(e, 'theme')}");
|
||||||
|
expect(source).toContain("onKeyDown={(e) => handleTopbarMenuTriggerKeyDown(e, 'user')}");
|
||||||
|
expect(source).toContain('onKeyDown={handleNotificationTriggerKeyDown}');
|
||||||
|
expect(source).toContain("onKeyDown={(e) => handleTopbarMenuKeyDown(e, 'theme')}");
|
||||||
|
expect(source).toContain("onKeyDown={(e) => handleTopbarMenuKeyDown(e, 'user')}");
|
||||||
|
expect(css).toContain('.user-dropdown-item:focus-visible');
|
||||||
|
expect(css).toContain('[data-theme="dark"] .user-dropdown-item:focus-visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the desktop topbar grid shrink-safe before the mobile breakpoint', () => {
|
||||||
|
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||||
|
const finalTopbarStart = css.indexOf('/* Final topbar layout guard: keep brand, nav, and user actions in their own lanes. */');
|
||||||
|
const finalTopbarEnd = css.indexOf('@media (max-width: 768px)', finalTopbarStart);
|
||||||
|
const finalTopbarCss = css.slice(finalTopbarStart, finalTopbarEnd);
|
||||||
|
|
||||||
|
expect(finalTopbarStart).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(finalTopbarEnd).toBeGreaterThan(finalTopbarStart);
|
||||||
|
expect(finalTopbarCss).toContain('grid-template-columns: minmax(min(230px, 100%), 280px) auto minmax(min(300px, 100%), 1fr);');
|
||||||
|
expect(finalTopbarCss).toContain('grid-template-columns: minmax(min(170px, 100%), 220px) auto minmax(min(220px, 100%), 1fr);');
|
||||||
|
expect(finalTopbarCss).toContain('grid-template-columns: minmax(min(150px, 100%), 190px) auto minmax(min(150px, 100%), 1fr);');
|
||||||
|
expect(finalTopbarCss).toContain('width: max-content;');
|
||||||
|
expect(finalTopbarCss).toContain('max-width: min(430px, 100%);');
|
||||||
|
expect(finalTopbarCss).toContain('overflow-x: auto;');
|
||||||
|
expect(finalTopbarCss).toContain('.topbar-nav::-webkit-scrollbar {\n display: none;\n}');
|
||||||
|
expect(finalTopbarCss).not.toContain('width: min(520px, 100%);');
|
||||||
|
expect(finalTopbarCss).not.toContain('grid-template-columns: minmax(230px, 280px) minmax(320px, max-content) minmax(300px, 1fr);');
|
||||||
|
expect(finalTopbarCss).not.toContain('grid-template-columns: minmax(170px, 220px) minmax(280px, max-content) minmax(220px, 1fr);');
|
||||||
|
expect(finalTopbarCss).not.toContain('grid-template-columns: minmax(150px, 190px) minmax(240px, max-content) minmax(150px, 1fr);');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,685 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import App from './App.js';
|
||||||
|
|
||||||
|
const { apiMock, authSessionMock } = vi.hoisted(() => ({
|
||||||
|
apiMock: {
|
||||||
|
getEvents: vi.fn(),
|
||||||
|
},
|
||||||
|
authSessionMock: {
|
||||||
|
hasValidAuthSession: vi.fn(),
|
||||||
|
persistAuthSession: vi.fn(),
|
||||||
|
clearAuthSession: vi.fn(),
|
||||||
|
getSessionToken: vi.fn(),
|
||||||
|
commitSession: vi.fn(),
|
||||||
|
clearSession: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-dom', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
createPortal: (node: unknown) => node,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('./api.js', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./authSession.js', () => ({
|
||||||
|
hasValidAuthSession: authSessionMock.hasValidAuthSession,
|
||||||
|
persistAuthSession: authSessionMock.persistAuthSession,
|
||||||
|
clearAuthSession: authSessionMock.clearAuthSession,
|
||||||
|
getSessionToken: authSessionMock.getSessionToken,
|
||||||
|
commitSession: authSessionMock.commitSession,
|
||||||
|
clearSession: authSessionMock.clearSession,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./components/SearchModal.js', () => ({
|
||||||
|
default: ({ open, dialogId }: { open: boolean; dialogId?: string }) => (
|
||||||
|
open ? <div id={dialogId} role="dialog" aria-modal="true" aria-label="搜索" /> : null
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./components/NotificationPanel.js', () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./components/TooltipLayer.js', () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./components/useAnimatedVisibility.js', () => ({
|
||||||
|
useAnimatedVisibility: (open: boolean) => ({
|
||||||
|
shouldRender: open,
|
||||||
|
isVisible: open,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./i18n.js', () => ({
|
||||||
|
I18nProvider: ({ children }: { children: ReactNode }) => children,
|
||||||
|
useI18n: () => ({
|
||||||
|
language: 'zh',
|
||||||
|
toggleLanguage: vi.fn(),
|
||||||
|
t: (text: string) => text,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/Dashboard.js', () => ({
|
||||||
|
default: ({ adminName }: { adminName?: string }) => <div>{adminName || 'Dashboard'}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createLocalStorage() {
|
||||||
|
const store = new Map<string, string>([
|
||||||
|
['theme_mode', 'light'],
|
||||||
|
['theme_preset', 'clearops'],
|
||||||
|
['metapi.firstUseDocReminder', '1'],
|
||||||
|
['admin_profile', JSON.stringify({
|
||||||
|
name: '管理员',
|
||||||
|
avatarSeed: 'seed-1',
|
||||||
|
avatarStyle: 'identicon',
|
||||||
|
})],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => store.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
store.set(key, String(value));
|
||||||
|
},
|
||||||
|
removeItem: (key: string) => {
|
||||||
|
store.delete(key);
|
||||||
|
},
|
||||||
|
dump: () => new Map(store),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupRuntime() {
|
||||||
|
const matchMedia = (query: string) => ({
|
||||||
|
matches: query.includes('prefers-color-scheme') ? false : false,
|
||||||
|
media: query,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
onchange: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const storage = createLocalStorage();
|
||||||
|
const documentElementAttributes = new Map<string, string>();
|
||||||
|
vi.stubGlobal('localStorage', storage);
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
innerWidth: 1280,
|
||||||
|
matchMedia,
|
||||||
|
requestAnimationFrame: (callback: FrameRequestCallback) => {
|
||||||
|
callback(0);
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
cancelAnimationFrame: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('document', {
|
||||||
|
body: { style: {} },
|
||||||
|
activeElement: null,
|
||||||
|
documentElement: {
|
||||||
|
setAttribute: vi.fn((name: string, value: string) => {
|
||||||
|
documentElementAttributes.set(name, value);
|
||||||
|
}),
|
||||||
|
getAttribute: vi.fn((name: string) => documentElementAttributes.get(name) ?? null),
|
||||||
|
},
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
});
|
||||||
|
return { documentElementAttributes, storage };
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyEvent(key: string) {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentKeyEvent(overrides: Partial<KeyboardEvent> & { key: string }) {
|
||||||
|
return {
|
||||||
|
ctrlKey: false,
|
||||||
|
defaultPrevented: false,
|
||||||
|
metaKey: false,
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestDocumentKeydownListener(): EventListener {
|
||||||
|
const calls = vi.mocked(document.addEventListener).mock.calls;
|
||||||
|
const listeners = calls
|
||||||
|
.filter(([type]) => type === 'keydown')
|
||||||
|
.map(([, listener]) => listener as EventListener);
|
||||||
|
const listener = listeners.at(-1);
|
||||||
|
if (!listener) throw new Error('No document keydown listener registered');
|
||||||
|
return listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findButton(root: ReactTestInstance, matcher: (ariaLabel: string) => boolean): ReactTestInstance {
|
||||||
|
return root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props['aria-label'] === 'string'
|
||||||
|
&& matcher(node.props['aria-label'])
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMenus(root: ReactTestInstance, label: string): ReactTestInstance[] {
|
||||||
|
return root.findAll((node) => node.props.role === 'menu' && node.props['aria-label'] === label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectText(node: ReactTestInstance): string {
|
||||||
|
return (node.children || []).map((child) => {
|
||||||
|
if (typeof child === 'string') return child;
|
||||||
|
return collectText(child);
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('App topbar menu behavior', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
apiMock.getEvents.mockResolvedValue([]);
|
||||||
|
authSessionMock.hasValidAuthSession.mockReturnValue(true);
|
||||||
|
authSessionMock.getSessionToken.mockImplementation((audience: string) =>
|
||||||
|
audience === 'admin' ? 'fake-admin-token' : null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the theme menu from the keyboard trigger', async () => {
|
||||||
|
setupRuntime();
|
||||||
|
let root!: WebTestRenderer;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
|
||||||
|
const event = keyEvent('ArrowDown');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
themeTrigger.props.onKeyDown(event);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||||
|
expect(findButton(root.root, (label) => label.includes('浅色模式')).props['aria-expanded']).toBe(true);
|
||||||
|
expect(findMenus(root.root, '主题菜单')).toHaveLength(1);
|
||||||
|
expect(findMenus(root.root, '账户菜单')).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves focus across theme menu items without scrolling the page', async () => {
|
||||||
|
setupRuntime();
|
||||||
|
const menuItems: Array<{ label: string; focus: ReturnType<typeof vi.fn> }> = [
|
||||||
|
{ label: '跟随系统', focus: vi.fn() },
|
||||||
|
{ label: '浅色模式', focus: vi.fn() },
|
||||||
|
{ label: '深色模式', focus: vi.fn() },
|
||||||
|
{ label: '清透运维', focus: vi.fn() },
|
||||||
|
{ label: '深色终端', focus: vi.fn() },
|
||||||
|
{ label: '云端实验室', focus: vi.fn() },
|
||||||
|
];
|
||||||
|
let activeElement: unknown = menuItems[0];
|
||||||
|
Object.defineProperty(document, 'activeElement', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => activeElement,
|
||||||
|
});
|
||||||
|
|
||||||
|
let root!: WebTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>,
|
||||||
|
{
|
||||||
|
createNodeMock: (element) => {
|
||||||
|
const children = Array.isArray(element.props.children)
|
||||||
|
? element.props.children
|
||||||
|
: [element.props.children];
|
||||||
|
const isThemeMenuWrapper = (
|
||||||
|
element.type === 'div'
|
||||||
|
&& element.props.style?.position === 'relative'
|
||||||
|
&& children.some((child: any) => (
|
||||||
|
child?.type === 'button'
|
||||||
|
&& child.props?.['aria-haspopup'] === 'menu'
|
||||||
|
&& String(child.props?.['aria-label'] || '').includes('浅色模式')
|
||||||
|
))
|
||||||
|
);
|
||||||
|
if (isThemeMenuWrapper) {
|
||||||
|
return {
|
||||||
|
contains: () => true,
|
||||||
|
querySelectorAll: () => menuItems,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
|
||||||
|
const openEvent = keyEvent('ArrowDown');
|
||||||
|
await act(async () => {
|
||||||
|
themeTrigger.props.onKeyDown(openEvent);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(openEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||||
|
expect(menuItems[0]!.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||||
|
|
||||||
|
const menu = findMenus(root.root, '主题菜单')[0]!;
|
||||||
|
const arrowDownEvent = keyEvent('ArrowDown');
|
||||||
|
await act(async () => {
|
||||||
|
menu.props.onKeyDown(arrowDownEvent);
|
||||||
|
});
|
||||||
|
expect(arrowDownEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||||
|
expect(menuItems[1]!.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||||
|
|
||||||
|
activeElement = menuItems[1];
|
||||||
|
const endEvent = keyEvent('End');
|
||||||
|
await act(async () => {
|
||||||
|
menu.props.onKeyDown(endEvent);
|
||||||
|
});
|
||||||
|
expect(endEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||||
|
expect(menuItems[5]!.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||||
|
|
||||||
|
activeElement = menuItems[2];
|
||||||
|
const homeEvent = keyEvent('Home');
|
||||||
|
await act(async () => {
|
||||||
|
menu.props.onKeyDown(homeEvent);
|
||||||
|
});
|
||||||
|
expect(homeEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||||
|
expect(menuItems[0]!.focus).toHaveBeenCalledTimes(2);
|
||||||
|
expect(menuItems[0]!.focus).toHaveBeenLastCalledWith({ preventScroll: true });
|
||||||
|
} finally {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns focus to the theme trigger after selecting a theme', async () => {
|
||||||
|
setupRuntime();
|
||||||
|
const themeFocus = vi.fn();
|
||||||
|
let root!: WebTestRenderer;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>,
|
||||||
|
{
|
||||||
|
createNodeMock: (element) => (
|
||||||
|
element.type === 'button'
|
||||||
|
&& String(element.props.className || '').includes('topbar-icon-btn')
|
||||||
|
&& element.props['aria-haspopup'] === 'menu'
|
||||||
|
? { focus: themeFocus }
|
||||||
|
: null
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
|
||||||
|
await act(async () => {
|
||||||
|
themeTrigger.props.onKeyDown(keyEvent('ArrowDown'));
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const darkModeItem = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& node.props.role === 'menuitemradio'
|
||||||
|
&& collectText(node) === '深色模式'
|
||||||
|
));
|
||||||
|
await act(async () => {
|
||||||
|
darkModeItem.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(themeFocus).toHaveBeenCalledWith({ preventScroll: true });
|
||||||
|
expect(findMenus(root.root, '主题菜单')).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists named theme presets and exposes root theme attributes', async () => {
|
||||||
|
const runtime = setupRuntime();
|
||||||
|
let root!: WebTestRenderer;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
|
||||||
|
await act(async () => {
|
||||||
|
themeTrigger.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const terminalPresetItem = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& node.props.role === 'menuitemradio'
|
||||||
|
&& collectText(node).includes('深色终端')
|
||||||
|
));
|
||||||
|
await act(async () => {
|
||||||
|
terminalPresetItem.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(runtime.storage.getItem('theme_preset')).toBe('terminal');
|
||||||
|
expect(runtime.documentElementAttributes.get('data-theme-preset')).toBe('terminal');
|
||||||
|
expect(runtime.documentElementAttributes.get('data-theme-tone')).toBe('dark');
|
||||||
|
expect(findMenus(root.root, '主题菜单')).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the notification panel from the keyboard trigger as a controlled dialog', async () => {
|
||||||
|
setupRuntime();
|
||||||
|
let root!: WebTestRenderer;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const notificationTrigger = findButton(root.root, (label) => label === '通知');
|
||||||
|
expect(notificationTrigger.props['aria-haspopup']).toBe('dialog');
|
||||||
|
expect(notificationTrigger.props['aria-expanded']).toBe(false);
|
||||||
|
expect(notificationTrigger.props['aria-controls']).toBeUndefined();
|
||||||
|
|
||||||
|
const openEvent = keyEvent('ArrowDown');
|
||||||
|
await act(async () => {
|
||||||
|
notificationTrigger.props.onKeyDown(openEvent);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const openedNotificationTrigger = findButton(root.root, (label) => label === '通知');
|
||||||
|
expect(openEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||||
|
expect(openedNotificationTrigger.props['aria-expanded']).toBe(true);
|
||||||
|
expect(typeof openedNotificationTrigger.props['aria-controls']).toBe('string');
|
||||||
|
|
||||||
|
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
|
||||||
|
await act(async () => {
|
||||||
|
themeTrigger.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(findButton(root.root, (label) => label === '通知').props['aria-expanded']).toBe(false);
|
||||||
|
expect(findButton(root.root, (label) => label.includes('浅色模式')).props['aria-expanded']).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens search as a controlled dialog trigger and closes other topbar surfaces', async () => {
|
||||||
|
setupRuntime();
|
||||||
|
let root!: WebTestRenderer;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const notificationTrigger = findButton(root.root, (label) => label === '通知');
|
||||||
|
await act(async () => {
|
||||||
|
notificationTrigger.props.onKeyDown(keyEvent('ArrowDown'));
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(findButton(root.root, (label) => label === '通知').props['aria-expanded']).toBe(true);
|
||||||
|
|
||||||
|
const searchTrigger = findButton(root.root, (label) => label === '搜索 (Ctrl+K)');
|
||||||
|
expect(searchTrigger.props['aria-haspopup']).toBe('dialog');
|
||||||
|
expect(searchTrigger.props['aria-expanded']).toBe(false);
|
||||||
|
expect(searchTrigger.props['aria-controls']).toBeUndefined();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
searchTrigger.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const openedSearchTrigger = findButton(root.root, (label) => label === '搜索 (Ctrl+K)');
|
||||||
|
expect(openedSearchTrigger.props['aria-expanded']).toBe(true);
|
||||||
|
expect(typeof openedSearchTrigger.props['aria-controls']).toBe('string');
|
||||||
|
const searchDialog = root.root.find((node) => (
|
||||||
|
node.props.role === 'dialog'
|
||||||
|
&& node.props.id === openedSearchTrigger.props['aria-controls']
|
||||||
|
));
|
||||||
|
expect(searchDialog.props['aria-modal']).toBe('true');
|
||||||
|
expect(findButton(root.root, (label) => label === '通知').props['aria-expanded']).toBe(false);
|
||||||
|
|
||||||
|
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
|
||||||
|
await act(async () => {
|
||||||
|
themeTrigger.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(findButton(root.root, (label) => label === '搜索 (Ctrl+K)').props['aria-expanded']).toBe(false);
|
||||||
|
expect(findButton(root.root, (label) => label.includes('浅色模式')).props['aria-expanded']).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens search from Ctrl or Command K without stealing handled shortcuts', async () => {
|
||||||
|
setupRuntime();
|
||||||
|
let root!: WebTestRenderer;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const keydown = latestDocumentKeydownListener();
|
||||||
|
const ignoredEvent = documentKeyEvent({ ctrlKey: true, defaultPrevented: true, key: 'k' });
|
||||||
|
await act(async () => {
|
||||||
|
keydown(ignoredEvent as unknown as Event);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(ignoredEvent.preventDefault).not.toHaveBeenCalled();
|
||||||
|
expect(findButton(root.root, (label) => label === '搜索 (Ctrl+K)').props['aria-expanded']).toBe(false);
|
||||||
|
|
||||||
|
const ctrlEvent = documentKeyEvent({ ctrlKey: true, key: 'K' });
|
||||||
|
await act(async () => {
|
||||||
|
keydown(ctrlEvent as unknown as Event);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(ctrlEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||||
|
expect(findButton(root.root, (label) => label === '搜索 (Ctrl+K)').props['aria-expanded']).toBe(true);
|
||||||
|
|
||||||
|
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
|
||||||
|
await act(async () => {
|
||||||
|
themeTrigger.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(findButton(root.root, (label) => label === '搜索 (Ctrl+K)').props['aria-expanded']).toBe(false);
|
||||||
|
|
||||||
|
const metaEvent = documentKeyEvent({ key: 'k', metaKey: true });
|
||||||
|
await act(async () => {
|
||||||
|
keydown(metaEvent as unknown as Event);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(metaEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||||
|
expect(findButton(root.root, (label) => label === '搜索 (Ctrl+K)').props['aria-expanded']).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders skip-to-main and route status landmarks in the app shell', async () => {
|
||||||
|
setupRuntime();
|
||||||
|
let root!: WebTestRenderer;
|
||||||
|
const mainFocus = vi.fn();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>,
|
||||||
|
{
|
||||||
|
createNodeMock: (element) => (
|
||||||
|
element.type === 'main'
|
||||||
|
? { focus: mainFocus }
|
||||||
|
: null
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const skipLink = root.root.find((node) => (
|
||||||
|
node.type === 'a'
|
||||||
|
&& node.props.className === 'skip-link'
|
||||||
|
));
|
||||||
|
expect(skipLink.props.href).toBe('#main-content');
|
||||||
|
await act(async () => {
|
||||||
|
skipLink.props.onClick();
|
||||||
|
});
|
||||||
|
expect(mainFocus).toHaveBeenCalledWith({ preventScroll: true });
|
||||||
|
|
||||||
|
const main = root.root.find((node) => node.type === 'main');
|
||||||
|
expect(main.props.id).toBe('main-content');
|
||||||
|
expect(main.props.tabIndex).toBe(-1);
|
||||||
|
expect(main.props['aria-label']).toBe('主内容');
|
||||||
|
|
||||||
|
const liveRegion = root.root.find((node) => (
|
||||||
|
node.props.className === 'sr-only'
|
||||||
|
&& node.props['aria-live'] === 'polite'
|
||||||
|
));
|
||||||
|
expect(liveRegion.props['aria-atomic']).toBe('true');
|
||||||
|
} finally {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the account menu from the keyboard trigger and closes it with Escape', async () => {
|
||||||
|
setupRuntime();
|
||||||
|
const accountFocus = vi.fn();
|
||||||
|
let root!: WebTestRenderer;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>,
|
||||||
|
{
|
||||||
|
createNodeMock: (element) => (
|
||||||
|
element.type === 'button'
|
||||||
|
&& String(element.props.className || '').includes('topbar-user-trigger')
|
||||||
|
? { focus: accountFocus }
|
||||||
|
: null
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const accountTrigger = findButton(root.root, (label) => label.startsWith('账户菜单:'));
|
||||||
|
const openEvent = keyEvent('ArrowUp');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
accountTrigger.props.onKeyDown(openEvent);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(openEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||||
|
expect(findButton(root.root, (label) => label.startsWith('账户菜单:')).props['aria-expanded']).toBe(true);
|
||||||
|
const menus = findMenus(root.root, '账户菜单');
|
||||||
|
expect(menus).toHaveLength(1);
|
||||||
|
expect(findMenus(root.root, '主题菜单')).toHaveLength(0);
|
||||||
|
|
||||||
|
const closeEvent = keyEvent('Escape');
|
||||||
|
await act(async () => {
|
||||||
|
menus[0]!.props.onKeyDown(closeEvent);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(closeEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||||
|
expect(accountFocus).toHaveBeenCalledWith({ preventScroll: true });
|
||||||
|
expect(findButton(root.root, (label) => label.startsWith('账户菜单:')).props['aria-expanded']).toBe(false);
|
||||||
|
expect(findMenus(root.root, '账户菜单')).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,16 +3,20 @@ import { readFileSync } from 'node:fs';
|
|||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
describe('App topbar tooltips', () => {
|
describe('App topbar tooltips', () => {
|
||||||
it('removes topbar hover tooltips while preserving sidebar collapsed tooltips', () => {
|
it('removes hover-only tooltips from topbar and fixed desktop sidebar navigation', () => {
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||||
const topbarStart = source.indexOf('<div className="topbar-right">');
|
const topbarStart = source.indexOf('<div className="topbar-right">');
|
||||||
const topbarEnd = source.indexOf('</header>');
|
const topbarEnd = source.indexOf('</header>');
|
||||||
|
const sidebarItemStart = source.indexOf('const renderDesktopSidebarItem');
|
||||||
|
const sidebarItemEnd = source.indexOf('return (', source.indexOf('<a'));
|
||||||
|
|
||||||
expect(topbarStart).toBeGreaterThanOrEqual(0);
|
expect(topbarStart).toBeGreaterThanOrEqual(0);
|
||||||
expect(topbarEnd).toBeGreaterThan(topbarStart);
|
expect(topbarEnd).toBeGreaterThan(topbarStart);
|
||||||
|
|
||||||
const topbarSection = source.slice(topbarStart, topbarEnd);
|
const topbarSection = source.slice(topbarStart, topbarEnd);
|
||||||
expect(topbarSection).not.toContain('data-tooltip=');
|
expect(topbarSection).not.toContain('data-tooltip=');
|
||||||
expect(source).toContain("data-tooltip={sidebarCollapsed ? t(item.label) : undefined}");
|
expect(source).not.toContain('data-tooltip={sidebarCollapsed');
|
||||||
|
expect(sidebarItemStart).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(sidebarItemEnd).toBeGreaterThan(sidebarItemStart);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+923
-175
File diff suppressed because it is too large
Load Diff
+385
-2
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { api, type ProxyTestRequestEnvelope } from './api.js';
|
import { api, buildSiteProbeStreamUrl, userApi, type ProxyTestRequestEnvelope } from './api.js';
|
||||||
import { persistAuthSession } from './authSession.js';
|
import { commitSession, persistAuthSession } from './authSession.js';
|
||||||
|
|
||||||
function createMemoryStorage() {
|
function createMemoryStorage() {
|
||||||
const store = new Map<string, string>();
|
const store = new Map<string, string>();
|
||||||
@@ -122,6 +122,20 @@ describe('api proxy test timeout handling', () => {
|
|||||||
expect(result.error.message).toBe('请求超时(120s)');
|
expect(result.error.message).toBe('请求超时(120s)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds site probe stream URLs with frontend camelCase query names', () => {
|
||||||
|
expect(buildSiteProbeStreamUrl(9, {
|
||||||
|
scope: 'single',
|
||||||
|
modelName: ' gpt-4o ',
|
||||||
|
latencyThresholdMs: 1800.9,
|
||||||
|
})).toBe('/api/sites/9/probe-stream?scope=single&modelName=gpt-4o&latencyThresholdMs=1800');
|
||||||
|
|
||||||
|
expect(buildSiteProbeStreamUrl(9, {
|
||||||
|
scope: 'all',
|
||||||
|
modelName: 'gpt-4o',
|
||||||
|
latencyThresholdMs: 0,
|
||||||
|
})).toBe('/api/sites/9/probe-stream?scope=all');
|
||||||
|
});
|
||||||
|
|
||||||
it('times out replay hydration file-content fetches after 30 seconds', async () => {
|
it('times out replay hydration file-content fetches after 30 seconds', async () => {
|
||||||
installPendingFetch();
|
installPendingFetch();
|
||||||
|
|
||||||
@@ -179,3 +193,372 @@ describe('api proxy test timeout handling', () => {
|
|||||||
expect(api.proxyTestStream).toBe(api.testProxyStream);
|
expect(api.proxyTestStream).toBe(api.testProxyStream);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('api oauth compatibility', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.stubGlobal('localStorage', createMemoryStorage());
|
||||||
|
persistAuthSession(globalThis.localStorage as Storage, 'token-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes oauth provider responses before the page renders selectors', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
id: 'codex',
|
||||||
|
name: 'Codex',
|
||||||
|
type: 'codex',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: 'undefined',
|
||||||
|
label: 'undefined',
|
||||||
|
platform: 'undefined',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'gemini-cli',
|
||||||
|
displayName: 'Gemini CLI',
|
||||||
|
requires_project_id: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaults: {
|
||||||
|
systemProxyConfigured: true,
|
||||||
|
},
|
||||||
|
}), { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const result = await api.getOAuthProviders();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/oauth/providers', expect.any(Object));
|
||||||
|
expect(result.defaults?.systemProxyConfigured).toBe(true);
|
||||||
|
expect(result.providers).toEqual([
|
||||||
|
{
|
||||||
|
provider: 'codex',
|
||||||
|
label: 'Codex',
|
||||||
|
platform: 'codex',
|
||||||
|
enabled: true,
|
||||||
|
loginType: 'oauth',
|
||||||
|
requiresProjectId: false,
|
||||||
|
supportsDirectAccountRouting: true,
|
||||||
|
supportsCloudValidation: true,
|
||||||
|
supportsNativeProxy: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: 'gemini-cli',
|
||||||
|
label: 'Gemini CLI',
|
||||||
|
platform: 'gemini-cli',
|
||||||
|
enabled: true,
|
||||||
|
loginType: 'oauth',
|
||||||
|
requiresProjectId: true,
|
||||||
|
supportsDirectAccountRouting: true,
|
||||||
|
supportsCloudValidation: true,
|
||||||
|
supportsNativeProxy: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes legacy oauth connection list responses into the page contract', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
site_id: 2,
|
||||||
|
username: 'codex-user',
|
||||||
|
oauthProvider: 'codex',
|
||||||
|
oauthAccountKey: 'chatgpt-account-123',
|
||||||
|
oauthProjectId: 'project-a',
|
||||||
|
models_preview: '["gpt-5.4","gpt-5.2-codex"]',
|
||||||
|
expires_at: '2030-01-01T00:00:00+00:00',
|
||||||
|
token_expires_at: 1893456000000,
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
}), { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const result = await api.getOAuthConnections({ limit: 25, offset: 5 });
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/oauth/connections?limit=25&offset=5');
|
||||||
|
expect(result.items).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
accountId: 7,
|
||||||
|
siteId: 2,
|
||||||
|
username: 'codex-user',
|
||||||
|
provider: 'codex',
|
||||||
|
accountKey: 'chatgpt-account-123',
|
||||||
|
projectId: 'project-a',
|
||||||
|
modelCount: 2,
|
||||||
|
modelsPreview: ['gpt-5.4', 'gpt-5.2-codex'],
|
||||||
|
expiresAt: '2030-01-01T00:00:00+00:00',
|
||||||
|
tokenExpiresAt: 1893456000000,
|
||||||
|
status: 'healthy',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
expect(result.offset).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the Rust oauth route paths for quota and proxy mutations', async () => {
|
||||||
|
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ success: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
})));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await api.refreshOAuthConnectionQuota(7);
|
||||||
|
await api.refreshOAuthConnectionQuotaBatch([7, 8]);
|
||||||
|
await api.updateOAuthConnectionProxy(7, { proxyUrl: 'http://127.0.0.1:7890', useSystemProxy: false });
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls.map((call) => [call[0], (call[1] as RequestInit | undefined)?.method])).toEqual([
|
||||||
|
['/api/oauth/connections/7/refresh-quota', 'POST'],
|
||||||
|
['/api/oauth/connections/refresh-quota-batch', 'POST'],
|
||||||
|
['/api/oauth/connections/7/proxy', 'POST'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the Rust oauth route paths for connection import, deletion, and route pools', async () => {
|
||||||
|
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ success: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
})));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await api.deleteOAuthConnection(7);
|
||||||
|
await api.importOAuthConnections({ type: 'codex', access_token: 'token-a' });
|
||||||
|
await api.importOAuthConnections({ items: [{ type: 'claude', access_token: 'token-b' }] });
|
||||||
|
await api.createOAuthRouteUnit({
|
||||||
|
accountIds: [7, 8],
|
||||||
|
name: 'OAuth Pool',
|
||||||
|
strategy: 'round_robin',
|
||||||
|
});
|
||||||
|
await api.updateOAuthRouteUnit(42, {
|
||||||
|
name: 'OAuth Pool 2',
|
||||||
|
strategy: 'stick_until_unavailable',
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
await api.deleteOAuthRouteUnit(42);
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls.map((call) => [call[0], (call[1] as RequestInit | undefined)?.method])).toEqual([
|
||||||
|
['/api/oauth/connections/7', 'DELETE'],
|
||||||
|
['/api/oauth/import', 'POST'],
|
||||||
|
['/api/oauth/import', 'POST'],
|
||||||
|
['/api/oauth/route-units', 'POST'],
|
||||||
|
['/api/oauth/route-units/42', 'PUT'],
|
||||||
|
['/api/oauth/route-units/42', 'DELETE'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const singleImportInit = fetchMock.mock.calls[1]?.[1] as RequestInit | undefined;
|
||||||
|
const batchImportInit = fetchMock.mock.calls[2]?.[1] as RequestInit | undefined;
|
||||||
|
expect(singleImportInit?.body).toBe(JSON.stringify({ data: { type: 'codex', access_token: 'token-a' } }));
|
||||||
|
expect(batchImportInit?.body).toBe(JSON.stringify({ items: [{ type: 'claude', access_token: 'token-b' }] }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('api workflow model folder compatibility', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.stubGlobal('localStorage', createMemoryStorage());
|
||||||
|
persistAuthSession(globalThis.localStorage as Storage, 'token-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes model folder responses from items, names, data, and value fields', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
items: [
|
||||||
|
{ name: 'wan2.1-t2v', pathIndex: 0 },
|
||||||
|
{ value: 'seedance-1-0-lite-t2v-250428' },
|
||||||
|
],
|
||||||
|
names: ['wan2.1-t2v', 'seedance-1-0-pro-t2v-250528'],
|
||||||
|
data: [{ filename: 'kling-video' }],
|
||||||
|
}), { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const payload = await api.getWorkflowModelFolder('video_models');
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls[0]?.[0]).toBe('/workflow-ui/api/experiment/models/video_models');
|
||||||
|
expect((fetchMock.mock.calls[0]?.[1] as RequestInit)?.headers).toBeInstanceOf(Headers);
|
||||||
|
expect(((fetchMock.mock.calls[0]?.[1] as RequestInit)?.headers as Headers).get('Authorization')).toBe('Bearer token-1');
|
||||||
|
expect(payload.names).toEqual([
|
||||||
|
'wan2.1-t2v',
|
||||||
|
'seedance-1-0-lite-t2v-250428',
|
||||||
|
'kling-video',
|
||||||
|
'seedance-1-0-pro-t2v-250528',
|
||||||
|
]);
|
||||||
|
expect(payload.items.map((item) => item.name)).toEqual(payload.names);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('api proxy log filters', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.stubGlobal('localStorage', createMemoryStorage());
|
||||||
|
persistAuthSession(globalThis.localStorage as Storage, 'token-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes downstream key filters for server-driven proxy logs', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
summary: { total: 0, success: 0, failed: 0 },
|
||||||
|
}), { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await api.getProxyLogsQuery({
|
||||||
|
limit: 25,
|
||||||
|
offset: 50,
|
||||||
|
status: 'failed',
|
||||||
|
downstreamKeyId: 17,
|
||||||
|
search: 'gpt-4o',
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = String(fetchMock.mock.calls[0]?.[0] || '');
|
||||||
|
expect(url).toContain('/api/stats/proxy-logs?');
|
||||||
|
expect(url).toContain('downstreamKeyId=17');
|
||||||
|
expect(url).toContain('status=failed');
|
||||||
|
expect(url).toContain('search=gpt-4o');
|
||||||
|
expect(url).toContain('view=query');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses proxy log billing details from snake_case JSON strings', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 42,
|
||||||
|
model_requested: 'seedance-comfy',
|
||||||
|
status: 'success',
|
||||||
|
estimated_cost: 0.35,
|
||||||
|
billing_details: JSON.stringify({
|
||||||
|
source: 'comfy-native-task',
|
||||||
|
totalCost: 0.35,
|
||||||
|
steps: [{ name: 'archive_outputs', cost: 0.35 }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
summary: { total: 1, success: 1, failed: 0, cost: 0.35 },
|
||||||
|
}), { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const response = await api.getProxyLogsQuery({ limit: 1 });
|
||||||
|
|
||||||
|
expect(response.items[0]?.billingDetails).toMatchObject({
|
||||||
|
source: 'comfy-native-task',
|
||||||
|
totalCost: 0.35,
|
||||||
|
steps: [{ name: 'archive_outputs', cost: 0.35 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('user recharge api', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
const storage = createMemoryStorage();
|
||||||
|
vi.stubGlobal('localStorage', storage);
|
||||||
|
commitSession('user', 'user-token', undefined, storage);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates recharge checkout records before returning the checkout url', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
checkoutUrl: 'https://checkout.comfy.org/c/pay/session',
|
||||||
|
record: {
|
||||||
|
id: 1,
|
||||||
|
publicId: 'rch_test',
|
||||||
|
provider: 'comfy_checkout',
|
||||||
|
checkoutUrl: 'https://checkout.comfy.org/c/pay/session',
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
}), { status: 201, headers: { 'content-type': 'application/json' } }));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const payload = await userApi.createRechargeCheckout({
|
||||||
|
optionId: 'usd-50',
|
||||||
|
amountUsd: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.record.publicId).toBe('rch_test');
|
||||||
|
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/user/recharge/checkout');
|
||||||
|
expect((fetchMock.mock.calls[0]?.[1] as RequestInit)?.method).toBe('POST');
|
||||||
|
expect((fetchMock.mock.calls[0]?.[1] as RequestInit)?.body).toBe(JSON.stringify({
|
||||||
|
optionId: 'usd-50',
|
||||||
|
amountUsd: 50,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can cancel and clear user recharge records', async () => {
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
record: {
|
||||||
|
id: 1,
|
||||||
|
publicId: 'rch_test',
|
||||||
|
provider: 'comfy_checkout',
|
||||||
|
checkoutUrl: 'https://checkout.comfy.org/c/pay/session',
|
||||||
|
status: 'cancelled',
|
||||||
|
},
|
||||||
|
}), { status: 200, headers: { 'content-type': 'application/json' } }))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
cleared: 2,
|
||||||
|
records: [],
|
||||||
|
items: [],
|
||||||
|
}), { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const cancelled = await userApi.cancelRechargeRecord('rch_test');
|
||||||
|
const cleared = await userApi.clearRechargeRecords();
|
||||||
|
|
||||||
|
expect(cancelled.record.status).toBe('cancelled');
|
||||||
|
expect(cleared.cleared).toBe(2);
|
||||||
|
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/user/recharge-records/rch_test/cancel');
|
||||||
|
expect((fetchMock.mock.calls[0]?.[1] as RequestInit)?.method).toBe('POST');
|
||||||
|
expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/user/recharge-records');
|
||||||
|
expect((fetchMock.mock.calls[1]?.[1] as RequestInit)?.method).toBe('DELETE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps billing guidance on quota errors', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
error: { message: '下游密钥成本额度已用完' },
|
||||||
|
retry_after_secs: 60,
|
||||||
|
billing: {
|
||||||
|
reason: 'insufficient_quota',
|
||||||
|
rechargePath: '/user/recharge',
|
||||||
|
usagePath: '/user/usage',
|
||||||
|
},
|
||||||
|
}), { status: 429, headers: { 'content-type': 'application/json' } }));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await expect(userApi.createMoneyPrinterJob({ topic: 'demo' })).rejects.toMatchObject({
|
||||||
|
status: 429,
|
||||||
|
message: '下游密钥成本额度已用完',
|
||||||
|
billing: {
|
||||||
|
rechargePath: '/user/recharge',
|
||||||
|
usagePath: '/user/usage',
|
||||||
|
},
|
||||||
|
retryAfterSecs: 60,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+581
-58
@@ -11,6 +11,22 @@ type RequestOptions = RequestInit & {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ApiRequestError = Error & {
|
||||||
|
status?: number;
|
||||||
|
payload?: any;
|
||||||
|
billing?: any;
|
||||||
|
retryAfterSecs?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createApiRequestError(message: string, status: number, payload: any): ApiRequestError {
|
||||||
|
const error = new Error(message) as ApiRequestError;
|
||||||
|
error.status = status;
|
||||||
|
error.payload = payload;
|
||||||
|
error.billing = payload?.billing ?? payload?.error?.billing ?? null;
|
||||||
|
error.retryAfterSecs = payload?.retry_after_secs ?? payload?.retryAfterSecs ?? null;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
function requireAuthToken(): string {
|
function requireAuthToken(): string {
|
||||||
const token = getSessionToken('admin', localStorage);
|
const token = getSessionToken('admin', localStorage);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -28,13 +44,15 @@ function requireAuthToken(): string {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function extractResponseErrorMessage(res: Response): Promise<string> {
|
async function extractResponseError(res: Response): Promise<ApiRequestError> {
|
||||||
let message = `HTTP ${res.status}`;
|
let message = `HTTP ${res.status}`;
|
||||||
|
let payload: any = null;
|
||||||
try {
|
try {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
if (text) {
|
if (text) {
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(text);
|
const json = JSON.parse(text);
|
||||||
|
payload = json;
|
||||||
if (json?.message && typeof json.message === "string") {
|
if (json?.message && typeof json.message === "string") {
|
||||||
message = json.message;
|
message = json.message;
|
||||||
} else if (json?.error && typeof json.error === "string") {
|
} else if (json?.error && typeof json.error === "string") {
|
||||||
@@ -47,12 +65,19 @@ async function extractResponseErrorMessage(res: Response): Promise<string> {
|
|||||||
} else {
|
} else {
|
||||||
message = `${message}: ${text.slice(0, 120)}`;
|
message = `${message}: ${text.slice(0, 120)}`;
|
||||||
}
|
}
|
||||||
|
if (typeof json?.path === "string" && json.path.trim()) {
|
||||||
|
message = `${message} (${json.path.trim()})`;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
message = `${message}: ${text.slice(0, 120)}`;
|
message = `${message}: ${text.slice(0, 120)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
return message;
|
return createApiRequestError(message, res.status, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractResponseErrorMessage(res: Response): Promise<string> {
|
||||||
|
return (await extractResponseError(res)).message;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parseJsonResponse<T = any>(res: Response): Promise<T> {
|
async function parseJsonResponse<T = any>(res: Response): Promise<T> {
|
||||||
@@ -248,13 +273,54 @@ export async function fetchSessionResponse(
|
|||||||
: fetchAuthenticatedResponse(url, options);
|
: fetchAuthenticatedResponse(url, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SiteProbeOptions = {
|
||||||
|
scope?: "single" | "all";
|
||||||
|
modelName?: string;
|
||||||
|
latencyThresholdMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildSiteProbeStreamUrl(
|
||||||
|
siteId: number,
|
||||||
|
options: SiteProbeOptions = {},
|
||||||
|
): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const scope = options.scope === "all" ? "all" : "single";
|
||||||
|
params.set("scope", scope);
|
||||||
|
|
||||||
|
const modelName = typeof options.modelName === "string"
|
||||||
|
? options.modelName.trim()
|
||||||
|
: "";
|
||||||
|
if (scope === "single" && modelName) {
|
||||||
|
params.set("modelName", modelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latencyThresholdMs = Number(options.latencyThresholdMs);
|
||||||
|
if (Number.isFinite(latencyThresholdMs) && latencyThresholdMs > 0) {
|
||||||
|
params.set("latencyThresholdMs", String(Math.trunc(latencyThresholdMs)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/api/sites/${siteId}/probe-stream?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSiteProbeStream(
|
||||||
|
siteId: number,
|
||||||
|
options: SiteProbeOptions = {},
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Response> {
|
||||||
|
return fetchSessionResponse("admin", buildSiteProbeStreamUrl(siteId, options), {
|
||||||
|
signal,
|
||||||
|
headers: { Accept: "text/event-stream" },
|
||||||
|
timeoutMs: options.scope === "all" ? 300_000 : 120_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function userRequest<T = any>(
|
async function userRequest<T = any>(
|
||||||
url: string,
|
url: string,
|
||||||
options: RequestOptions = {},
|
options: RequestOptions = {},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const res = await fetchUserResponse(url, options);
|
const res = await fetchUserResponse(url, options);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(await extractResponseErrorMessage(res));
|
throw await extractResponseError(res);
|
||||||
}
|
}
|
||||||
return parseJsonResponse<T>(res);
|
return parseJsonResponse<T>(res);
|
||||||
}
|
}
|
||||||
@@ -265,7 +331,7 @@ async function request<T = any>(
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const res = await fetchAuthenticatedResponse(url, options);
|
const res = await fetchAuthenticatedResponse(url, options);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(await extractResponseErrorMessage(res));
|
throw await extractResponseError(res);
|
||||||
}
|
}
|
||||||
return parseJsonResponse<T>(res);
|
return parseJsonResponse<T>(res);
|
||||||
}
|
}
|
||||||
@@ -299,6 +365,20 @@ function parseJsonArrayField(value: any): any[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseJsonObjectField(value: any): any {
|
||||||
|
if (!value) return null;
|
||||||
|
if (typeof value === "object") return value;
|
||||||
|
if (typeof value !== "string" || !value.trim()) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||||
|
? parsed
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRouteRow(row: any): any {
|
function normalizeRouteRow(row: any): any {
|
||||||
if (!row || typeof row !== "object") return row;
|
if (!row || typeof row !== "object") return row;
|
||||||
return {
|
return {
|
||||||
@@ -322,6 +402,15 @@ function normalizeDownstreamKeyRow(row: any): any {
|
|||||||
groupName: row.groupName ?? row.group_name ?? null,
|
groupName: row.groupName ?? row.group_name ?? null,
|
||||||
usedCost: Number(row.usedCost ?? row.used_cost ?? 0),
|
usedCost: Number(row.usedCost ?? row.used_cost ?? 0),
|
||||||
usedRequests: Number(row.usedRequests ?? row.used_requests ?? 0),
|
usedRequests: Number(row.usedRequests ?? row.used_requests ?? 0),
|
||||||
|
billing: row.billing ?? null,
|
||||||
|
rangeUsage: row.rangeUsage ?? row.range_usage ?? {
|
||||||
|
totalRequests: 0,
|
||||||
|
successRequests: 0,
|
||||||
|
failedRequests: 0,
|
||||||
|
successRate: null,
|
||||||
|
totalTokens: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
},
|
||||||
maxCost: row.maxCost ?? row.max_cost ?? null,
|
maxCost: row.maxCost ?? row.max_cost ?? null,
|
||||||
maxRequests: row.maxRequests ?? row.max_requests ?? null,
|
maxRequests: row.maxRequests ?? row.max_requests ?? null,
|
||||||
supportedModels: row.supportedModels ?? parseJsonArrayField(row.supported_models),
|
supportedModels: row.supportedModels ?? parseJsonArrayField(row.supported_models),
|
||||||
@@ -383,6 +472,11 @@ function normalizeProxyLogRow(row: any): any {
|
|||||||
clientAppId: row.clientAppId ?? row.client_app_id ?? null,
|
clientAppId: row.clientAppId ?? row.client_app_id ?? null,
|
||||||
clientAppName: row.clientAppName ?? row.client_app_name ?? null,
|
clientAppName: row.clientAppName ?? row.client_app_name ?? null,
|
||||||
clientConfidence: row.clientConfidence ?? row.client_confidence ?? null,
|
clientConfidence: row.clientConfidence ?? row.client_confidence ?? null,
|
||||||
|
billingDetails:
|
||||||
|
row.billingDetails ?? parseJsonObjectField(row.billing_details),
|
||||||
|
contentSummary: row.contentSummary ?? row.content_summary ?? null,
|
||||||
|
requestContent: row.requestContent ?? row.request_content ?? null,
|
||||||
|
responseContent: row.responseContent ?? row.response_content ?? null,
|
||||||
createdAt: row.createdAt ?? row.created_at ?? null,
|
createdAt: row.createdAt ?? row.created_at ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -460,6 +554,144 @@ function normalizeProxyDebugTraceDetail(response: any): ProxyDebugTraceDetail {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOAuthConnectionRow(row: any): OAuthConnectionInfo | null {
|
||||||
|
if (!row || typeof row !== "object") return null;
|
||||||
|
const rawAccountId = row.accountId ?? row.account_id ?? row.id;
|
||||||
|
const accountId = Number(rawAccountId);
|
||||||
|
if (!Number.isFinite(accountId) || accountId <= 0) return null;
|
||||||
|
|
||||||
|
const rawSiteId = row.siteId ?? row.site_id ?? row.site?.id ?? 0;
|
||||||
|
const siteId = Number(rawSiteId);
|
||||||
|
const provider = String(row.provider ?? row.oauthProvider ?? row.oauth_provider ?? "").trim();
|
||||||
|
const modelsPreview = parseJsonArrayField(row.modelsPreview ?? row.models_preview)
|
||||||
|
.map((item) => String(item || "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const modelCount = Number(row.modelCount ?? row.model_count ?? modelsPreview.length);
|
||||||
|
const routeChannelCount = Number(row.routeChannelCount ?? row.route_channel_count);
|
||||||
|
const rawStatus = String(row.status ?? "").trim().toLowerCase();
|
||||||
|
const status: OAuthConnectionInfo["status"] = rawStatus === "healthy" || rawStatus === "active" || rawStatus === "enabled"
|
||||||
|
? "healthy"
|
||||||
|
: "abnormal";
|
||||||
|
const rawSite = row.site && typeof row.site === "object" ? row.site : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
accountId,
|
||||||
|
siteId: Number.isFinite(siteId) ? siteId : 0,
|
||||||
|
provider,
|
||||||
|
username: row.username ?? null,
|
||||||
|
email: row.email ?? null,
|
||||||
|
accountKey: row.accountKey ?? row.account_key ?? row.oauthAccountKey ?? row.oauth_account_key ?? null,
|
||||||
|
planType: row.planType ?? row.plan_type ?? null,
|
||||||
|
projectId: row.projectId ?? row.project_id ?? row.oauthProjectId ?? row.oauth_project_id ?? null,
|
||||||
|
modelCount: Number.isFinite(modelCount) ? modelCount : modelsPreview.length,
|
||||||
|
modelsPreview,
|
||||||
|
status,
|
||||||
|
quota: row.quota ?? null,
|
||||||
|
expiresAt: row.expiresAt ?? row.expires_at ?? null,
|
||||||
|
tokenExpiresAt: row.tokenExpiresAt ?? row.token_expires_at ?? null,
|
||||||
|
routeChannelCount: Number.isFinite(routeChannelCount) ? routeChannelCount : undefined,
|
||||||
|
lastModelSyncAt: row.lastModelSyncAt ?? row.last_model_sync_at ?? null,
|
||||||
|
lastModelSyncError: row.lastModelSyncError ?? row.last_model_sync_error ?? null,
|
||||||
|
proxyUrl: row.proxyUrl ?? row.proxy_url ?? null,
|
||||||
|
useSystemProxy: row.useSystemProxy ?? row.use_system_proxy ?? false,
|
||||||
|
routeUnit: row.routeUnit ?? row.route_unit ?? null,
|
||||||
|
routeParticipation: row.routeParticipation ?? row.route_participation ?? null,
|
||||||
|
site: rawSite
|
||||||
|
? {
|
||||||
|
id: Number(rawSite.id ?? rawSite.siteId ?? rawSite.site_id ?? siteId) || 0,
|
||||||
|
name: String(rawSite.name ?? rawSite.siteName ?? rawSite.site_name ?? "").trim(),
|
||||||
|
url: String(rawSite.url ?? rawSite.siteUrl ?? rawSite.site_url ?? "").trim(),
|
||||||
|
platform: String(rawSite.platform ?? provider).trim(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOAuthConnectionsResponse(response: any): OAuthConnectionsResponse {
|
||||||
|
const wrapped = wrapItemsResponse(response);
|
||||||
|
const items = unwrapArrayResponse(wrapped)
|
||||||
|
.map(normalizeOAuthConnectionRow)
|
||||||
|
.filter((item): item is OAuthConnectionInfo => item != null);
|
||||||
|
return {
|
||||||
|
...wrapped,
|
||||||
|
items,
|
||||||
|
total: Number(wrapped.total ?? items.length),
|
||||||
|
limit: Number(wrapped.limit ?? items.length),
|
||||||
|
offset: Number(wrapped.offset ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanOAuthProviderText(value: unknown): string {
|
||||||
|
if (typeof value !== "string") return "";
|
||||||
|
const text = value.trim();
|
||||||
|
const lowerText = text.toLowerCase();
|
||||||
|
if (lowerText === "undefined" || lowerText === "null") return "";
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOAuthProviderFallbackLabel(providerKey: string): string {
|
||||||
|
const normalized = providerKey.trim().toLowerCase();
|
||||||
|
if (normalized === "codex") return "Codex";
|
||||||
|
if (normalized === "openai") return "OpenAI";
|
||||||
|
if (normalized === "anthropic") return "Anthropic";
|
||||||
|
if (normalized === "claude") return "Claude";
|
||||||
|
if (normalized === "google") return "Google AI";
|
||||||
|
if (normalized === "gemini-cli" || normalized === "gemini") return "Gemini CLI";
|
||||||
|
if (normalized === "antigravity") return "Antigravity";
|
||||||
|
return providerKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOAuthProviderRow(row: any): OAuthProviderInfo | null {
|
||||||
|
if (!row || typeof row !== "object") return null;
|
||||||
|
|
||||||
|
const provider = cleanOAuthProviderText(row.provider)
|
||||||
|
|| cleanOAuthProviderText(row.oauthProvider)
|
||||||
|
|| cleanOAuthProviderText(row.oauth_provider)
|
||||||
|
|| cleanOAuthProviderText(row.id)
|
||||||
|
|| cleanOAuthProviderText(row.key)
|
||||||
|
|| cleanOAuthProviderText(row.value)
|
||||||
|
|| cleanOAuthProviderText(row.type);
|
||||||
|
if (!provider) return null;
|
||||||
|
|
||||||
|
const label = cleanOAuthProviderText(row.label)
|
||||||
|
|| cleanOAuthProviderText(row.name)
|
||||||
|
|| cleanOAuthProviderText(row.displayName)
|
||||||
|
|| cleanOAuthProviderText(row.display_name)
|
||||||
|
|| resolveOAuthProviderFallbackLabel(provider);
|
||||||
|
const platform = cleanOAuthProviderText(row.platform)
|
||||||
|
|| cleanOAuthProviderText(row.type)
|
||||||
|
|| provider;
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
label,
|
||||||
|
platform,
|
||||||
|
enabled: row.enabled !== false,
|
||||||
|
loginType: "oauth",
|
||||||
|
requiresProjectId: row.requiresProjectId === true || row.requires_project_id === true,
|
||||||
|
supportsDirectAccountRouting: row.supportsDirectAccountRouting ?? row.supports_direct_account_routing ?? true,
|
||||||
|
supportsCloudValidation: row.supportsCloudValidation ?? row.supports_cloud_validation ?? true,
|
||||||
|
supportsNativeProxy: row.supportsNativeProxy ?? row.supports_native_proxy ?? true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOAuthProvidersResponse(response: any): OAuthProvidersResponse {
|
||||||
|
const rawProviders: unknown[] = Array.isArray(response?.providers) ? response.providers : [];
|
||||||
|
const providers = rawProviders
|
||||||
|
.map(normalizeOAuthProviderRow)
|
||||||
|
.filter((item): item is OAuthProviderInfo => item != null);
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
providers: Array.from(
|
||||||
|
new Map(providers.map((provider) => [provider.provider, provider])).values(),
|
||||||
|
),
|
||||||
|
defaults: response?.defaults && typeof response.defaults === "object"
|
||||||
|
? response.defaults
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function streamSse(
|
async function streamSse(
|
||||||
url: string,
|
url: string,
|
||||||
handlers: {
|
handlers: {
|
||||||
@@ -683,6 +915,8 @@ export type RuntimeSettingsPayload = {
|
|||||||
barkUrl?: string;
|
barkUrl?: string;
|
||||||
webhookEnabled?: boolean;
|
webhookEnabled?: boolean;
|
||||||
barkEnabled?: boolean;
|
barkEnabled?: boolean;
|
||||||
|
feishuEnabled?: boolean;
|
||||||
|
feishuWebhook?: string;
|
||||||
serverChanEnabled?: boolean;
|
serverChanEnabled?: boolean;
|
||||||
serverChanKey?: string;
|
serverChanKey?: string;
|
||||||
telegramEnabled?: boolean;
|
telegramEnabled?: boolean;
|
||||||
@@ -775,6 +1009,10 @@ export type ProxyLogListItem = {
|
|||||||
promptTokens?: number | null;
|
promptTokens?: number | null;
|
||||||
completionTokens?: number | null;
|
completionTokens?: number | null;
|
||||||
estimatedCost?: number | null;
|
estimatedCost?: number | null;
|
||||||
|
billingDetails?: ProxyLogBillingDetails;
|
||||||
|
contentSummary?: string | null;
|
||||||
|
requestContent?: string | null;
|
||||||
|
responseContent?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProxyLogDetail = ProxyLogListItem & {
|
export type ProxyLogDetail = ProxyLogListItem & {
|
||||||
@@ -799,6 +1037,7 @@ export type ProxyLogsQuery = {
|
|||||||
search?: string;
|
search?: string;
|
||||||
client?: string;
|
client?: string;
|
||||||
siteId?: number;
|
siteId?: number;
|
||||||
|
downstreamKeyId?: number;
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
};
|
};
|
||||||
@@ -985,6 +1224,8 @@ export type OAuthConnectionInfo = {
|
|||||||
modelsPreview: string[];
|
modelsPreview: string[];
|
||||||
status: "healthy" | "abnormal";
|
status: "healthy" | "abnormal";
|
||||||
quota?: OAuthQuotaInfo | null;
|
quota?: OAuthQuotaInfo | null;
|
||||||
|
expiresAt?: string | null;
|
||||||
|
tokenExpiresAt?: number | null;
|
||||||
routeChannelCount?: number;
|
routeChannelCount?: number;
|
||||||
lastModelSyncAt?: string | null;
|
lastModelSyncAt?: string | null;
|
||||||
lastModelSyncError?: string | null;
|
lastModelSyncError?: string | null;
|
||||||
@@ -1017,11 +1258,12 @@ export type OAuthQuotaBatchRefreshResponse = {
|
|||||||
export type OAuthImportResponse = {
|
export type OAuthImportResponse = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
imported: number;
|
imported: number;
|
||||||
|
updated?: number;
|
||||||
skipped: number;
|
skipped: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
items: Array<{
|
items: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
status: "imported" | "skipped" | "failed";
|
status: "imported" | "updated" | "skipped" | "failed";
|
||||||
accountId?: number;
|
accountId?: number;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
@@ -1083,6 +1325,13 @@ export type WorkspaceAssetsResponse = {
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorkspaceBatchDeleteResponse = {
|
||||||
|
success: boolean;
|
||||||
|
deletedCount: number;
|
||||||
|
deletedIds: string[];
|
||||||
|
failedIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkspaceAssetQuery = {
|
export type WorkspaceAssetQuery = {
|
||||||
source?: string;
|
source?: string;
|
||||||
kind?: string;
|
kind?: string;
|
||||||
@@ -1142,6 +1391,219 @@ export type WorkflowSavePayload = {
|
|||||||
isTemplate?: boolean;
|
isTemplate?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DigitalHumanJob = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
message?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
progress: number;
|
||||||
|
progressTotal: number;
|
||||||
|
result?: any;
|
||||||
|
createdAt?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
startedAt?: string | null;
|
||||||
|
finishedAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DigitalHumanJobResponse = {
|
||||||
|
success: boolean;
|
||||||
|
job: DigitalHumanJob;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DigitalHumanJobsResponse = {
|
||||||
|
success: boolean;
|
||||||
|
jobs: DigitalHumanJob[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DigitalHumanStatusResponse = {
|
||||||
|
success: boolean;
|
||||||
|
mode: string;
|
||||||
|
keyName?: string;
|
||||||
|
capabilities?: string[];
|
||||||
|
runninghub?: {
|
||||||
|
configured?: boolean;
|
||||||
|
endpoint?: string;
|
||||||
|
};
|
||||||
|
comfy?: {
|
||||||
|
configured?: boolean;
|
||||||
|
backend?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MoneyPrinterJob = DigitalHumanJob;
|
||||||
|
|
||||||
|
export type MoneyPrinterJobResponse = {
|
||||||
|
success: boolean;
|
||||||
|
job: MoneyPrinterJob;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MoneyPrinterJobsResponse = {
|
||||||
|
success: boolean;
|
||||||
|
jobs: MoneyPrinterJob[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MoneyPrinterStatusResponse = {
|
||||||
|
success: boolean;
|
||||||
|
mode: string;
|
||||||
|
keyName?: string;
|
||||||
|
capabilities?: string[];
|
||||||
|
comfy?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
backend?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
};
|
||||||
|
builtinCloudTemplate?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
label?: string;
|
||||||
|
nodeType?: string;
|
||||||
|
model?: string;
|
||||||
|
models?: string[];
|
||||||
|
duration?: {
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
default?: number;
|
||||||
|
};
|
||||||
|
resolutions?: string[];
|
||||||
|
ratios?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserRechargeRecord = {
|
||||||
|
id: number;
|
||||||
|
publicId: string;
|
||||||
|
userId?: number;
|
||||||
|
optionId?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
amountUsd?: number | null;
|
||||||
|
provider: string;
|
||||||
|
checkoutUrl: string;
|
||||||
|
status: string;
|
||||||
|
note?: string | null;
|
||||||
|
metadata?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
completedAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserBillingSummary = {
|
||||||
|
keyCount: number;
|
||||||
|
usedCost: number;
|
||||||
|
maxCost?: number | null;
|
||||||
|
remainingCost?: number | null;
|
||||||
|
unlimitedKeyCount: number;
|
||||||
|
rechargePath: string;
|
||||||
|
usagePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserRechargeRecordsResponse = {
|
||||||
|
success: boolean;
|
||||||
|
records: UserRechargeRecord[];
|
||||||
|
items: UserRechargeRecord[];
|
||||||
|
billing?: UserBillingSummary;
|
||||||
|
arrivalNote?: string;
|
||||||
|
checkoutProvider?: string;
|
||||||
|
usagePath?: string;
|
||||||
|
rechargePath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateUserRechargeCheckoutPayload = {
|
||||||
|
optionId?: string;
|
||||||
|
amountUsd?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateUserRechargeCheckoutResponse = {
|
||||||
|
success: boolean;
|
||||||
|
record: UserRechargeRecord;
|
||||||
|
checkoutUrl: string;
|
||||||
|
arrivalNote?: string;
|
||||||
|
checkoutProvider?: string;
|
||||||
|
billing?: UserBillingSummary;
|
||||||
|
usagePath?: string;
|
||||||
|
rechargePath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserRechargeRecordActionResponse = {
|
||||||
|
success: boolean;
|
||||||
|
record: UserRechargeRecord;
|
||||||
|
billing?: UserBillingSummary;
|
||||||
|
arrivalNote?: string;
|
||||||
|
checkoutProvider?: string;
|
||||||
|
usagePath?: string;
|
||||||
|
rechargePath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClearUserRechargeRecordsResponse = {
|
||||||
|
success: boolean;
|
||||||
|
cleared: number;
|
||||||
|
records: UserRechargeRecord[];
|
||||||
|
items: UserRechargeRecord[];
|
||||||
|
billing?: UserBillingSummary;
|
||||||
|
arrivalNote?: string;
|
||||||
|
checkoutProvider?: string;
|
||||||
|
usagePath?: string;
|
||||||
|
rechargePath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowModelItem = {
|
||||||
|
name: string;
|
||||||
|
pathIndex?: number;
|
||||||
|
path_index?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowModelFolderResponse = {
|
||||||
|
success: boolean;
|
||||||
|
items: WorkflowModelItem[];
|
||||||
|
names: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeWorkflowModelFolderResponse(value: any): WorkflowModelFolderResponse {
|
||||||
|
const rawItems = Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: ["items", "data", "models", "names"].flatMap((key) => (
|
||||||
|
Array.isArray(value?.[key]) ? value[key] : []
|
||||||
|
));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const items: WorkflowModelItem[] = [];
|
||||||
|
|
||||||
|
for (const item of rawItems) {
|
||||||
|
const name = String(
|
||||||
|
typeof item === "string"
|
||||||
|
? item
|
||||||
|
: item?.name ?? item?.model ?? item?.value ?? item?.filename ?? item?.path ?? "",
|
||||||
|
).trim();
|
||||||
|
if (!name || seen.has(name)) continue;
|
||||||
|
seen.add(name);
|
||||||
|
items.push({
|
||||||
|
...(item && typeof item === "object" && !Array.isArray(item) ? item : {}),
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: value?.success ?? true,
|
||||||
|
items,
|
||||||
|
names: items.map((item) => item.name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function workflowModelFolderRequest(
|
||||||
|
requester: <T = any>(url: string, options?: RequestOptions) => Promise<T>,
|
||||||
|
folder: string,
|
||||||
|
): Promise<WorkflowModelFolderResponse> {
|
||||||
|
const normalized = folder.trim();
|
||||||
|
if (!normalized) return { success: true, items: [], names: [] };
|
||||||
|
const value = await requester<any>(
|
||||||
|
`/workflow-ui/api/experiment/models/${encodeURIComponent(normalized)}`,
|
||||||
|
{ method: "GET" },
|
||||||
|
);
|
||||||
|
return normalizeWorkflowModelFolderResponse(value);
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
// Sites
|
// Sites
|
||||||
getSites: async () => {
|
getSites: async () => {
|
||||||
@@ -1171,12 +1633,14 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
getSiteAvailableModels: (siteId: number) =>
|
getSiteAvailableModels: (siteId: number) =>
|
||||||
request(`/api/sites/${siteId}/available-models`),
|
request(`/api/sites/${siteId}/available-models`),
|
||||||
probeSiteNow: (siteId: number, options?: { scope?: 'single' | 'all'; modelName?: string; latencyThresholdMs?: number }) =>
|
probeSiteNow: (siteId: number, options?: SiteProbeOptions) =>
|
||||||
request(`/api/sites/${siteId}/probe-now`, {
|
request(`/api/sites/${siteId}/probe-now`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(options || {}),
|
body: JSON.stringify(options || {}),
|
||||||
timeoutMs: options?.scope === 'all' ? 120_000 : 30_000,
|
timeoutMs: options?.scope === 'all' ? 120_000 : 30_000,
|
||||||
}),
|
}),
|
||||||
|
probeSiteStream: (siteId: number, options?: SiteProbeOptions, signal?: AbortSignal) =>
|
||||||
|
fetchSiteProbeStream(siteId, options, signal),
|
||||||
|
|
||||||
// Accounts
|
// Accounts
|
||||||
getAccounts: async (params?: { includeOauth?: boolean }) => {
|
getAccounts: async (params?: { includeOauth?: boolean }) => {
|
||||||
@@ -1556,7 +2020,8 @@ export const api = {
|
|||||||
|
|
||||||
// OAuth
|
// OAuth
|
||||||
getOAuthProviders: () =>
|
getOAuthProviders: () =>
|
||||||
request("/api/oauth/providers") as Promise<OAuthProvidersResponse>,
|
request("/api/oauth/providers")
|
||||||
|
.then(normalizeOAuthProvidersResponse) as Promise<OAuthProvidersResponse>,
|
||||||
startOAuthProvider: (
|
startOAuthProvider: (
|
||||||
provider: string,
|
provider: string,
|
||||||
data?: {
|
data?: {
|
||||||
@@ -1585,14 +2050,14 @@ export const api = {
|
|||||||
getOAuthConnections: (params?: { limit?: number; offset?: number }) =>
|
getOAuthConnections: (params?: { limit?: number; offset?: number }) =>
|
||||||
request(
|
request(
|
||||||
`/api/oauth/connections${buildQueryString(params)}`,
|
`/api/oauth/connections${buildQueryString(params)}`,
|
||||||
) as Promise<OAuthConnectionsResponse>,
|
).then(normalizeOAuthConnectionsResponse) as Promise<OAuthConnectionsResponse>,
|
||||||
refreshOAuthConnectionQuota: (accountId: number) =>
|
refreshOAuthConnectionQuota: (accountId: number) =>
|
||||||
request(`/api/oauth/connections/${accountId}/quota/refresh`, {
|
request(`/api/oauth/connections/${accountId}/refresh-quota`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
}) as Promise<{ success: true; quota: OAuthQuotaInfo }>,
|
}) as Promise<{ success: true; quota: OAuthQuotaInfo }>,
|
||||||
refreshOAuthConnectionQuotaBatch: (accountIds: number[]) =>
|
refreshOAuthConnectionQuotaBatch: (accountIds: number[]) =>
|
||||||
request("/api/oauth/connections/quota/refresh-batch", {
|
request("/api/oauth/connections/refresh-quota-batch", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ accountIds }),
|
body: JSON.stringify({ accountIds }),
|
||||||
}) as Promise<OAuthQuotaBatchRefreshResponse>,
|
}) as Promise<OAuthQuotaBatchRefreshResponse>,
|
||||||
@@ -1601,7 +2066,7 @@ export const api = {
|
|||||||
data: { proxyUrl?: string | null; useSystemProxy?: boolean },
|
data: { proxyUrl?: string | null; useSystemProxy?: boolean },
|
||||||
) =>
|
) =>
|
||||||
request(`/api/oauth/connections/${accountId}/proxy`, {
|
request(`/api/oauth/connections/${accountId}/proxy`, {
|
||||||
method: "PATCH",
|
method: "POST",
|
||||||
body: JSON.stringify(data || {}),
|
body: JSON.stringify(data || {}),
|
||||||
}) as Promise<{ success: true }>,
|
}) as Promise<{ success: true }>,
|
||||||
rebindOAuthConnection: (
|
rebindOAuthConnection: (
|
||||||
@@ -1630,6 +2095,18 @@ export const api = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}) as Promise<OAuthRouteUnitMutationResponse>,
|
}) as Promise<OAuthRouteUnitMutationResponse>,
|
||||||
|
updateOAuthRouteUnit: (
|
||||||
|
routeUnitId: number,
|
||||||
|
data: {
|
||||||
|
name?: string;
|
||||||
|
strategy?: OAuthRouteUnitStrategy;
|
||||||
|
enabled?: boolean;
|
||||||
|
},
|
||||||
|
) =>
|
||||||
|
request(`/api/oauth/route-units/${routeUnitId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(data || {}),
|
||||||
|
}) as Promise<OAuthRouteUnitMutationResponse>,
|
||||||
deleteOAuthRouteUnit: (routeUnitId: number) =>
|
deleteOAuthRouteUnit: (routeUnitId: number) =>
|
||||||
request(`/api/oauth/route-units/${routeUnitId}`, {
|
request(`/api/oauth/route-units/${routeUnitId}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
@@ -1704,43 +2181,6 @@ export const api = {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
getUpdateCenterStatus: () => request("/api/update-center/status"),
|
|
||||||
saveUpdateCenterConfig: (data: any) =>
|
|
||||||
request("/api/update-center/config", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
checkUpdateCenter: () =>
|
|
||||||
request("/api/update-center/check", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
}),
|
|
||||||
deployUpdateCenter: (data: {
|
|
||||||
source: "github-release" | "docker-hub-tag";
|
|
||||||
targetTag: string;
|
|
||||||
targetDigest?: string | null;
|
|
||||||
}) =>
|
|
||||||
request("/api/update-center/deploy", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
rollbackUpdateCenter: (data: { targetRevision: string }) =>
|
|
||||||
request("/api/update-center/rollback", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
streamUpdateCenterTaskLogs: (
|
|
||||||
taskId: string,
|
|
||||||
handlers: {
|
|
||||||
onLog?: (entry: any) => void;
|
|
||||||
onDone?: (payload: any) => void;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
},
|
|
||||||
) =>
|
|
||||||
streamSse(
|
|
||||||
`/api/update-center/tasks/${encodeURIComponent(taskId)}/stream`,
|
|
||||||
handlers,
|
|
||||||
),
|
|
||||||
testSystemProxy: (data: SystemProxyTestRequest) =>
|
testSystemProxy: (data: SystemProxyTestRequest) =>
|
||||||
request("/api/settings/system-proxy/test", {
|
request("/api/settings/system-proxy/test", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1873,15 +2313,6 @@ export const api = {
|
|||||||
testNotification: () =>
|
testNotification: () =>
|
||||||
request("/api/settings/notify/test", { method: "POST" }),
|
request("/api/settings/notify/test", { method: "POST" }),
|
||||||
|
|
||||||
// Monitor embed
|
|
||||||
getMonitorConfig: () => request("/api/monitor/config"),
|
|
||||||
updateMonitorConfig: (data: { ldohCookie?: string | null }) =>
|
|
||||||
request("/api/monitor/config", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
initMonitorSession: () => request("/api/monitor/session", { method: "POST" }),
|
|
||||||
|
|
||||||
// Models marketplace
|
// Models marketplace
|
||||||
getModelsMarketplace: (options?: {
|
getModelsMarketplace: (options?: {
|
||||||
refresh?: boolean;
|
refresh?: boolean;
|
||||||
@@ -2000,8 +2431,15 @@ export const api = {
|
|||||||
request<WorkspaceAssetsResponse>(`/api/workspace/assets${buildQueryString(params)}`),
|
request<WorkspaceAssetsResponse>(`/api/workspace/assets${buildQueryString(params)}`),
|
||||||
deleteWorkspaceAsset: (publicId: string) =>
|
deleteWorkspaceAsset: (publicId: string) =>
|
||||||
request(`/api/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
|
request(`/api/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
|
||||||
|
batchDeleteWorkspaceAssets: (publicIds: string[]) =>
|
||||||
|
request<WorkspaceBatchDeleteResponse>("/api/workspace/assets/batch-delete", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ publicIds }),
|
||||||
|
}),
|
||||||
getWorkflowNodeTypes: () =>
|
getWorkflowNodeTypes: () =>
|
||||||
request<{ success: boolean; nodeTypes: any[] }>("/api/workflow/node-types"),
|
request<{ success: boolean; nodeTypes: any[] }>("/api/workflow/node-types"),
|
||||||
|
getWorkflowModelFolder: (folder: string) =>
|
||||||
|
workflowModelFolderRequest(request, folder),
|
||||||
getWorkflows: (params?: { include_public?: boolean }) =>
|
getWorkflows: (params?: { include_public?: boolean }) =>
|
||||||
request<{ success: boolean; items: WorkflowSummary[] }>(`/api/workflows${buildQueryString(params)}`),
|
request<{ success: boolean; items: WorkflowSummary[] }>(`/api/workflows${buildQueryString(params)}`),
|
||||||
createWorkflow: (data: WorkflowSavePayload) =>
|
createWorkflow: (data: WorkflowSavePayload) =>
|
||||||
@@ -2020,6 +2458,38 @@ export const api = {
|
|||||||
request<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/workflow-runs${buildQueryString(params)}`),
|
request<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/workflow-runs${buildQueryString(params)}`),
|
||||||
getWorkflowRun: (publicId: string) =>
|
getWorkflowRun: (publicId: string) =>
|
||||||
request<{ success: boolean; run: WorkflowRunSummary }>(`/api/workflow-runs/${encodeURIComponent(publicId)}`),
|
request<{ success: boolean; run: WorkflowRunSummary }>(`/api/workflow-runs/${encodeURIComponent(publicId)}`),
|
||||||
|
getDigitalHumanStatus: () =>
|
||||||
|
request<DigitalHumanStatusResponse>("/api/digital-human/status"),
|
||||||
|
createDigitalHumanJob: (data: any) =>
|
||||||
|
request<DigitalHumanJobResponse>("/api/digital-human/jobs", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
}),
|
||||||
|
getDigitalHumanJobs: (params?: { limit?: number }) =>
|
||||||
|
request<DigitalHumanJobsResponse>(`/api/digital-human/jobs${buildQueryString(params)}`),
|
||||||
|
getDigitalHumanJob: (id: string) =>
|
||||||
|
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}`),
|
||||||
|
cancelDigitalHumanJob: (id: string) =>
|
||||||
|
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
|
||||||
|
clearDigitalHumanJobs: () =>
|
||||||
|
request<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
|
||||||
|
getMoneyPrinterStatus: () =>
|
||||||
|
request<MoneyPrinterStatusResponse>("/api/money-printer/status"),
|
||||||
|
createMoneyPrinterJob: (data: any) =>
|
||||||
|
request<MoneyPrinterJobResponse>("/api/money-printer/jobs", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
}),
|
||||||
|
getMoneyPrinterJobs: (params?: { limit?: number }) =>
|
||||||
|
request<MoneyPrinterJobsResponse>(`/api/money-printer/jobs${buildQueryString(params)}`),
|
||||||
|
getMoneyPrinterJob: (id: string) =>
|
||||||
|
request<MoneyPrinterJobResponse>(`/api/money-printer/jobs/${encodeURIComponent(id)}`),
|
||||||
|
cancelMoneyPrinterJob: (id: string) =>
|
||||||
|
request<MoneyPrinterJobResponse>(`/api/money-printer/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
|
||||||
|
clearMoneyPrinterJobs: () =>
|
||||||
|
request<{ success: boolean; cleared: number }>("/api/money-printer/jobs/clear", { method: "POST" }),
|
||||||
};
|
};
|
||||||
export const userApi = {
|
export const userApi = {
|
||||||
register: (data: {
|
register: (data: {
|
||||||
@@ -2044,9 +2514,23 @@ export const userApi = {
|
|||||||
userRequest(`/api/user-api-keys/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
userRequest(`/api/user-api-keys/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||||
deleteApiKey: (id: number) =>
|
deleteApiKey: (id: number) =>
|
||||||
userRequest(`/api/user-api-keys/${id}`, { method: "DELETE" }),
|
userRequest(`/api/user-api-keys/${id}`, { method: "DELETE" }),
|
||||||
getLogs: (params?: { limit?: number; offset?: number; page?: number; page_size?: number; q?: string; status?: string; date_from?: string; date_to?: string }) =>
|
getLogs: (params?: { limit?: number; offset?: number; page?: number; page_size?: number; q?: string; key_id?: number; key?: string; status?: string; date_from?: string; date_to?: string }) =>
|
||||||
userRequest(`/api/user/logs${buildQueryString(params)}`),
|
userRequest(`/api/user/logs${buildQueryString(params)}`),
|
||||||
getSummary: () => userRequest("/api/user/summary"),
|
getSummary: () => userRequest("/api/user/summary"),
|
||||||
|
getDashboard: () => userRequest("/api/user/dashboard"),
|
||||||
|
getRechargeRecords: () =>
|
||||||
|
userRequest<UserRechargeRecordsResponse>("/api/user/recharge-records"),
|
||||||
|
createRechargeCheckout: (data: CreateUserRechargeCheckoutPayload) =>
|
||||||
|
userRequest<CreateUserRechargeCheckoutResponse>("/api/user/recharge/checkout", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
cancelRechargeRecord: (publicId: string) =>
|
||||||
|
userRequest<UserRechargeRecordActionResponse>(`/api/user/recharge-records/${encodeURIComponent(publicId)}/cancel`, {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
clearRechargeRecords: () =>
|
||||||
|
userRequest<ClearUserRechargeRecordsResponse>("/api/user/recharge-records", { method: "DELETE" }),
|
||||||
getModelsMarketplace: (options?: {
|
getModelsMarketplace: (options?: {
|
||||||
refresh?: boolean;
|
refresh?: boolean;
|
||||||
includePricing?: boolean;
|
includePricing?: boolean;
|
||||||
@@ -2069,8 +2553,15 @@ export const userApi = {
|
|||||||
userRequest<WorkspaceAssetsResponse>(`/api/user/workspace/assets${buildQueryString(params)}`),
|
userRequest<WorkspaceAssetsResponse>(`/api/user/workspace/assets${buildQueryString(params)}`),
|
||||||
deleteWorkspaceAsset: (publicId: string) =>
|
deleteWorkspaceAsset: (publicId: string) =>
|
||||||
userRequest(`/api/user/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
|
userRequest(`/api/user/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
|
||||||
|
batchDeleteWorkspaceAssets: (publicIds: string[]) =>
|
||||||
|
userRequest<WorkspaceBatchDeleteResponse>("/api/user/workspace/assets/batch-delete", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ publicIds }),
|
||||||
|
}),
|
||||||
getWorkflowNodeTypes: () =>
|
getWorkflowNodeTypes: () =>
|
||||||
userRequest<{ success: boolean; nodeTypes: any[] }>("/api/user/workflow/node-types"),
|
userRequest<{ success: boolean; nodeTypes: any[] }>("/api/user/workflow/node-types"),
|
||||||
|
getWorkflowModelFolder: (folder: string) =>
|
||||||
|
workflowModelFolderRequest(userRequest, folder),
|
||||||
getWorkflows: (params?: { include_public?: boolean }) =>
|
getWorkflows: (params?: { include_public?: boolean }) =>
|
||||||
userRequest<{ success: boolean; items: WorkflowSummary[] }>(`/api/user/workflows${buildQueryString(params)}`),
|
userRequest<{ success: boolean; items: WorkflowSummary[] }>(`/api/user/workflows${buildQueryString(params)}`),
|
||||||
createWorkflow: (data: WorkflowSavePayload) =>
|
createWorkflow: (data: WorkflowSavePayload) =>
|
||||||
@@ -2089,4 +2580,36 @@ export const userApi = {
|
|||||||
userRequest<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/user/workflow-runs${buildQueryString(params)}`),
|
userRequest<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/user/workflow-runs${buildQueryString(params)}`),
|
||||||
getWorkflowRun: (publicId: string) =>
|
getWorkflowRun: (publicId: string) =>
|
||||||
userRequest<{ success: boolean; run: WorkflowRunSummary }>(`/api/user/workflow-runs/${encodeURIComponent(publicId)}`),
|
userRequest<{ success: boolean; run: WorkflowRunSummary }>(`/api/user/workflow-runs/${encodeURIComponent(publicId)}`),
|
||||||
|
getDigitalHumanStatus: () =>
|
||||||
|
userRequest<DigitalHumanStatusResponse>("/api/digital-human/status"),
|
||||||
|
createDigitalHumanJob: (data: any) =>
|
||||||
|
userRequest<DigitalHumanJobResponse>("/api/digital-human/jobs", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
}),
|
||||||
|
getDigitalHumanJobs: (params?: { limit?: number }) =>
|
||||||
|
userRequest<DigitalHumanJobsResponse>(`/api/digital-human/jobs${buildQueryString(params)}`),
|
||||||
|
getDigitalHumanJob: (id: string) =>
|
||||||
|
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}`),
|
||||||
|
cancelDigitalHumanJob: (id: string) =>
|
||||||
|
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
|
||||||
|
clearDigitalHumanJobs: () =>
|
||||||
|
userRequest<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
|
||||||
|
getMoneyPrinterStatus: () =>
|
||||||
|
userRequest<MoneyPrinterStatusResponse>("/api/money-printer/status"),
|
||||||
|
createMoneyPrinterJob: (data: any) =>
|
||||||
|
userRequest<MoneyPrinterJobResponse>("/api/money-printer/jobs", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
}),
|
||||||
|
getMoneyPrinterJobs: (params?: { limit?: number }) =>
|
||||||
|
userRequest<MoneyPrinterJobsResponse>(`/api/money-printer/jobs${buildQueryString(params)}`),
|
||||||
|
getMoneyPrinterJob: (id: string) =>
|
||||||
|
userRequest<MoneyPrinterJobResponse>(`/api/money-printer/jobs/${encodeURIComponent(id)}`),
|
||||||
|
cancelMoneyPrinterJob: (id: string) =>
|
||||||
|
userRequest<MoneyPrinterJobResponse>(`/api/money-printer/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
|
||||||
|
clearMoneyPrinterJobs: () =>
|
||||||
|
userRequest<{ success: boolean; cleared: number }>("/api/money-printer/jobs/clear", { method: "POST" }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { clearSession } from './authSession.js';
|
import { clearSession } from './authSession.js';
|
||||||
|
|
||||||
export const THEME_MODE_STORAGE_KEY = 'theme_mode';
|
export const THEME_MODE_STORAGE_KEY = 'theme_mode';
|
||||||
|
export const THEME_PRESET_STORAGE_KEY = 'theme_preset';
|
||||||
export const LEGACY_THEME_STORAGE_KEY = 'theme';
|
export const LEGACY_THEME_STORAGE_KEY = 'theme';
|
||||||
export const USER_PROFILE_STORAGE_KEY = 'user_profile';
|
export const USER_PROFILE_STORAGE_KEY = 'user_profile';
|
||||||
|
export const ADMIN_PROFILE_STORAGE_KEY = 'admin_profile';
|
||||||
|
export const LEGACY_USER_PROFILE_STORAGE_KEY = USER_PROFILE_STORAGE_KEY;
|
||||||
export const FIRST_USE_DOC_REMINDER_KEY = 'boosapi_first_use_docs_reminder_seen_v1';
|
export const FIRST_USE_DOC_REMINDER_KEY = 'boosapi_first_use_docs_reminder_seen_v1';
|
||||||
|
export const LEGACY_FIRST_USE_DOC_REMINDER_KEY = 'metapi_first_use_docs_reminder_seen_v1';
|
||||||
|
|
||||||
type StorageLike = {
|
type StorageLike = {
|
||||||
getItem?: (key: string) => string | null;
|
getItem?: (key: string) => string | null;
|
||||||
@@ -22,7 +26,10 @@ export function clearAppInstallationState(storage?: StorageLike | null): void {
|
|||||||
if (!target) return;
|
if (!target) return;
|
||||||
clearSession('admin', target as never);
|
clearSession('admin', target as never);
|
||||||
target.removeItem(THEME_MODE_STORAGE_KEY);
|
target.removeItem(THEME_MODE_STORAGE_KEY);
|
||||||
|
target.removeItem(THEME_PRESET_STORAGE_KEY);
|
||||||
target.removeItem(LEGACY_THEME_STORAGE_KEY);
|
target.removeItem(LEGACY_THEME_STORAGE_KEY);
|
||||||
target.removeItem(USER_PROFILE_STORAGE_KEY);
|
target.removeItem(USER_PROFILE_STORAGE_KEY);
|
||||||
|
target.removeItem(ADMIN_PROFILE_STORAGE_KEY);
|
||||||
target.removeItem(FIRST_USE_DOC_REMINDER_KEY);
|
target.removeItem(FIRST_USE_DOC_REMINDER_KEY);
|
||||||
|
target.removeItem(LEGACY_FIRST_USE_DOC_REMINDER_KEY);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
function readSource(relativePath: string): string {
|
||||||
|
return readFileSync(resolve(process.cwd(), relativePath), 'utf8').replace(/\r\n/g, '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppMotion animation contract', () => {
|
||||||
|
it('uses GSAP matchMedia so page transitions respect reduced motion and viewport density', () => {
|
||||||
|
const source = readSource('src/web/components/AppMotion.tsx');
|
||||||
|
|
||||||
|
expect(source).toContain("import { gsap } from 'gsap';");
|
||||||
|
expect(source).toContain('const mm = gsap.matchMedia();');
|
||||||
|
expect(source).toContain("reduceMotion: '(prefers-reduced-motion: reduce)'");
|
||||||
|
expect(source).toContain("isDesktop: '(min-width: 900px)'");
|
||||||
|
expect(source).toContain('if (conditions.reduceMotion) {');
|
||||||
|
expect(source).toContain("gsap.set([root, ...targets], { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });");
|
||||||
|
expect(source).toContain('return () => mm.revert();');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps route/login entrance motion scoped, subtle, and cleaned up on unmount', () => {
|
||||||
|
const source = readSource('src/web/components/AppMotion.tsx');
|
||||||
|
|
||||||
|
expect(source).toContain('motionVariant === \'login\' ? 14 : (conditions.isDesktop ? 26 : 16)');
|
||||||
|
expect(source).toContain('const timeline = gsap.timeline({');
|
||||||
|
expect(source).toContain("ease: 'power2.out'");
|
||||||
|
expect(source).toContain("overwrite: 'auto'");
|
||||||
|
expect(source).toContain('timeline.fromTo(');
|
||||||
|
expect(source).toContain("stagger: {\n each: motionVariant === 'login' ? 0.045 : 0.026,");
|
||||||
|
expect(source).toContain('timeline.kill();');
|
||||||
|
expect(source).toContain('gsap.killTweensOf([root, ...targets]);');
|
||||||
|
expect(source).toContain("gsap.set([root, ...targets], { clearProps: 'transform,opacity,visibility' });");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes the newer dashboard operations blocks in the GSAP entrance target set', () => {
|
||||||
|
const source = readSource('src/web/components/AppMotion.tsx');
|
||||||
|
|
||||||
|
expect(source).toContain("':scope > .dashboard-war-room'");
|
||||||
|
expect(source).toContain("':scope > .dashboard-command-strip'");
|
||||||
|
expect(source).toContain("':scope .dashboard-pulse-card'");
|
||||||
|
expect(source).toContain("':scope .dashboard-risk-panel'");
|
||||||
|
expect(source).toContain("':scope .dashboard-command-item'");
|
||||||
|
expect(source).toContain("':scope .ops-monitor-log-card'");
|
||||||
|
expect(source).toContain("':scope .responsive-batch-action-bar'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps responsive batch action bars reachable by the GSAP selector', () => {
|
||||||
|
const mobileBatchBar = readSource('src/web/components/MobileBatchBar.tsx');
|
||||||
|
const responsiveBatchBar = readSource('src/web/components/ResponsiveBatchActionBar.tsx');
|
||||||
|
|
||||||
|
expect(mobileBatchBar).toContain('responsive-batch-action-bar');
|
||||||
|
expect(responsiveBatchBar).toContain('responsive-batch-action-bar');
|
||||||
|
expect(mobileBatchBar).toContain('className="mobile-actions-info" role="status" aria-live="polite"');
|
||||||
|
expect(responsiveBatchBar).toContain('<span role="status" aria-live="polite"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps high-frequency overlay motion reduced-motion friendly', () => {
|
||||||
|
const css = readSource('src/web/index.css');
|
||||||
|
|
||||||
|
expect(css).toContain('@media (prefers-reduced-motion: reduce) {');
|
||||||
|
expect(css).toContain('.user-dropdown,\n .user-dropdown.is-closing');
|
||||||
|
expect(css).toContain('.modal-backdrop,\n .modal-backdrop.is-closing');
|
||||||
|
expect(css).toContain('.mobile-drawer-panel,\n .mobile-drawer-panel-right');
|
||||||
|
expect(css).toContain('.filter-panel.filter-collapsible,\n .filter-panel.filter-collapsible.is-closing');
|
||||||
|
expect(css).toContain('animation: none !important;');
|
||||||
|
expect(css).toContain('transition: none !important;');
|
||||||
|
expect(css).toContain('transform: none !important;');
|
||||||
|
expect(css).toContain('.user-dropdown.is-closing,\n .modal-backdrop.is-closing');
|
||||||
|
expect(css).not.toContain('.main-content.is-route-transitioning');
|
||||||
|
expect(css).not.toContain('.route-progress');
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user