Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ff6a89587 | |||
| a213e394d9 | |||
| 6b24f160c3 | |||
| 0e36358624 | |||
| 082099e63d | |||
| 8730713655 | |||
| 44e3e6ea35 | |||
| 559a31463e | |||
| eb7cf61fea | |||
| b012f48a23 | |||
| 0db88e2b20 | |||
| add7ae7048 | |||
| 53d416cfb8 | |||
| 3178f0d67f | |||
| 4719cf41eb | |||
| 281d65a0c7 | |||
| f4d129ebc4 | |||
| 294d227eba |
+5
-6
@@ -1,19 +1,18 @@
|
||||
# Cargo build acceleration.
|
||||
#
|
||||
# The biggest single speedup is the `mold` linker (~3-5x faster link
|
||||
# time on debug builds). `build-and-run.sh` enables mold/lld automatically
|
||||
# when available, so the block below is kept as an optional manual setup for
|
||||
# direct `cargo build` / `cargo run` usage:
|
||||
# time on debug builds). It isn't installed by default on this host
|
||||
# (no apt-get, see build-and-run.sh notes), so the mold block below is
|
||||
# commented out — uncomment it after running:
|
||||
#
|
||||
# sudo apt-get install -y mold
|
||||
#
|
||||
# (or build it from source: https://github.com/rui314/mold)
|
||||
#
|
||||
# Without mold/lld, the [profile.dev] tweaks in Cargo.toml still give a
|
||||
# Without mold, the [profile.dev] tweaks in Cargo.toml still give a
|
||||
# meaningful speedup (line-tables-only debuginfo + bumped codegen-units).
|
||||
|
||||
# Optional: uncomment once mold is on PATH if you want direct cargo commands
|
||||
# to use it without going through build-and-run.sh.
|
||||
# Uncomment once mold is on PATH:
|
||||
# [target.x86_64-unknown-linux-gnu]
|
||||
# linker = "clang"
|
||||
# rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||
|
||||
@@ -2,19 +2,3 @@ comfy_source/venv
|
||||
comfy_source/.git
|
||||
comfy_source/__pycache__
|
||||
**/__pycache__
|
||||
target/
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
data/
|
||||
logs/
|
||||
tmp/
|
||||
.agents/
|
||||
.cache/
|
||||
.codex/
|
||||
.claude/
|
||||
.comfyui/
|
||||
boosapi
|
||||
bossai
|
||||
boosapi.db
|
||||
docker/*.tar
|
||||
|
||||
+1
-11
@@ -1,15 +1,13 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
Cargo.lock
|
||||
.env
|
||||
.env.*
|
||||
data/
|
||||
node_modules/
|
||||
website/.next/
|
||||
website/out/
|
||||
dist/
|
||||
release/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
logs/
|
||||
tmp/
|
||||
!tmp/.gitkeep
|
||||
@@ -21,13 +19,5 @@ docker/.env
|
||||
.comfyui/
|
||||
.claude/
|
||||
.codex/
|
||||
.agents/
|
||||
.cache/
|
||||
openspec/
|
||||
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
@@ -70,9 +70,6 @@ once_cell = "1"
|
||||
parking_lot = "0.12"
|
||||
mime = "0.3"
|
||||
base64 = "0.22"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
include_dir = "0.7"
|
||||
mime_guess = "2"
|
||||
|
||||
|
||||
@@ -81,8 +81,6 @@ boosapi
|
||||
config.toml
|
||||
```
|
||||
|
||||
部署目录只需要运行产物和配置。仓库里的 `.agents/`、`.cache/`、本地二进制、Docker tar 包、运行日志等均为本地缓存或构建产物,不应进入发布包。`Cargo.lock` 应保留在仓库中,用于保证生产构建依赖可复现。
|
||||
|
||||
启动前检查配置:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
# BoosAPI
|
||||
# BossAI
|
||||
|
||||
**AI 聚合网关 + Workflow Studio — 将分散的中转站整合为一个统一入口**
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
现在 AI 生态里出现了越来越多基于 New API / One API 系列的聚合中转站,要管理多个站点的余额、模型列表和 API 密钥,往往既分散又费时。
|
||||
|
||||
**BoosAPI** 作为中转站之上的**元聚合层**,把多个站点统一到 **一个入口**——下游所有工具即可无感接入全部模型。
|
||||
**BossAI** 作为中转站之上的**元聚合层**,把多个站点统一到 **一个入口**——下游所有工具即可无感接入全部模型。
|
||||
|
||||
支持的上游范围:
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
- **官方预设**:阿里云、智谱、豆包 Coding Plan、DeepSeek、Moonshot、MiniMax、ModelScope
|
||||
- **OAuth 连接**:Codex、Claude、Gemini CLI、Antigravity
|
||||
|
||||
| 痛点 | BoosAPI 怎么解决 |
|
||||
| 痛点 | BossAI 怎么解决 |
|
||||
|------|-----------------|
|
||||
| 🔑 每个站点一个 Key,下游配置一堆 | **统一代理入口 + 多下游 Key 策略**,模型自动聚合 |
|
||||
| 💸 不知道哪个站点最便宜 | **智能路由** 按成本、余额、使用率选最优通道 |
|
||||
@@ -53,22 +53,10 @@
|
||||
| 📊 余额分散各处 | **集中看板** 一目了然,余额不足自动告警 |
|
||||
| ✅ 每天签到领额度 | **自动签到** 定时执行,奖励自动追踪 |
|
||||
| 🤷 不知道各站有什么模型 | **自动模型发现**,上游新增模型零配置 |
|
||||
| 🎨 想编排图像/视频流程但不想跑本地模型 | **Workflow Studio + 对话助手**,直接复用 BoosAPI 模型路由 |
|
||||
| 🎨 想编排图像/视频流程但不想跑本地模型 | **Workflow Studio + 对话助手**,直接复用 BossAI 模型路由 |
|
||||
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
|
||||
| 📋 不同团队需要不同配额 | **套餐系统**,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 解析后再转成本地显示。
|
||||
|
||||
## ✨ 核心功能
|
||||
|
||||
### 🎨 Workflow Studio 与视频助手(Rust)
|
||||
@@ -168,13 +156,13 @@
|
||||
|
||||
## 🖥️ 硬件配置建议
|
||||
|
||||
BoosAPI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时不需要 GPU。实际硬件主要取决于你是否把图像/视频生成放在本机 ComfyUI 上执行。
|
||||
BossAI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时不需要 GPU。实际硬件主要取决于你是否把图像/视频生成放在本机 ComfyUI 上执行。
|
||||
|
||||
| 使用模式 | CPU | 内存 | 磁盘 | GPU |
|
||||
|----------|-----|------|------|-----|
|
||||
| 仅网关 / 管理后台 / `/v1/*` 代理 | 2 核起步,4 核推荐 | 2GB 起步,4GB 推荐 | 10GB+ | 不需要 |
|
||||
| BoosAPI + Workflow UI + 远程模型 API / Comfy Cloud / RunningHub | 4 核推荐 | 4GB 起步,8GB 推荐 | 20-50GB | 不需要 |
|
||||
| 外部独立 ComfyUI 代理 | 4 核推荐 | 8GB 推荐 | 50GB+ | BoosAPI 机器不需要;ComfyUI 机器按模型需求配置 |
|
||||
| BossAI + Workflow UI + 远程模型 API / Comfy Cloud / RunningHub | 4 核推荐 | 4GB 起步,8GB 推荐 | 20-50GB | 不需要 |
|
||||
| 外部独立 ComfyUI 代理 | 4 核推荐 | 8GB 推荐 | 50GB+ | BossAI 机器不需要;ComfyUI 机器按模型需求配置 |
|
||||
| 本机 Docker / 本地 ComfyUI 轻量文生图 | 4-8 核 | 16GB+ | 100GB+ | NVIDIA 8GB VRAM 勉强,12GB+ 推荐 |
|
||||
| 本机视频生成 / 数字人 / 大工作流 | 8 核+ | 32GB 起步,64GB 推荐 | 200GB-1TB | NVIDIA 24GB VRAM 起步,48GB+ 更稳 |
|
||||
|
||||
@@ -187,7 +175,7 @@ BoosAPI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时
|
||||
无需 GPU
|
||||
```
|
||||
|
||||
如果图像、视频、语音能力走 BoosAPI 上游路由、Comfy Cloud、RunningHub 或其他远程 API,本机只负责网关、数据库、Workflow UI 代理、日志和 Workspace 归档,上面这档配置通常足够。只有把 ComfyUI 模型和视频生成放在本机运行时,才需要高显存 NVIDIA GPU。
|
||||
如果图像、视频、语音能力走 BossAI 上游路由、Comfy Cloud、RunningHub 或其他远程 API,本机只负责网关、数据库、Workflow UI 代理、日志和 Workspace 归档,上面这档配置通常足够。只有把 ComfyUI 模型和视频生成放在本机运行时,才需要高显存 NVIDIA GPU。
|
||||
|
||||
## 🎛️ Workflow API 后端模式
|
||||
|
||||
@@ -230,38 +218,7 @@ base_url = "http://127.0.0.1:8188"
|
||||
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 服务并强刷浏览器缓存。
|
||||
> Workflow UI 有 cloud 与 external/runninghub 两套前端资产。开发环境可用 `npm run workflow-ui:install:cloud`、`npm run workflow-ui:install:external` 单独安装;生产构建会把可用资产嵌入 Rust 二进制。
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
@@ -287,14 +244,14 @@ services:
|
||||
restart: unless-stopped
|
||||
EOF
|
||||
|
||||
# AUTH_TOKEN = 管理后台登录令牌(管理员只支持 API Token 登录)
|
||||
# AUTH_TOKEN = 管理后台登录令牌
|
||||
# PROXY_TOKEN = 下游客户端调用 /v1/* 的 Token
|
||||
export AUTH_TOKEN=your-admin-token
|
||||
export PROXY_TOKEN=sk-your-proxy-token
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
启动后访问 `http://localhost:4000/boos`,用 `AUTH_TOKEN` 登录管理员后台;普通用户从用户登录页使用账号密码进入用户工作台。
|
||||
启动后访问 `http://localhost:4000`,用 `AUTH_TOKEN` 登录即可。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。
|
||||
@@ -337,16 +294,15 @@ docker compose up -d
|
||||
│ ├── billing/ # 计费逻辑
|
||||
│ └── notification/ # 通知推送
|
||||
├── migrations/ # SQLite 迁移
|
||||
├── src/web/ # 管理台前端源码(React + Vite)
|
||||
├── src/web/ # 前端源码(React 19 + Vite)
|
||||
│ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶)
|
||||
│ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage)
|
||||
│ ├── pages/
|
||||
│ │ ├── auth/ # 登录页拆分:LoginShell + AdminLoginPage(token-only) + UserLoginPage(OAuthSlot 占位)
|
||||
│ │ ├── auth/ # 登录页拆分:LoginShell + AdminLoginPage + UserLoginPage(OAuthSlot 占位,行为由后续 change 接入)
|
||||
│ │ ├── ComfyUIAgent.tsx # 视频/工作流助手聊天界面
|
||||
│ │ ├── Workflows.tsx # Workflow Studio 入口
|
||||
│ │ └── ...
|
||||
│ └── components/
|
||||
├── website/ # 独立官网(Next.js 16 App Router + React 19)
|
||||
└── dist/web/ # 前端构建产物(由 Rust serve)
|
||||
```
|
||||
|
||||
@@ -404,8 +360,7 @@ BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
|
||||
| 协议转换 | OpenAI / Claude / Gemini 全兼容 |
|
||||
| Workflow Studio | 内嵌 ComfyUI 前端 + Cloud / RunningHub / External / BoosAPI 多后端 |
|
||||
| 视频助手 | SSE 流式 + gpt-image-2 + TTS 自动生成 |
|
||||
| 管理台前端 | React + Vite(仅构建工具) |
|
||||
| 独立官网 | Next.js 16 App Router + React 19 + TypeScript |
|
||||
| 前端框架 | React 19 + Vite(仅构建工具) |
|
||||
| 样式 | Tailwind CSS v4 |
|
||||
| 数据可视化 | VChart |
|
||||
| 容器化 | Docker + Docker Compose + K3s |
|
||||
@@ -413,8 +368,6 @@ BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
|
||||
|
||||
> **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。
|
||||
|
||||
> 独立官网位于 `website/`。默认构建会把官网静态导出到 `dist/website/`,Rust 后端会让根路径 `/` 显示官网;管理台仍由 `dist/web/` 提供,管理员入口是 `/boos`,登录后进入 `/dashboard`。
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 本地开发
|
||||
@@ -422,20 +375,11 @@ BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
|
||||
### 后端(Rust)
|
||||
|
||||
```bash
|
||||
# 最快验证:只检查 Rust 后端,不构建前端、不启动服务
|
||||
./build-and-run.sh --check
|
||||
# 编译并运行
|
||||
cargo run --release
|
||||
|
||||
# 日常启动:复用前端缓存,后端 dev profile 编译后启动
|
||||
./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 check
|
||||
|
||||
# 运行测试
|
||||
cargo test
|
||||
@@ -446,15 +390,9 @@ export BOOSAPI_SERVER__PORT=4000
|
||||
export BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
|
||||
export BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
|
||||
export BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1
|
||||
export BOOSAPI_UPSTREAM_KEY=sk-your-upstream-key
|
||||
export BOOSAPI_UPSTREAM_KEY=sk-boos4721
|
||||
```
|
||||
|
||||
构建加速说明:
|
||||
|
||||
- 脚本会在 Linux 下自动使用 `mold` / `lld` 快速链接器;可用 `BOOSAPI_FAST_LINKER=off` 关闭。
|
||||
- `BOOSAPI_CARGO_JOBS=8 ./build-and-run.sh --check` 可限制 Cargo 并发,避免把开发机打满。
|
||||
- 生产性能验证优先用 `--fast-release`,真正发布再用 `--production` 或 `--release`。
|
||||
|
||||
### 前端(React SPA)
|
||||
|
||||
前端使用 Vite 构建,产物由 Rust 后端静态 serve:
|
||||
@@ -466,22 +404,11 @@ npm install
|
||||
# 开发模式(热更新,需要 Node.js)
|
||||
cd src/web && npx vite --host 0.0.0.0
|
||||
|
||||
# 类型检查与构建生产版本
|
||||
npm run typecheck:web
|
||||
npm run build:web
|
||||
# 构建生产版本
|
||||
npm run build
|
||||
# 输出到 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
|
||||
@@ -498,24 +425,6 @@ BOOSAPI_FRONTEND_PM=auto ./build-and-run.sh
|
||||
> 生产环境不需要 Node.js 进程。`npm run build` 后,Rust 后端直接 serve 静态文件。
|
||||
> 前端 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
|
||||
@@ -529,13 +438,6 @@ 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 部署
|
||||
|
||||
```bash
|
||||
|
||||
File diff suppressed because one or more lines are too long
+14
-226
@@ -17,17 +17,13 @@ fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
|
||||
BUILD_PROFILE="${BOOSAPI_BUILD_PROFILE:-dev}"
|
||||
FRONTEND_ONLY=0
|
||||
BACKEND_ONLY=0
|
||||
CHECK_ONLY=0
|
||||
NO_RUN=0
|
||||
FORCE_FRONTEND=0
|
||||
SKIP_FRONTEND=0
|
||||
PRODUCTION_BUNDLE=0
|
||||
PRODUCTION_DIR="${BOOSAPI_PRODUCTION_DIR:-$ROOT_DIR/dist/production}"
|
||||
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() {
|
||||
cat <<'EOF'
|
||||
@@ -42,8 +38,6 @@ usage() {
|
||||
--prod 同 --production
|
||||
--production-dir <dir>
|
||||
指定生产产物目录(默认:dist/production)
|
||||
--check 只执行 cargo check,不构建前端、不启动服务(最快验证)
|
||||
--backend-check 同 --check
|
||||
--frontend-only 只构建前端
|
||||
--backend-only 只构建后端
|
||||
--force-frontend 强制重建前端,忽略缓存
|
||||
@@ -52,8 +46,6 @@ usage() {
|
||||
--no-run 构建后不启动服务
|
||||
BOOSAPI_BUILD_PROFILE 可设置默认 profile(dev / release / fast-release)
|
||||
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 显示帮助
|
||||
@@ -61,7 +53,6 @@ usage() {
|
||||
示例:
|
||||
./build-and-run.sh --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
|
||||
@@ -84,11 +75,6 @@ while [ $# -gt 0 ]; do
|
||||
[ $# -gt 0 ] || fail "--production-dir 需要目录参数"
|
||||
PRODUCTION_DIR="$1"
|
||||
;;
|
||||
--check|--backend-check)
|
||||
CHECK_ONLY=1
|
||||
BACKEND_ONLY=1
|
||||
NO_RUN=1
|
||||
;;
|
||||
--profile)
|
||||
shift
|
||||
[ $# -gt 0 ] || fail "--profile 需要参数:dev / release / fast-release"
|
||||
@@ -119,114 +105,12 @@ 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
|
||||
dev|debug) BUILD_PROFILE="dev" ;;
|
||||
release|fast-release) ;;
|
||||
*) fail "未知构建 profile: $BUILD_PROFILE(可选:dev / release / fast-release)" ;;
|
||||
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() {
|
||||
if [ -n "${BOOSAPI_SERVER__PORT:-}" ]; then
|
||||
printf '%s\n' "$BOOSAPI_SERVER__PORT"
|
||||
@@ -340,12 +224,8 @@ install_frontend_dependencies() {
|
||||
}
|
||||
|
||||
build_frontend() {
|
||||
local started
|
||||
started="$(timer_start)"
|
||||
|
||||
if [ "$SKIP_FRONTEND" -eq 1 ]; then
|
||||
log "跳过前端构建(--skip-frontend)"
|
||||
record_timing "前端检查" "$started"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -387,7 +267,6 @@ build_frontend() {
|
||||
&& [ -f "$ROOT_DIR/dist/web/index.html" ] \
|
||||
&& [ "$(cat "$fingerprint_file" 2>/dev/null || true)" = "$fingerprint" ]; then
|
||||
ok "前端无变化,跳过 Vite 构建 → dist/web/"
|
||||
record_timing "前端检查" "$started"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -401,71 +280,11 @@ build_frontend() {
|
||||
mkdir -p "$ROOT_DIR/dist/web"
|
||||
printf '%s\n' "$fingerprint" > "$fingerprint_file"
|
||||
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
|
||||
|
||||
@@ -474,7 +293,6 @@ ensure_workflow_ui_for_embedding() {
|
||||
&& [ -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
|
||||
|
||||
@@ -485,60 +303,43 @@ ensure_workflow_ui_for_embedding() {
|
||||
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() {
|
||||
local started
|
||||
started="$(timer_start)"
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
fail "未找到 cargo,请先安装 Rust 工具链"
|
||||
fi
|
||||
|
||||
configure_fast_linker
|
||||
build_cargo_args
|
||||
|
||||
if [ "$CHECK_ONLY" -eq 1 ]; then
|
||||
if [ "$BUILD_PROFILE" = "release" ]; then
|
||||
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[@]}"
|
||||
# 提示一次:如果链接器走默认 ld 而 mold 不在 PATH,给出一条性能建议。
|
||||
# mold 安装后还需在 .cargo/config.toml 取消 `linker = "clang"` 那段注释。
|
||||
if ! command -v mold >/dev/null 2>&1; then
|
||||
log "提示: 安装 mold 后取消 .cargo/config.toml 中的相应注释,链接将快 3-5x"
|
||||
fi
|
||||
ok "后端检查完成"
|
||||
record_timing "后端检查" "$started"
|
||||
return 0
|
||||
|
||||
CARGO_FEATURE_ARGS=()
|
||||
if [ -n "$CARGO_FEATURES" ]; then
|
||||
CARGO_FEATURE_ARGS=(--features "$CARGO_FEATURES")
|
||||
fi
|
||||
|
||||
if [ "$BUILD_PROFILE" = "release" ]; then
|
||||
log "构建后端 (cargo build --release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
|
||||
cargo build "${CARGO_BASE_ARGS[@]}" --release "${CARGO_FEATURE_ARGS[@]}"
|
||||
log "构建后端 (cargo build --release ${CARGO_FEATURE_ARGS[*]})..."
|
||||
cargo build --release "${CARGO_FEATURE_ARGS[@]}"
|
||||
BINARY="$ROOT_DIR/target/release/boosapi"
|
||||
ok "后端构建完成 → target/release/boosapi"
|
||||
elif [ "$BUILD_PROFILE" = "fast-release" ]; then
|
||||
log "构建后端 (cargo build --profile fast-release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
|
||||
cargo build "${CARGO_BASE_ARGS[@]}" --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
|
||||
log "构建后端 (cargo build --profile fast-release ${CARGO_FEATURE_ARGS[*]})..."
|
||||
cargo build --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
|
||||
BINARY="$ROOT_DIR/target/fast-release/boosapi"
|
||||
ok "后端构建完成 → target/fast-release/boosapi"
|
||||
else
|
||||
log "构建后端 (cargo build ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
|
||||
cargo build "${CARGO_BASE_ARGS[@]}" "${CARGO_FEATURE_ARGS[@]}"
|
||||
log "构建后端 (cargo build ${CARGO_FEATURE_ARGS[*]})..."
|
||||
cargo build "${CARGO_FEATURE_ARGS[@]}"
|
||||
BINARY="$ROOT_DIR/target/debug/boosapi"
|
||||
ok "后端构建完成 → target/debug/boosapi"
|
||||
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"
|
||||
@@ -559,7 +360,6 @@ build_production_bundle() {
|
||||
log "产物内容:"
|
||||
(cd "$PRODUCTION_DIR" && ls -lh boosapi config.toml)
|
||||
log "部署时只需要复制该目录内的 boosapi 和 config.toml;主 Web 前端已嵌入二进制。"
|
||||
record_timing "生产打包" "$started"
|
||||
}
|
||||
|
||||
cleanup_started_server() {
|
||||
@@ -574,15 +374,12 @@ cleanup_started_server() {
|
||||
|
||||
if [ "$FRONTEND_ONLY" -eq 1 ]; then
|
||||
build_frontend
|
||||
build_public_website
|
||||
print_timing_summary
|
||||
log "跳过启动(--frontend-only)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$BACKEND_ONLY" -ne 1 ]; then
|
||||
build_frontend
|
||||
build_public_website
|
||||
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
|
||||
ensure_workflow_ui_for_embedding
|
||||
fi
|
||||
@@ -590,25 +387,16 @@ fi
|
||||
|
||||
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
|
||||
print_timing_summary
|
||||
log "跳过启动(--no-run)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
print_timing_summary
|
||||
stop_previous_server
|
||||
|
||||
log "启动服务..."
|
||||
|
||||
+1
-31
@@ -25,28 +25,6 @@ upstream_timeout_secs = 300
|
||||
first_byte_timeout_secs = 60
|
||||
max_retries = 3
|
||||
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]
|
||||
enabled = false
|
||||
@@ -57,20 +35,12 @@ 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://www.runninghub.cn/proxy/94d1330ea62d4ae980ffb018ecdf82a2"
|
||||
base_url = "https://cloud.comfy.org"
|
||||
api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f"
|
||||
agent_model = "gpt-5.5"
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
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;
|
||||
@@ -1,51 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,27 +0,0 @@
|
||||
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);
|
||||
@@ -1,39 +0,0 @@
|
||||
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);
|
||||
@@ -1,3 +0,0 @@
|
||||
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;
|
||||
@@ -1,45 +0,0 @@
|
||||
-- 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);
|
||||
+2
-7
@@ -32,25 +32,20 @@
|
||||
"typecheck:web": "tsc -p tsconfig.web.json",
|
||||
"typecheck:web:test": "tsc -p tsconfig.web.test.json",
|
||||
"typecheck": "npm run typecheck:web && npm run typecheck:web:test",
|
||||
"website:dev": "npm --prefix website run dev",
|
||||
"website:build": "npm --prefix website run build",
|
||||
"website:start": "npm --prefix website run start",
|
||||
"website:typecheck": "npm --prefix website run typecheck",
|
||||
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
|
||||
"workflow-ui:install": "node scripts/workflow-ui/install.mjs all",
|
||||
"workflow-ui:install:external": "node scripts/workflow-ui/install.mjs external",
|
||||
"workflow-ui:install:cloud": "node scripts/workflow-ui/install.mjs cloud",
|
||||
"test": "vitest run --root src/web",
|
||||
"test:watch": "vitest --root src/web"
|
||||
"test": "vitest run --root .",
|
||||
"test:watch": "vitest --root ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@visactor/react-vchart": "^2.0.21",
|
||||
"gsap": "^3.15.0",
|
||||
"marked": "^17.0.5",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
Generated
-8
@@ -20,9 +20,6 @@ importers:
|
||||
'@visactor/react-vchart':
|
||||
specifier: ^2.0.21
|
||||
version: 2.0.21(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
gsap:
|
||||
specifier: ^3.15.0
|
||||
version: 3.15.0
|
||||
marked:
|
||||
specifier: ^17.0.5
|
||||
version: 17.0.5
|
||||
@@ -2213,9 +2210,6 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
gsap@3.15.0:
|
||||
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
|
||||
|
||||
hachure-fill@0.5.2:
|
||||
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
|
||||
|
||||
@@ -5144,8 +5138,6 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
gsap@3.15.0: {}
|
||||
|
||||
hachure-fill@0.5.2: {}
|
||||
|
||||
has-symbols@1.1.0:
|
||||
|
||||
@@ -35,18 +35,9 @@ const variants = {
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
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, {
|
||||
cwd: options.cwd || ROOT,
|
||||
env,
|
||||
env: { ...process.env, ...(options.env || {}) },
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
@@ -64,11 +55,6 @@ function selectedVariants() {
|
||||
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;
|
||||
@@ -91,39 +77,17 @@ async function buildVariant(name, config) {
|
||||
await run('git', ['submodule', 'update', '--init', '--recursive'], { 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,
|
||||
});
|
||||
await run('pnpm', ['exec', 'vite', 'build', '--config', 'vite.config.mts'], { cwd: dir });
|
||||
} else {
|
||||
await run('npm', ['ci'], { cwd: dir });
|
||||
await run('npm', ['run', 'build'], {
|
||||
cwd: dir,
|
||||
cleanPackageEnv: true,
|
||||
env: packageEnv,
|
||||
});
|
||||
await run('npm', ['run', 'build'], { cwd: dir });
|
||||
}
|
||||
|
||||
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') {
|
||||
|
||||
+2
-980
File diff suppressed because it is too large
Load Diff
@@ -13,8 +13,6 @@ pub struct AppConfig {
|
||||
pub proxy: ProxyConfig,
|
||||
#[serde(default)]
|
||||
pub comfyui: ComfyUIConfig,
|
||||
#[serde(default)]
|
||||
pub payment: PaymentConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
@@ -58,10 +56,6 @@ pub struct ComfyUIConfig {
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
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")]
|
||||
pub docker_image: String,
|
||||
#[serde(default = "default_comfyui_container_name")]
|
||||
@@ -77,23 +71,6 @@ pub struct ComfyUIConfig {
|
||||
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)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ComfyUIBackend {
|
||||
@@ -118,8 +95,6 @@ impl Default for ComfyUIConfig {
|
||||
Self {
|
||||
enabled: false,
|
||||
backend: ComfyUIBackend::Boosapi,
|
||||
money_printer_video_cost: 0.0,
|
||||
digital_human_video_cost: 0.0,
|
||||
docker_image: default_comfyui_docker_image(),
|
||||
container_name: default_comfyui_container_name(),
|
||||
install_dir: None,
|
||||
@@ -135,26 +110,6 @@ 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 {
|
||||
pub fn uses_native_backend(&self) -> bool {
|
||||
matches!(
|
||||
|
||||
@@ -8,7 +8,6 @@ use sqlx::{
|
||||
|
||||
pub mod models;
|
||||
pub mod queries;
|
||||
pub mod schema_compat;
|
||||
pub mod sqlite_to_postgres;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
@@ -46,23 +45,6 @@ pub async fn create_pool(database_url: &str, max_connections: u32) -> anyhow::Re
|
||||
}
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.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)
|
||||
.await?;
|
||||
Ok(pool)
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
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,200 +299,6 @@ const TABLES: &[TableSpec] = &[
|
||||
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(
|
||||
|
||||
@@ -66,14 +66,6 @@ impl IntoResponse for AppError {
|
||||
} => Some(*retry_after_secs),
|
||||
_ => None,
|
||||
};
|
||||
let billing = match &self {
|
||||
AppError::RateLimited | AppError::RateLimitedWithRetry { .. } => Some(json!({
|
||||
"reason": "insufficient_quota",
|
||||
"rechargePath": "/user/recharge",
|
||||
"usagePath": "/user/usage",
|
||||
})),
|
||||
_ => None,
|
||||
};
|
||||
(
|
||||
status,
|
||||
Json(json!({
|
||||
@@ -82,7 +74,6 @@ impl IntoResponse for AppError {
|
||||
"type": status.canonical_reason(),
|
||||
},
|
||||
"retry_after_secs": retry_after_secs,
|
||||
"billing": billing,
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
|
||||
+2
-22
@@ -11,16 +11,14 @@ pub mod transformers;
|
||||
pub mod workflow;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: config::AppConfig,
|
||||
pub db: Arc<SqlitePool>,
|
||||
pub http: reqwest::Client,
|
||||
pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>,
|
||||
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 registration_limiter: Arc<services::registration_limiter::RegistrationLimiter>,
|
||||
}
|
||||
@@ -30,7 +28,6 @@ impl AppState {
|
||||
config: config::AppConfig,
|
||||
db: SqlitePool,
|
||||
builtin_keys: services::builtin_keys::BuiltinKeys,
|
||||
runtime_auth: services::runtime_auth::RuntimeAuthSettings,
|
||||
) -> Self {
|
||||
let boosapi_host = if config.server.host == "0.0.0.0" || config.server.host == "::" {
|
||||
"127.0.0.1".to_string()
|
||||
@@ -42,36 +39,19 @@ impl AppState {
|
||||
let comfyui = services::comfyui_process::ComfyUIProcess::new(
|
||||
config.comfyui.clone(),
|
||||
boosapi_url.clone(),
|
||||
runtime_auth.proxy_token(),
|
||||
config.auth.proxy_token.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 {
|
||||
config,
|
||||
db: Arc::new(db),
|
||||
http,
|
||||
comfyui,
|
||||
builtin_keys: Arc::new(builtin_keys),
|
||||
runtime_auth: Arc::new(runtime_auth),
|
||||
register_captcha: Arc::new(services::register_captcha::RegisterCaptchaStore::new()),
|
||||
registration_limiter: Arc::new(
|
||||
services::registration_limiter::RegistrationLimiter::new(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runtime_proxy_token(&self) -> String {
|
||||
self.runtime_auth.proxy_token()
|
||||
}
|
||||
}
|
||||
|
||||
+4
-20
@@ -1,16 +1,11 @@
|
||||
use anyhow::Context;
|
||||
use axum::serve;
|
||||
use boosapi::{
|
||||
config::AppConfig,
|
||||
db,
|
||||
services::{builtin_keys, runtime_auth},
|
||||
AppState,
|
||||
};
|
||||
use boosapi::{config::AppConfig, db, services::builtin_keys, AppState};
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::ffi::OsString;
|
||||
use std::net::SocketAddr;
|
||||
use tokio::signal;
|
||||
use tracing_subscriber::{prelude::*, EnvFilter};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
@@ -109,11 +104,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new(&config.server.log_level));
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.with(boosapi::services::runtime_logs::RuntimeLogLayer)
|
||||
.init();
|
||||
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||
|
||||
tracing::info!("Loaded configuration from {}", args.config);
|
||||
|
||||
@@ -125,7 +116,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
// Run migrations
|
||||
db::run_migrations(db_kind, Some(&pool), None).await?;
|
||||
db::schema_compat::ensure_sqlite_upstream_compat(&pool).await?;
|
||||
tracing::info!("Database migrations applied");
|
||||
|
||||
if args.migrate_only {
|
||||
@@ -144,15 +134,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
let builtin = builtin_keys::BuiltinKeys {
|
||||
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
|
||||
let state = AppState::new(config.clone(), pool, builtin, runtime_auth);
|
||||
let state = AppState::new(config.clone(), pool, builtin);
|
||||
if let Err(err) = state.comfyui.start().await {
|
||||
tracing::warn!(%err, "ComfyUI embedded process did not start");
|
||||
}
|
||||
|
||||
+48
-494
@@ -14,7 +14,9 @@ use crate::transformers::{
|
||||
canonical::CanonicalRequest,
|
||||
};
|
||||
use crate::AppState;
|
||||
use serde_json::{json, Value};
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Resolved upstream info for a given model request.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -24,6 +26,15 @@ pub struct UpstreamRoute {
|
||||
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(
|
||||
text: &str,
|
||||
upstream_url: &str,
|
||||
@@ -163,11 +174,11 @@ fn aggregate_chat_sse(text: &str, fallback_model: &str) -> Option<Value> {
|
||||
|
||||
/// Execute a non-streaming chat completions request.
|
||||
pub async fn execute_chat_completions(
|
||||
state: AppState,
|
||||
_state: AppState,
|
||||
route: UpstreamRoute,
|
||||
body: Value,
|
||||
) -> AppResult<Value> {
|
||||
let client = state.http.clone();
|
||||
let client = http_client();
|
||||
let upstream_url = upstream_url(&route.base_url, "/chat/completions");
|
||||
|
||||
let resp = client
|
||||
@@ -199,29 +210,19 @@ pub async fn execute_chat_completions(
|
||||
/// Execute a streaming chat completions request.
|
||||
/// Returns a vector of SSE data chunks as strings.
|
||||
pub async fn execute_chat_completions_stream(
|
||||
state: AppState,
|
||||
_state: AppState,
|
||||
route: UpstreamRoute,
|
||||
body: Value,
|
||||
) -> AppResult<Vec<String>> {
|
||||
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_streaming_request(route, "/chat/completions", body).await
|
||||
}
|
||||
|
||||
/// Execute an Anthropic Messages API request against an Anthropic-shaped upstream.
|
||||
pub async fn execute_anthropic_messages(
|
||||
state: AppState,
|
||||
route: UpstreamRoute,
|
||||
request: CanonicalRequest,
|
||||
) -> AppResult<Value> {
|
||||
let client = state.http.clone();
|
||||
let client = http_client();
|
||||
let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/'));
|
||||
let compatibility = AnthropicMessagesCompatibility::default();
|
||||
let body = format_messages_request_with_compat(&request, &compatibility);
|
||||
@@ -259,21 +260,10 @@ pub async fn execute_anthropic_messages(
|
||||
|
||||
/// Execute a streaming Anthropic Messages request and return Anthropic SSE frames.
|
||||
pub async fn execute_anthropic_messages_stream(
|
||||
state: AppState,
|
||||
route: UpstreamRoute,
|
||||
request: CanonicalRequest,
|
||||
) -> AppResult<Vec<String>> {
|
||||
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 client = http_client();
|
||||
let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/'));
|
||||
let compatibility = AnthropicMessagesCompatibility::default();
|
||||
let body = format_messages_request_with_compat(&request, &compatibility);
|
||||
@@ -302,10 +292,6 @@ pub async fn execute_anthropic_messages_stream_response(
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
async fn collect_anthropic_stream_frames(resp: reqwest::Response) -> AppResult<Vec<String>> {
|
||||
let mut frames = Vec::new();
|
||||
let mut stream = resp.bytes_stream();
|
||||
use futures::StreamExt;
|
||||
@@ -420,12 +406,32 @@ fn denormalize_anthropic_stop_reason(reason: &str) -> &str {
|
||||
/// Execute a streaming request against any upstream path (e.g. /responses).
|
||||
/// Returns SSE `data: ...` lines verbatim.
|
||||
pub async fn execute_streaming_request(
|
||||
state: AppState,
|
||||
route: UpstreamRoute,
|
||||
path_suffix: &str,
|
||||
body: Value,
|
||||
) -> AppResult<Vec<String>> {
|
||||
let resp = execute_streaming_response(state, route, path_suffix, body).await?;
|
||||
let client = http_client();
|
||||
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 stream = resp.bytes_stream();
|
||||
@@ -449,50 +455,14 @@ pub async fn execute_streaming_request(
|
||||
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.
|
||||
pub async fn execute_proxy_request(
|
||||
state: AppState,
|
||||
_state: AppState,
|
||||
route: UpstreamRoute,
|
||||
path_suffix: &str,
|
||||
body: Value,
|
||||
) -> AppResult<Value> {
|
||||
let client = state.http.clone();
|
||||
let client = http_client();
|
||||
let upstream_url = upstream_url(&route.base_url, path_suffix);
|
||||
|
||||
let resp = client
|
||||
@@ -522,12 +492,8 @@ pub async fn execute_proxy_request(
|
||||
}
|
||||
|
||||
/// Generic GET proxy request to an upstream endpoint path.
|
||||
pub async fn execute_proxy_get(
|
||||
state: AppState,
|
||||
route: UpstreamRoute,
|
||||
path_suffix: &str,
|
||||
) -> AppResult<Value> {
|
||||
let client = state.http.clone();
|
||||
pub async fn execute_proxy_get(route: UpstreamRoute, path_suffix: &str) -> AppResult<Value> {
|
||||
let client = http_client();
|
||||
let upstream_url = upstream_url(&route.base_url, path_suffix);
|
||||
|
||||
let resp = client
|
||||
@@ -552,23 +518,6 @@ pub async fn execute_proxy_get(
|
||||
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
|
||||
/// task endpoints as a first-class compatibility target.
|
||||
pub async fn execute_video_generation(
|
||||
@@ -576,10 +525,6 @@ pub async fn execute_video_generation(
|
||||
route: UpstreamRoute,
|
||||
body: 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) {
|
||||
let body = volcengine_video_create_body(body, &route.model)?;
|
||||
return execute_proxy_request(state, route, "/contents/generations/tasks", body).await;
|
||||
@@ -588,76 +533,6 @@ pub async fn execute_video_generation(
|
||||
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 {
|
||||
base_url.contains("ark.cn-beijing.volces.com") || base_url.contains("volces.com/api/v3")
|
||||
}
|
||||
@@ -727,334 +602,13 @@ 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.
|
||||
pub async fn execute_image_generation(
|
||||
state: AppState,
|
||||
_state: AppState,
|
||||
route: UpstreamRoute,
|
||||
body: Value,
|
||||
) -> AppResult<Value> {
|
||||
let client = state.http.clone();
|
||||
let client = http_client();
|
||||
let upstream_url = upstream_url(&route.base_url, "/images/generations");
|
||||
|
||||
let resp = client
|
||||
@@ -1107,13 +661,13 @@ pub struct MultipartField {
|
||||
/// the route-resolved upstream model and skip any client-side fields
|
||||
/// that don't belong on the wire.
|
||||
pub async fn execute_image_edit(
|
||||
state: AppState,
|
||||
_state: AppState,
|
||||
route: UpstreamRoute,
|
||||
fields: Vec<MultipartField>,
|
||||
) -> AppResult<Value> {
|
||||
use reqwest::multipart::{Form, Part};
|
||||
|
||||
let client = state.http.clone();
|
||||
let client = http_client();
|
||||
let upstream_url = upstream_url(&route.base_url, "/images/edits");
|
||||
|
||||
let mut form = Form::new();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,8 +13,6 @@ use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
use tracing::info;
|
||||
|
||||
const RUNTIME_HEALTH_DEGRADED_LATENCY_MS: i64 = 5_000;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListAccountsQuery {
|
||||
pub site_id: Option<i64>,
|
||||
@@ -153,23 +151,10 @@ pub async fn list_accounts(
|
||||
let site_by_id: std::collections::HashMap<i64, &Site> =
|
||||
sites.iter().map(|s| (s.id, s)).collect();
|
||||
|
||||
let latest_health_rows: Vec<(
|
||||
i64,
|
||||
String,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
Option<i64>,
|
||||
i64,
|
||||
)> =
|
||||
let latest_health_rows: Vec<(i64, String, Option<i64>, Option<String>, Option<i64>, Option<String>)> =
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
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
|
||||
SELECT l.account_id, l.status, l.http_status, l.error_message, l.latency_ms, l.created_at
|
||||
FROM proxy_logs l
|
||||
INNER JOIN (
|
||||
SELECT account_id, MAX(id) AS id
|
||||
@@ -191,42 +176,14 @@ pub async fn list_accounts(
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
Option<i64>,
|
||||
i64,
|
||||
),
|
||||
> = latest_health_rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(
|
||||
account_id,
|
||||
status,
|
||||
http_status,
|
||||
error_message,
|
||||
latency_ms,
|
||||
created_at,
|
||||
model_requested,
|
||||
model_actual,
|
||||
route_id,
|
||||
channel_id,
|
||||
log_id,
|
||||
)| {
|
||||
|(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,
|
||||
),
|
||||
(status, http_status, error_message, latency_ms, created_at),
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -257,11 +214,6 @@ fn account_response_value(
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
Option<i64>,
|
||||
i64,
|
||||
)>,
|
||||
) -> Value {
|
||||
let credential_mode = resolve_account_credential_mode(account);
|
||||
@@ -294,47 +246,17 @@ fn account_response_value(
|
||||
"state": "unhealthy",
|
||||
"reason": "连接凭证已过期,请更新凭证",
|
||||
})
|
||||
} else if let Some((
|
||||
status,
|
||||
http_status,
|
||||
error_message,
|
||||
latency_ms,
|
||||
created_at,
|
||||
model_requested,
|
||||
model_actual,
|
||||
route_id,
|
||||
channel_id,
|
||||
log_id,
|
||||
)) =
|
||||
} else if let Some((status, http_status, error_message, latency_ms, created_at)) =
|
||||
runtime_health
|
||||
{
|
||||
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!({
|
||||
"state": if !ok {
|
||||
"unhealthy"
|
||||
} else if is_degraded_latency {
|
||||
"degraded"
|
||||
} else {
|
||||
"healthy"
|
||||
},
|
||||
"state": if ok { "healthy" } else { "unhealthy" },
|
||||
"reason": if ok {
|
||||
if is_degraded_latency {
|
||||
match latency_ms {
|
||||
Some(ms) => format!("最近代理请求成功但延迟偏高,用时 {}ms", ms),
|
||||
None => "最近代理请求成功但延迟偏高".to_string(),
|
||||
}
|
||||
} else {
|
||||
match latency_ms {
|
||||
Some(ms) => format!("最近代理请求成功,用时 {}ms", ms),
|
||||
None => "最近代理请求成功".to_string(),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error_message
|
||||
.clone()
|
||||
@@ -343,17 +265,11 @@ fn account_response_value(
|
||||
"latencyMs": latency_ms,
|
||||
"httpStatus": http_status,
|
||||
"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" {
|
||||
json!({
|
||||
"state": "unknown",
|
||||
"reason": "尚无代理请求记录,首次调用后自动更新",
|
||||
"state": "degraded",
|
||||
"reason": "尚无代理请求记录,等待首次调用后更新",
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
@@ -1350,163 +1266,3 @@ 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+17
-1982
File diff suppressed because it is too large
Load Diff
@@ -417,7 +417,6 @@ mod tests {
|
||||
BuiltinKeys {
|
||||
comfyui_agent: (1, "sk-comfyui-abcdef0123456789".into()),
|
||||
},
|
||||
crate::services::runtime_auth::RuntimeAuthSettings::from_token_for_test("proxy"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+16
-76
@@ -24,7 +24,7 @@ mod sites;
|
||||
mod tokens;
|
||||
mod users;
|
||||
|
||||
/// Middleware to require admin authentication via the configured admin API token.
|
||||
/// Middleware to require admin authentication via config token or admin JWT.
|
||||
async fn require_admin_auth(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -37,9 +37,21 @@ async fn require_admin_auth(
|
||||
.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 {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
@@ -61,15 +73,6 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
.route(
|
||||
"/api/sites/{id}/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()
|
||||
@@ -214,7 +217,6 @@ const HIDDEN_SETTINGS_PREFIX: &str = "builtin_key.";
|
||||
|
||||
fn is_hidden_setting_key(key: &str) -> bool {
|
||||
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.
|
||||
@@ -286,15 +288,9 @@ async fn update_setting(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::jwt::generate_jwt;
|
||||
use crate::config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig};
|
||||
use crate::services::builtin_keys::BuiltinKeys;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
async fn settings_state() -> AppState {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
@@ -317,12 +313,6 @@ mod tests {
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO settings (key, value) VALUES ('runtime.auth.proxy_token', 'sk-runtime-secret')",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
AppState::new(
|
||||
AppConfig {
|
||||
@@ -359,12 +349,11 @@ mod tests {
|
||||
BuiltinKeys {
|
||||
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 protected
|
||||
/// secret rows.
|
||||
/// 5.2 — `GET /api/settings` returns regular settings but never any
|
||||
/// `builtin_key.*` rows.
|
||||
#[tokio::test]
|
||||
async fn list_settings_filters_builtin_key_prefix() {
|
||||
let state = settings_state().await;
|
||||
@@ -375,10 +364,6 @@ mod tests {
|
||||
!data.contains_key("builtin_key.comfyui_agent.secret"),
|
||||
"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.*`
|
||||
@@ -417,49 +402,4 @@ mod tests {
|
||||
.expect("update ok");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-894
@@ -1,27 +1,16 @@
|
||||
use crate::{
|
||||
db::models::site::Site,
|
||||
error::{AppError, AppResult},
|
||||
proxy::{conductor::SuccessMetrics, executors, token_router::TokenRouter},
|
||||
services::oauth::oauth_http_client,
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::sse::{Event, Sse},
|
||||
Json,
|
||||
};
|
||||
use futures::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
convert::Infallible,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
|
||||
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||||
use tracing::{info, warn};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DetectSiteRequest {
|
||||
@@ -409,89 +398,12 @@ async fn site_api_endpoints(state: &AppState, site_id: i64) -> AppResult<Vec<Val
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn resolve_site_api_base_url(state: &AppState, site: &Site) -> AppResult<String> {
|
||||
let endpoint: Option<String> = sqlx::query_scalar(
|
||||
"SELECT url
|
||||
FROM site_api_endpoints
|
||||
WHERE site_id = ?
|
||||
AND COALESCE(enabled, 1) = 1
|
||||
AND (cooldown_until IS NULL OR cooldown_until <= datetime('now'))
|
||||
ORDER BY COALESCE(sort_order, 0), id
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(site.id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
let raw_url = endpoint.as_deref().unwrap_or(&site.url).trim();
|
||||
if raw_url.is_empty() {
|
||||
return Err(AppError::BadRequest("site url is required".into()));
|
||||
}
|
||||
|
||||
let normalized = raw_url.trim_end_matches('/').to_string();
|
||||
reqwest::Url::parse(&normalized)
|
||||
.map_err(|err| AppError::BadRequest(format!("Invalid site API URL: {}", err)))?;
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateDisabledModelsRequest {
|
||||
#[serde(default)]
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProbeSiteOptions {
|
||||
pub scope: Option<String>,
|
||||
pub model_name: Option<String>,
|
||||
pub latency_threshold_ms: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SiteProbeModel {
|
||||
name: String,
|
||||
source_account_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SiteProbeModelResult {
|
||||
model_name: String,
|
||||
status: String,
|
||||
latency_ms: Option<i64>,
|
||||
latency_exceeded: bool,
|
||||
reason: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
http_status: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stage: Option<&'static str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
source: Option<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SiteProbeSummary {
|
||||
site_id: i64,
|
||||
scope: String,
|
||||
models_count: usize,
|
||||
probed: usize,
|
||||
supported: usize,
|
||||
unsupported: usize,
|
||||
disabled_models: Vec<String>,
|
||||
results: Vec<SiteProbeModelResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SiteProbePlan {
|
||||
site: Site,
|
||||
base_url: String,
|
||||
scope: String,
|
||||
latency_threshold_ms: i64,
|
||||
models: Vec<SiteProbeModel>,
|
||||
}
|
||||
|
||||
/// GET /api/sites/{id}/disabled-models — list disabled model names for a site.
|
||||
pub async fn list_site_disabled_models(
|
||||
State(state): State<AppState>,
|
||||
@@ -565,727 +477,6 @@ pub async fn update_site_disabled_models(
|
||||
Ok(Json(json!({ "models": normalised })))
|
||||
}
|
||||
|
||||
/// GET /api/sites/{id}/available-models — list currently known available models for a site.
|
||||
pub async fn list_site_available_models(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
ensure_site_exists(&state, id).await?;
|
||||
let models = site_available_model_names(&state, id).await?;
|
||||
Ok(Json(json!({ "models": models })))
|
||||
}
|
||||
|
||||
/// POST /api/sites/{id}/probe-now — actively probe one or all known models for a site.
|
||||
pub async fn probe_site_now(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(options): Json<ProbeSiteOptions>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let plan = build_probe_plan(&state, id, options).await?;
|
||||
let summary = run_site_probe_plan(state, plan, None).await;
|
||||
let summary = summary?;
|
||||
Ok(Json(json!({ "success": true, "probe": summary })))
|
||||
}
|
||||
|
||||
/// GET /api/sites/{id}/probe-stream — stream active site probe progress as SSE.
|
||||
pub async fn probe_site_stream(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Query(options): Query<ProbeSiteOptions>,
|
||||
) -> AppResult<Sse<impl Stream<Item = Result<Event, Infallible>>>> {
|
||||
let plan = build_probe_plan(&state, id, options).await?;
|
||||
let (tx, rx) = unbounded_channel::<Result<Event, Infallible>>();
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = run_site_probe_plan(state, plan, Some(tx.clone())).await {
|
||||
send_probe_sse(&tx, "error", probe_error_payload(&err, None, None));
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Sse::new(UnboundedReceiverStream::new(rx)))
|
||||
}
|
||||
|
||||
async fn ensure_site_exists(state: &AppState, id: i64) -> AppResult<()> {
|
||||
let exists: Option<i64> = sqlx::query_scalar("SELECT id FROM sites WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
if exists.is_none() {
|
||||
return Err(AppError::NotFound("site not found".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn site_available_model_names(state: &AppState, site_id: i64) -> AppResult<Vec<String>> {
|
||||
let rows: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT DISTINCT ma.model_name
|
||||
FROM model_availability ma
|
||||
JOIN accounts a ON a.id = ma.account_id
|
||||
WHERE a.site_id = ?
|
||||
AND a.status = 'active'
|
||||
AND COALESCE(ma.available, 0) = 1
|
||||
AND TRIM(COALESCE(ma.model_name, '')) <> ''
|
||||
ORDER BY ma.model_name ASC",
|
||||
)
|
||||
.bind(site_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
async fn build_probe_plan(
|
||||
state: &AppState,
|
||||
site_id: i64,
|
||||
options: ProbeSiteOptions,
|
||||
) -> AppResult<SiteProbePlan> {
|
||||
let site: Site = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?")
|
||||
.bind(site_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("site not found".into()))?;
|
||||
if site.status != "active" {
|
||||
return Err(AppError::BadRequest("站点未启用,无法执行探测".into()));
|
||||
}
|
||||
|
||||
let scope = match options
|
||||
.scope
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("single")
|
||||
{
|
||||
"all" => "all",
|
||||
"single" => "single",
|
||||
_ => return Err(AppError::BadRequest("scope must be single or all".into())),
|
||||
}
|
||||
.to_string();
|
||||
let latency_threshold_ms = options.latency_threshold_ms.unwrap_or(0).max(0);
|
||||
let base_url = resolve_site_api_base_url(state, &site).await?;
|
||||
let requested_model = options
|
||||
.model_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
let models = if scope == "all" {
|
||||
site_probe_models(state, site_id, None).await?
|
||||
} else if let Some(model_name) = requested_model {
|
||||
site_probe_models(state, site_id, Some(model_name))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|model| vec![model])
|
||||
.unwrap_or_else(|| {
|
||||
vec![SiteProbeModel {
|
||||
name: model_name.to_string(),
|
||||
source_account_id: 0,
|
||||
}]
|
||||
})
|
||||
} else {
|
||||
site_probe_models(state, site_id, None)
|
||||
.await?
|
||||
.into_iter()
|
||||
.take(1)
|
||||
.collect()
|
||||
};
|
||||
|
||||
if models.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"当前站点还没有可探测模型,请先刷新账号模型列表".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(SiteProbePlan {
|
||||
site,
|
||||
base_url,
|
||||
scope,
|
||||
latency_threshold_ms,
|
||||
models,
|
||||
})
|
||||
}
|
||||
|
||||
async fn site_probe_models(
|
||||
state: &AppState,
|
||||
site_id: i64,
|
||||
model_name: Option<&str>,
|
||||
) -> AppResult<Vec<SiteProbeModel>> {
|
||||
let rows: Vec<(String, i64)> = if let Some(model_name) = model_name {
|
||||
sqlx::query_as(
|
||||
"SELECT ma.model_name, MIN(a.id) AS source_account_id
|
||||
FROM model_availability ma
|
||||
JOIN accounts a ON a.id = ma.account_id
|
||||
WHERE a.site_id = ?
|
||||
AND a.status = 'active'
|
||||
AND COALESCE(ma.available, 0) = 1
|
||||
AND ma.model_name = ?
|
||||
GROUP BY ma.model_name
|
||||
ORDER BY ma.model_name ASC",
|
||||
)
|
||||
.bind(site_id)
|
||||
.bind(model_name)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT ma.model_name, MIN(a.id) AS source_account_id
|
||||
FROM model_availability ma
|
||||
JOIN accounts a ON a.id = ma.account_id
|
||||
WHERE a.site_id = ?
|
||||
AND a.status = 'active'
|
||||
AND COALESCE(ma.available, 0) = 1
|
||||
AND TRIM(COALESCE(ma.model_name, '')) <> ''
|
||||
GROUP BY ma.model_name
|
||||
ORDER BY ma.model_name ASC",
|
||||
)
|
||||
.bind(site_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
};
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(name, source_account_id)| SiteProbeModel {
|
||||
name,
|
||||
source_account_id,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn run_site_probe_plan(
|
||||
state: AppState,
|
||||
plan: SiteProbePlan,
|
||||
tx: Option<UnboundedSender<Result<Event, Infallible>>>,
|
||||
) -> AppResult<SiteProbeSummary> {
|
||||
if let Some(tx) = &tx {
|
||||
send_probe_sse(
|
||||
tx,
|
||||
"start",
|
||||
json!({
|
||||
"siteId": plan.site.id,
|
||||
"scope": plan.scope,
|
||||
"modelsCount": plan.models.len(),
|
||||
"latencyThresholdMs": plan.latency_threshold_ms,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let mut results = Vec::with_capacity(plan.models.len());
|
||||
let mut disabled_models = Vec::new();
|
||||
let mut disabled_seen = HashSet::new();
|
||||
|
||||
for model in &plan.models {
|
||||
let result = probe_site_model(&state, &plan, model).await;
|
||||
if let Some(tx) = &tx {
|
||||
send_probe_sse(tx, "model", json!(&result));
|
||||
}
|
||||
|
||||
if result.status == "unsupported" {
|
||||
if disabled_seen.insert(result.model_name.clone()) {
|
||||
disabled_models.push(result.model_name.clone());
|
||||
}
|
||||
if let Err(err) = disable_site_model(&state, plan.site.id, &result.model_name).await {
|
||||
warn!(
|
||||
site_id = plan.site.id,
|
||||
model = %result.model_name,
|
||||
error = %err,
|
||||
"failed to add probed model to site disabled list"
|
||||
);
|
||||
} else if let Some(tx) = &tx {
|
||||
send_probe_sse(
|
||||
tx,
|
||||
"action",
|
||||
json!({ "action": "disabled", "modelName": result.model_name }),
|
||||
);
|
||||
}
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
if !disabled_models.is_empty() {
|
||||
spawn_route_rebuild(state.clone());
|
||||
}
|
||||
|
||||
let supported = results
|
||||
.iter()
|
||||
.filter(|result| result.status == "supported")
|
||||
.count();
|
||||
let unsupported = results
|
||||
.iter()
|
||||
.filter(|result| result.status == "unsupported")
|
||||
.count();
|
||||
let summary = SiteProbeSummary {
|
||||
site_id: plan.site.id,
|
||||
scope: plan.scope,
|
||||
models_count: results.len(),
|
||||
probed: results.len(),
|
||||
supported,
|
||||
unsupported,
|
||||
disabled_models,
|
||||
results,
|
||||
};
|
||||
|
||||
if let Some(tx) = &tx {
|
||||
send_probe_sse(
|
||||
tx,
|
||||
"complete",
|
||||
json!({
|
||||
"siteId": summary.site_id,
|
||||
"scope": summary.scope,
|
||||
"modelsCount": summary.models_count,
|
||||
"probed": summary.probed,
|
||||
"supported": summary.supported,
|
||||
"unsupported": summary.unsupported,
|
||||
"disabledModels": summary.disabled_models,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
async fn probe_site_model(
|
||||
state: &AppState,
|
||||
plan: &SiteProbePlan,
|
||||
model: &SiteProbeModel,
|
||||
) -> SiteProbeModelResult {
|
||||
let started_at = Instant::now();
|
||||
let mut selected = match resolve_probe_channel(state, plan, model).await {
|
||||
Ok(selected) => selected,
|
||||
Err(err) => {
|
||||
let diagnostics = probe_model_error_diagnostics(&err, "model");
|
||||
return SiteProbeModelResult {
|
||||
model_name: model.name.clone(),
|
||||
status: "unsupported".to_string(),
|
||||
latency_ms: None,
|
||||
latency_exceeded: false,
|
||||
reason: Some(probe_error_message(&err)),
|
||||
http_status: diagnostics.http_status,
|
||||
stage: Some(diagnostics.stage),
|
||||
source: Some(diagnostics.source),
|
||||
};
|
||||
}
|
||||
};
|
||||
selected.base_url = plan.base_url.clone();
|
||||
let request_body = json!({
|
||||
"model": selected.actual_model,
|
||||
"messages": [
|
||||
{ "role": "user", "content": "ping" }
|
||||
],
|
||||
"max_tokens": 1,
|
||||
"stream": false,
|
||||
});
|
||||
let mut probe_state = state.clone();
|
||||
match site_probe_http_client(state, &plan.site).await {
|
||||
Ok(client) => probe_state.http = client,
|
||||
Err(err) => {
|
||||
let diagnostics = probe_model_error_diagnostics(&err, "connect");
|
||||
return SiteProbeModelResult {
|
||||
model_name: model.name.clone(),
|
||||
status: "unsupported".to_string(),
|
||||
latency_ms: None,
|
||||
latency_exceeded: false,
|
||||
reason: Some(probe_error_message(&err)),
|
||||
http_status: diagnostics.http_status,
|
||||
stage: Some(diagnostics.stage),
|
||||
source: Some(diagnostics.source),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let timeout_secs = state.config.proxy.upstream_timeout_secs.max(10);
|
||||
let response = match tokio::time::timeout(
|
||||
Duration::from_secs(timeout_secs),
|
||||
executors::execute_chat_completions(
|
||||
probe_state,
|
||||
selected_channel_to_upstream_route(&selected),
|
||||
request_body,
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(AppError::GatewayTimeout(format!(
|
||||
"探测请求超时({}s)",
|
||||
timeout_secs
|
||||
))),
|
||||
};
|
||||
let latency_ms = started_at.elapsed().as_millis().min(i64::MAX as u128) as i64;
|
||||
let latency_exceeded = plan.latency_threshold_ms > 0 && latency_ms > plan.latency_threshold_ms;
|
||||
|
||||
match response {
|
||||
Ok(_) if latency_exceeded => {
|
||||
if let Err(err) =
|
||||
update_model_probe_latency(state, selected.account_id, &model.name, latency_ms, true)
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, model = %model.name, "failed to save probe latency");
|
||||
}
|
||||
SiteProbeModelResult {
|
||||
model_name: model.name.clone(),
|
||||
status: "unsupported".to_string(),
|
||||
latency_ms: Some(latency_ms),
|
||||
latency_exceeded: true,
|
||||
reason: Some(format!("响应延迟 {}ms 超过阈值 {}ms", latency_ms, plan.latency_threshold_ms)),
|
||||
http_status: None,
|
||||
stage: Some("model"),
|
||||
source: Some("probe"),
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
if let Some(channel_id) = selected_channel_id(&selected) {
|
||||
let router = TokenRouter::new(state.db.clone());
|
||||
if let Err(err) = router
|
||||
.record_success(
|
||||
channel_id,
|
||||
SuccessMetrics {
|
||||
latency_ms: Some(latency_ms as u64),
|
||||
cost: None,
|
||||
metadata: Default::default(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(channel_id, error = %err, "failed to record probe success");
|
||||
}
|
||||
}
|
||||
if let Err(err) =
|
||||
update_model_probe_latency(state, selected.account_id, &model.name, latency_ms, true)
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, model = %model.name, "failed to save probe latency");
|
||||
}
|
||||
SiteProbeModelResult {
|
||||
model_name: model.name.clone(),
|
||||
status: "supported".to_string(),
|
||||
latency_ms: Some(latency_ms),
|
||||
latency_exceeded: false,
|
||||
reason: None,
|
||||
http_status: None,
|
||||
stage: Some("model"),
|
||||
source: Some("upstream"),
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let diagnostics = probe_model_error_diagnostics(&err, "model");
|
||||
if let Some(channel_id) = selected_channel_id(&selected) {
|
||||
let router = TokenRouter::new(state.db.clone());
|
||||
if let Err(record_err) = router
|
||||
.record_failure(
|
||||
channel_id,
|
||||
crate::proxy::conductor::FailureInfo {
|
||||
action: "terminal".to_string(),
|
||||
status: diagnostics.http_status,
|
||||
raw_error_text: Some(probe_error_message(&err)),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(channel_id, error = %record_err, "failed to record probe failure");
|
||||
}
|
||||
}
|
||||
if let Err(update_err) =
|
||||
update_model_probe_latency(state, selected.account_id, &model.name, latency_ms, false)
|
||||
.await
|
||||
{
|
||||
warn!(error = %update_err, model = %model.name, "failed to save failed probe state");
|
||||
}
|
||||
SiteProbeModelResult {
|
||||
model_name: model.name.clone(),
|
||||
status: "unsupported".to_string(),
|
||||
latency_ms: Some(latency_ms),
|
||||
latency_exceeded: false,
|
||||
reason: Some(probe_error_message(&err)),
|
||||
http_status: diagnostics.http_status,
|
||||
stage: Some(diagnostics.stage),
|
||||
source: Some(diagnostics.source),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_probe_channel(
|
||||
state: &AppState,
|
||||
plan: &SiteProbePlan,
|
||||
model: &SiteProbeModel,
|
||||
) -> AppResult<crate::proxy::conductor::SelectedChannel> {
|
||||
let router = TokenRouter::new(state.db.clone());
|
||||
if let Ok(selected) = router.resolve_selected(&model.name).await {
|
||||
if selected.site_id == plan.site.id {
|
||||
return Ok(selected);
|
||||
}
|
||||
}
|
||||
|
||||
let row: Option<(i64, String, Option<String>, Option<String>)> = sqlx::query_as(
|
||||
"SELECT id, access_token, api_token, oauth_provider
|
||||
FROM accounts
|
||||
WHERE id = ?
|
||||
AND site_id = ?
|
||||
AND status = 'active'
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(model.source_account_id)
|
||||
.bind(plan.site.id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
let (account_id, access_token, api_token, oauth_provider) = match row {
|
||||
Some(row) => row,
|
||||
None => {
|
||||
let fallback: Option<(i64, String, Option<String>, Option<String>)> = sqlx::query_as(
|
||||
"SELECT id, access_token, api_token, oauth_provider
|
||||
FROM accounts
|
||||
WHERE site_id = ?
|
||||
AND status = 'active'
|
||||
ORDER BY sort_order, id
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(plan.site.id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
fallback.ok_or_else(|| AppError::BadRequest("当前站点没有启用中的账号,无法探测".into()))?
|
||||
}
|
||||
};
|
||||
|
||||
let api_key = if oauth_provider
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
{
|
||||
crate::services::oauth::OAuthService::new(state.db.clone())
|
||||
.refresh_token(account_id, plan.site.id)
|
||||
.await?
|
||||
.access_token
|
||||
} else {
|
||||
api_token.unwrap_or(access_token)
|
||||
}
|
||||
.trim()
|
||||
.to_string();
|
||||
let api_key = if api_key.is_empty() {
|
||||
plan.site.api_key.clone().unwrap_or_default()
|
||||
} else {
|
||||
api_key
|
||||
};
|
||||
|
||||
if api_key.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("当前站点账号缺少可用 Token,无法探测".into()));
|
||||
}
|
||||
|
||||
Ok(crate::proxy::conductor::SelectedChannel {
|
||||
channel_id: 0,
|
||||
route_id: None,
|
||||
site_id: plan.site.id,
|
||||
account_id: Some(account_id),
|
||||
account_unit_cost: None,
|
||||
base_url: plan.base_url.clone(),
|
||||
api_key,
|
||||
model: model.name.clone(),
|
||||
actual_model: model.name.clone(),
|
||||
token_name: None,
|
||||
token_value: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn site_probe_http_client(state: &AppState, site: &Site) -> AppResult<reqwest::Client> {
|
||||
let has_custom_proxy = site
|
||||
.proxy_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some();
|
||||
if has_custom_proxy || site.use_system_proxy.unwrap_or(false) {
|
||||
return oauth_http_client(
|
||||
&state.db,
|
||||
site.proxy_url.as_deref(),
|
||||
site.use_system_proxy.unwrap_or(false),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(state.http.clone())
|
||||
}
|
||||
|
||||
fn selected_channel_to_upstream_route(
|
||||
selected: &crate::proxy::conductor::SelectedChannel,
|
||||
) -> crate::proxy::executors::UpstreamRoute {
|
||||
crate::proxy::executors::UpstreamRoute {
|
||||
base_url: selected.base_url.clone(),
|
||||
api_key: selected.api_key.clone(),
|
||||
model: selected.actual_model.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_channel_id(selected: &crate::proxy::conductor::SelectedChannel) -> Option<i64> {
|
||||
(selected.channel_id > 0).then_some(selected.channel_id)
|
||||
}
|
||||
|
||||
async fn update_model_probe_latency(
|
||||
state: &AppState,
|
||||
account_id: Option<i64>,
|
||||
model_name: &str,
|
||||
latency_ms: i64,
|
||||
available: bool,
|
||||
) -> AppResult<()> {
|
||||
let Some(account_id) = account_id else {
|
||||
return Ok(());
|
||||
};
|
||||
sqlx::query(
|
||||
"UPDATE model_availability
|
||||
SET latency_ms = ?,
|
||||
available = ?,
|
||||
checked_at = datetime('now')
|
||||
WHERE account_id = ?
|
||||
AND model_name = ?",
|
||||
)
|
||||
.bind(latency_ms)
|
||||
.bind(available)
|
||||
.bind(account_id)
|
||||
.bind(model_name)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disable_site_model(state: &AppState, site_id: i64, model_name: &str) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO site_disabled_models (site_id, model_name)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(site_id, model_name) DO NOTHING",
|
||||
)
|
||||
.bind(site_id)
|
||||
.bind(model_name)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_probe_sse(
|
||||
tx: &UnboundedSender<Result<Event, Infallible>>,
|
||||
event_type: &str,
|
||||
payload: Value,
|
||||
) -> bool {
|
||||
tx.send(Ok(Event::default()
|
||||
.event(event_type)
|
||||
.json_data(payload)
|
||||
.unwrap_or_else(|_| Event::default().event(event_type).data("{}"))))
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn probe_error_message(err: &AppError) -> String {
|
||||
match err {
|
||||
AppError::NotFound(message)
|
||||
| AppError::BadRequest(message)
|
||||
| AppError::Forbidden(message)
|
||||
| AppError::Unauthorized(message)
|
||||
| AppError::ServiceUnavailable(message)
|
||||
| AppError::GatewayTimeout(message) => message.clone(),
|
||||
AppError::UpstreamError(message) => simplify_upstream_error_message(message),
|
||||
AppError::RateLimited => "触发频率限制".to_string(),
|
||||
AppError::RateLimitedWithRetry { message, .. } => message.clone(),
|
||||
AppError::Internal(err) => err.to_string(),
|
||||
AppError::Database(err) => err.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn probe_error_payload(err: &AppError, site_id: Option<i64>, model_name: Option<&str>) -> Value {
|
||||
let message = probe_error_message(err);
|
||||
let diagnostics = probe_model_error_diagnostics(err, "stream");
|
||||
json!({
|
||||
"message": message,
|
||||
"httpStatus": diagnostics.http_status,
|
||||
"siteId": site_id,
|
||||
"modelName": model_name,
|
||||
"stage": diagnostics.stage,
|
||||
"source": diagnostics.source,
|
||||
})
|
||||
}
|
||||
|
||||
fn simplify_upstream_error_message(message: &str) -> String {
|
||||
let marker = "Upstream returned ";
|
||||
if let Some(start) = message.find(marker).map(|index| index + marker.len()) {
|
||||
let tail = &message[start..];
|
||||
if let Some((status, body)) = tail.split_once(':') {
|
||||
let body = body.trim();
|
||||
return if body.is_empty() {
|
||||
format!("上游返回 HTTP {}", status.trim())
|
||||
} else {
|
||||
format!(
|
||||
"上游返回 HTTP {}:{}",
|
||||
status.trim(),
|
||||
body.chars().take(180).collect::<String>()
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
message.chars().take(220).collect()
|
||||
}
|
||||
|
||||
fn upstream_status_from_error(err: &AppError) -> Option<u16> {
|
||||
match err {
|
||||
AppError::UpstreamError(message) => extract_http_status_from_text(message),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_http_status_from_text(message: &str) -> Option<u16> {
|
||||
message
|
||||
.split(|ch: char| !ch.is_ascii_digit())
|
||||
.find_map(|token| {
|
||||
if token.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let status = token.parse::<u16>().ok()?;
|
||||
if (100..=599).contains(&status) {
|
||||
Some(status)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct ProbeErrorDiagnostics {
|
||||
http_status: Option<u16>,
|
||||
stage: &'static str,
|
||||
source: &'static str,
|
||||
}
|
||||
|
||||
fn probe_model_error_diagnostics(
|
||||
err: &AppError,
|
||||
fallback_stage: &'static str,
|
||||
) -> ProbeErrorDiagnostics {
|
||||
if matches!(err, AppError::UpstreamError(_)) {
|
||||
return ProbeErrorDiagnostics {
|
||||
http_status: upstream_status_from_error(err),
|
||||
stage: "model",
|
||||
source: "upstream",
|
||||
};
|
||||
}
|
||||
|
||||
ProbeErrorDiagnostics {
|
||||
http_status: None,
|
||||
stage: fallback_stage,
|
||||
source: "probe",
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_route_rebuild(state: AppState) {
|
||||
tokio::spawn(async move {
|
||||
match crate::services::route_rebuild::rebuild_routes(&state).await {
|
||||
Ok(summary) => {
|
||||
info!(
|
||||
created_routes = summary.created_routes,
|
||||
reused_routes = summary.reused_routes,
|
||||
created_channels = summary.created_channels,
|
||||
deleted_channels = summary.deleted_channels,
|
||||
"route rebuild completed after site probe"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = %err, "route rebuild after site probe failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn normalize_site_url(raw: &str) -> AppResult<String> {
|
||||
let trimmed = raw.trim().trim_end_matches('/').to_string();
|
||||
if trimmed.is_empty() {
|
||||
@@ -1454,86 +645,3 @@ async fn replace_site_api_endpoints(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn probe_options_accept_camel_case_payload() {
|
||||
let options: ProbeSiteOptions = serde_json::from_value(json!({
|
||||
"scope": "single",
|
||||
"modelName": "gpt-4o",
|
||||
"latencyThresholdMs": 1800
|
||||
}))
|
||||
.expect("probe options should deserialize from frontend payload");
|
||||
|
||||
assert_eq!(options.scope.as_deref(), Some("single"));
|
||||
assert_eq!(options.model_name.as_deref(), Some("gpt-4o"));
|
||||
assert_eq!(options.latency_threshold_ms, Some(1800));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simplify_upstream_error_message_names_http_status() {
|
||||
let message = simplify_upstream_error_message(
|
||||
"Upstream returned 404 Not Found: {\"error\":\"model missing\"}",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
message,
|
||||
"上游返回 HTTP 404 Not Found:{\"error\":\"model missing\"}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_status_from_error_extracts_probe_failure_status() {
|
||||
let err = AppError::UpstreamError("Upstream returned 503 Service Unavailable: busy".into());
|
||||
|
||||
assert_eq!(upstream_status_from_error(&err), Some(503));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_status_from_error_extracts_localized_probe_failure_status() {
|
||||
let err = AppError::UpstreamError("上游返回 HTTP 404 Not Found:model missing".into());
|
||||
|
||||
assert_eq!(upstream_status_from_error(&err), Some(404));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_probe_model_result_serializes_structured_diagnostics() {
|
||||
let result = SiteProbeModelResult {
|
||||
model_name: "gpt-4o".to_string(),
|
||||
status: "unsupported".to_string(),
|
||||
latency_ms: Some(88),
|
||||
latency_exceeded: false,
|
||||
reason: Some("Not found".to_string()),
|
||||
http_status: Some(404),
|
||||
stage: Some("model"),
|
||||
source: Some("upstream"),
|
||||
};
|
||||
|
||||
let payload = serde_json::to_value(&result).expect("model result should serialize");
|
||||
|
||||
assert_eq!(payload.get("httpStatus").and_then(Value::as_u64), Some(404));
|
||||
assert_eq!(payload.get("stage").and_then(Value::as_str), Some("model"));
|
||||
assert_eq!(payload.get("source").and_then(Value::as_str), Some("upstream"));
|
||||
assert_eq!(payload.get("modelName").and_then(Value::as_str), Some("gpt-4o"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_error_payload_preserves_upstream_status_for_sse_diagnostics() {
|
||||
let err = AppError::UpstreamError("Upstream returned 404 Not Found: model missing".into());
|
||||
|
||||
let payload = probe_error_payload(&err, Some(7), Some("gpt-4o"));
|
||||
|
||||
assert_eq!(payload.get("httpStatus").and_then(Value::as_u64), Some(404));
|
||||
assert_eq!(payload.get("stage").and_then(Value::as_str), Some("model"));
|
||||
assert_eq!(payload.get("source").and_then(Value::as_str), Some("upstream"));
|
||||
assert_eq!(payload.get("siteId").and_then(Value::as_i64), Some(7));
|
||||
assert_eq!(payload.get("modelName").and_then(Value::as_str), Some("gpt-4o"));
|
||||
assert_eq!(
|
||||
payload.get("message").and_then(Value::as_str),
|
||||
Some("上游返回 HTTP 404 Not Found:model missing")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-150
@@ -324,10 +324,7 @@ pub async fn get_user_usage(
|
||||
// Recent logs
|
||||
let recent_logs: Vec<Value> = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
l.*,
|
||||
k.id AS downstream_api_key_id,
|
||||
k.name AS downstream_key_name
|
||||
SELECT l.*
|
||||
FROM usage_logs l
|
||||
JOIN downstream_api_keys k ON l.downstream_key_id = k.id
|
||||
WHERE k.user_id = ?
|
||||
@@ -344,11 +341,6 @@ pub async fn get_user_usage(
|
||||
json!({
|
||||
"id": row.get::<i64, _>("id"),
|
||||
"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),
|
||||
"completionTokens": row.get::<Option<i64>, _>("completion_tokens").unwrap_or(0),
|
||||
"totalTokens": row.get::<Option<i64>, _>("total_tokens").unwrap_or(0),
|
||||
@@ -707,144 +699,3 @@ pub async fn update_user_key(
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
+13
-867
@@ -10,14 +10,14 @@ use crate::{
|
||||
};
|
||||
use axum::{
|
||||
extract::{ConnectInfo, Path, State},
|
||||
http::{header, HeaderMap},
|
||||
http::HeaderMap,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use tracing::{info, warn};
|
||||
use std::net::SocketAddr;
|
||||
use tracing::info;
|
||||
|
||||
// ── Request / Response types ─────────────────────────────────────────
|
||||
|
||||
@@ -70,6 +70,7 @@ pub fn routes() -> Router<AppState> {
|
||||
.route("/api/users/register", post(register))
|
||||
.route("/api/auth/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/auth/me", get(me))
|
||||
.route("/api/users/me", get(me).patch(update_me))
|
||||
@@ -228,82 +229,24 @@ async fn register(
|
||||
/// POST /api/auth/login
|
||||
async fn login(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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 = if let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
|
||||
.bind(&email)
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
|
||||
.bind(&payload.email)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
{
|
||||
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()));
|
||||
};
|
||||
.ok_or_else(|| AppError::Unauthorized("Invalid email or password".into()))?;
|
||||
|
||||
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()));
|
||||
}
|
||||
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)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?;
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
@@ -315,18 +258,6 @@ async fn login(
|
||||
)
|
||||
.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!({
|
||||
"token": token,
|
||||
"user": {
|
||||
@@ -357,25 +288,10 @@ async fn login(
|
||||
/// be enabled, and the owning user must be active.
|
||||
async fn login_api_key(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<LoginApiKeyRequest>,
|
||||
) -> 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();
|
||||
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()));
|
||||
}
|
||||
|
||||
@@ -386,91 +302,20 @@ async fn login_api_key(
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
let api_key = if let Some(api_key) = row {
|
||||
api_key
|
||||
} else {
|
||||
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 api_key = row.ok_or_else(|| AppError::Unauthorized("Invalid API key".into()))?;
|
||||
let user_id = api_key
|
||||
.user_id
|
||||
.ok_or_else(|| AppError::Unauthorized("API key is not bound to a user".into()))?;
|
||||
|
||||
let user: User = if let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
{
|
||||
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()));
|
||||
};
|
||||
.ok_or_else(|| AppError::Unauthorized("User not found".into()))?;
|
||||
|
||||
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()));
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -482,18 +327,6 @@ async fn login_api_key(
|
||||
)
|
||||
.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!({
|
||||
"token": token,
|
||||
"user": {
|
||||
@@ -670,225 +503,6 @@ 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 ──────────────────────────
|
||||
|
||||
fn extract_jwt(state: &AppState, headers: &HeaderMap) -> AppResult<(i64, String)> {
|
||||
@@ -908,471 +522,3 @@ fn extract_jwt(state: &AppState, headers: &HeaderMap) -> AppResult<(i64, String)
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-111
@@ -6,7 +6,6 @@
|
||||
|
||||
use crate::{
|
||||
auth::api_key::{authorize_downstream_token, DownstreamAuthResult},
|
||||
auth::jwt::validate_jwt,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
@@ -48,20 +47,10 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
|
||||
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
|
||||
if auth.starts_with("sk-") {
|
||||
let proxy_token = state.runtime_proxy_token();
|
||||
let result =
|
||||
authorize_downstream_token(auth, &state.db, Some(&proxy_token))
|
||||
authorize_downstream_token(auth, &state.db, Some(&state.config.auth.proxy_token))
|
||||
.await
|
||||
.map_err(AppError::Unauthorized)?;
|
||||
|
||||
@@ -79,105 +68,6 @@ 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.
|
||||
fn comfyui_base(state: &AppState) -> String {
|
||||
state.comfyui.internal_base_url()
|
||||
|
||||
+19
-130
@@ -243,12 +243,11 @@ async fn resolve_agent_auth(
|
||||
)
|
||||
})?;
|
||||
|
||||
let proxy_token = state.runtime_proxy_token();
|
||||
if token == state.config.auth.auth_token || token == proxy_token {
|
||||
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
|
||||
return Ok(AgentAuthContext {
|
||||
user_id: None,
|
||||
role: "admin".to_string(),
|
||||
outbound_key: proxy_token,
|
||||
outbound_key: state.config.auth.proxy_token.clone(),
|
||||
key_name: "global-proxy".to_string(),
|
||||
});
|
||||
}
|
||||
@@ -283,7 +282,7 @@ async fn resolve_agent_auth(
|
||||
Err(_) if claims.role == "admin" => Ok(AgentAuthContext {
|
||||
user_id: Some(user_id),
|
||||
role: claims.role,
|
||||
outbound_key: state.runtime_proxy_token(),
|
||||
outbound_key: state.config.auth.proxy_token.clone(),
|
||||
key_name: "global-proxy".to_string(),
|
||||
}),
|
||||
Err(message) => Err((
|
||||
@@ -369,8 +368,6 @@ async fn status_handler(
|
||||
"keyName": key_name,
|
||||
"userId": auth.user_id,
|
||||
"role": auth.role,
|
||||
"apiBase": upstream_base,
|
||||
"upstreamBase": upstream_base,
|
||||
"upstreamLabel": upstream_label,
|
||||
"comfyui": state.comfyui.status_response().await,
|
||||
})))
|
||||
@@ -683,77 +680,6 @@ fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) ->
|
||||
.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 {
|
||||
let mime = attachment
|
||||
.mime_type
|
||||
@@ -1010,7 +936,22 @@ async fn chat_handler(
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(reasoning) = extract_reasoning_delta(&parsed) {
|
||||
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 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(
|
||||
&tx,
|
||||
json!({
|
||||
@@ -1022,14 +963,6 @@ 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
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
@@ -1310,7 +1243,6 @@ mod tests {
|
||||
BuiltinKeys {
|
||||
comfyui_agent: (1, "sk-comfyui-test".into()),
|
||||
},
|
||||
crate::services::runtime_auth::RuntimeAuthSettings::from_token_for_test("proxy"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1330,49 +1262,6 @@ mod tests {
|
||||
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:
|
||||
/// no env → local; whitespace/empty env → still local (treated as unset);
|
||||
/// real env → fallback_remote with trimmed `upstreamBase`.
|
||||
|
||||
+14
-617
@@ -2,7 +2,6 @@ use crate::{
|
||||
auth::jwt::validate_jwt,
|
||||
db::models::{background_task::BackgroundTask, downstream_api_key::DownstreamApiKey},
|
||||
error::{AppError, AppResult},
|
||||
services::billing::{ComfyTaskBillingEvent, UsageTracker},
|
||||
services::workspace::{self, WorkspaceAssetInput},
|
||||
workflow::storage,
|
||||
AppState,
|
||||
@@ -10,7 +9,7 @@ use crate::{
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Multipart, Path, Query, State},
|
||||
http::{header, HeaderMap, Method, StatusCode},
|
||||
http::{header, HeaderMap, Method},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
@@ -23,61 +22,11 @@ const DIGITAL_HUMAN_TIMEOUT_SECS: u64 = 900;
|
||||
const DIGITAL_HUMAN_POLL_INTERVAL_SECS: u64 = 5;
|
||||
const DIGITAL_HUMAN_MAX_POLLS: usize = 180;
|
||||
|
||||
fn normalize_video_model(value: &str) -> String {
|
||||
match value.trim() {
|
||||
"seedance-1-0-lite-t2v" => "seedance-1-0-lite-t2v-250428".to_string(),
|
||||
"seedance-1-0-lite-i2v" => "seedance-1-0-pro-i2v-250528".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_task_cost(value: f64) -> f64 {
|
||||
if value.is_finite() {
|
||||
value.max(0.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_chars(value: &str, limit: usize) -> String {
|
||||
value.chars().take(limit).collect()
|
||||
}
|
||||
|
||||
fn comfy_task_event(
|
||||
auth: &DigitalHumanAuth,
|
||||
model: String,
|
||||
prompt_tokens: i64,
|
||||
completion_tokens: i64,
|
||||
cost: f64,
|
||||
script: &str,
|
||||
request: &Value,
|
||||
response: Option<&Value>,
|
||||
error_message: Option<String>,
|
||||
) -> ComfyTaskBillingEvent {
|
||||
ComfyTaskBillingEvent {
|
||||
key_id: auth.key_id,
|
||||
model,
|
||||
task_type: TASK_TYPE_DIGITAL_HUMAN,
|
||||
client_family: "digital-human",
|
||||
client_app_id: "digital-human",
|
||||
client_app_name: "Digital Human",
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
cost,
|
||||
content_summary: Some(truncate_chars(script, 240)),
|
||||
request_content: Some(request.to_string()),
|
||||
response_content: response.map(Value::to_string),
|
||||
error_message,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct DigitalHumanAuth {
|
||||
user_id: Option<i64>,
|
||||
role: String,
|
||||
outbound_key: String,
|
||||
key_id: Option<i64>,
|
||||
key_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -155,14 +104,11 @@ async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<DigitalHu
|
||||
let token = extract_bearer_token(headers)
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||
|
||||
let proxy_token = state.runtime_proxy_token();
|
||||
if token == state.config.auth.auth_token || token == proxy_token {
|
||||
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
|
||||
return Ok(DigitalHumanAuth {
|
||||
user_id: None,
|
||||
role: "admin".to_string(),
|
||||
outbound_key: proxy_token,
|
||||
key_id: None,
|
||||
key_name: "global-proxy".to_string(),
|
||||
outbound_key: state.config.auth.proxy_token.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -181,26 +127,21 @@ async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<DigitalHu
|
||||
user_id: Some(user_id),
|
||||
role: claims.role,
|
||||
outbound_key: key.key,
|
||||
key_id: Some(key.id),
|
||||
key_name: key.name,
|
||||
}),
|
||||
Err(_) if claims.role == "admin" => Ok(DigitalHumanAuth {
|
||||
user_id: Some(user_id),
|
||||
role: claims.role,
|
||||
outbound_key: state.runtime_proxy_token(),
|
||||
key_id: None,
|
||||
key_name: "global-proxy".to_string(),
|
||||
outbound_key: state.config.auth.proxy_token.clone(),
|
||||
}),
|
||||
Err(message) => Err(AppError::Forbidden(message)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||
let auth = resolve_auth(&headers, &state).await?;
|
||||
let _ = resolve_auth(&headers, &state).await?;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"mode": if state.config.comfyui.uses_native_backend() { "comfy-native" } else { "boosapi-video-route" },
|
||||
"keyName": auth.key_name,
|
||||
"runninghub": {
|
||||
"configured": state.config.comfyui.api_key.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()),
|
||||
"endpoint": "https://www.runninghub.cn/task/openapi/ai-app/run"
|
||||
@@ -544,7 +485,6 @@ fn default_mapping() -> Value {
|
||||
json!({
|
||||
"prompt": { "nodeId": "", "field": "prompt" },
|
||||
"script": { "nodeId": "", "field": "text" },
|
||||
"model": { "nodeId": "", "field": "model" },
|
||||
"avatar": { "nodeId": "", "field": "image" },
|
||||
"referenceAudio": { "nodeId": "", "field": "audio" },
|
||||
"voiceReference": { "nodeId": "", "field": "audio" },
|
||||
@@ -579,106 +519,11 @@ fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn graph_node_type(node: &Value) -> String {
|
||||
node.get("type")
|
||||
.or_else(|| node.get("class_type"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn graph_node_params(node: &Value) -> serde_json::Map<String, Value> {
|
||||
node.get("params")
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.or_else(|| node.get("inputs").and_then(Value::as_object).cloned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn normalize_comfy_value(class_type: &str, field: &str, value: Value) -> Value {
|
||||
match (class_type, field) {
|
||||
("KSampler", "cfg") => value
|
||||
.as_f64()
|
||||
.filter(|value| (0.0..=50.0).contains(value))
|
||||
.map_or(json!(8.0), |value| json!(value)),
|
||||
("KSampler", "denoise") => value
|
||||
.as_f64()
|
||||
.map(|value| if value > 1.0 { value / 100.0 } else { value })
|
||||
.filter(|value| (0.0..=1.0).contains(value))
|
||||
.map_or(json!(1.0), |value| json!(value)),
|
||||
("KSampler", "sampler_name") => {
|
||||
if value.is_string() {
|
||||
value
|
||||
} else {
|
||||
json!("euler")
|
||||
}
|
||||
}
|
||||
("KSampler", "scheduler") => {
|
||||
if value.is_string() {
|
||||
value
|
||||
} else {
|
||||
json!("normal")
|
||||
}
|
||||
}
|
||||
("KSampler", "seed") => value.as_i64().map_or(json!(0), |value| json!(value.max(0))),
|
||||
("EmptyLatentImage", "width") | ("EmptyLatentImage", "height") => value
|
||||
.as_i64()
|
||||
.filter(|value| (64..=4096).contains(value))
|
||||
.map_or(json!(1024), |value| json!(value)),
|
||||
("EmptyLatentImage", "batch_size") => value
|
||||
.as_i64()
|
||||
.filter(|value| (1..=16).contains(value))
|
||||
.map_or(json!(1), |value| json!(value)),
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
|
||||
fn split_endpoint(value: &str) -> Option<(&str, &str)> {
|
||||
let (node_id, port) = value.split_once('.')?;
|
||||
Some((node_id.trim(), port.trim()))
|
||||
}
|
||||
|
||||
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").or_else(|| node.get("nodeId")))?;
|
||||
let class_type = graph_node_type(node);
|
||||
if class_type.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut inputs = serde_json::Map::new();
|
||||
for (field, value) in graph_node_params(node) {
|
||||
inputs.insert(field.clone(), normalize_comfy_value(&class_type, &field, value));
|
||||
}
|
||||
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)) = split_endpoint(from) else { continue; };
|
||||
let Some((to_id, to_field)) = split_endpoint(to) 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 workflow_prompt_from_graph(graph: &Value) -> Value {
|
||||
graph.get("prompt")
|
||||
.cloned()
|
||||
.or_else(|| graph.get("workflow").cloned())
|
||||
.or_else(|| graph.get("comfyPrompt").cloned())
|
||||
.or_else(|| graph_to_comfy_prompt(graph))
|
||||
.unwrap_or_else(|| graph.clone())
|
||||
}
|
||||
|
||||
@@ -737,16 +582,6 @@ fn inject_digital_human_inputs(
|
||||
&mapping_field(mapping, &["script", "scriptNode", "script_node"], "text"),
|
||||
json!(product_script),
|
||||
)?;
|
||||
if let Some(model) = optional_string(request, &["comfyApiModel", "comfy_api_model", "apiModel", "api_model"]) {
|
||||
if let Some(model_node) = mapping_node_id(mapping, &["model", "modelNode", "apiModel"]) {
|
||||
apply_node_input(
|
||||
prompt,
|
||||
&model_node,
|
||||
&mapping_field(mapping, &["model", "modelNode", "apiModel"], "model"),
|
||||
json!(normalize_video_model(&model)),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
apply_node_input(
|
||||
prompt,
|
||||
&avatar_node,
|
||||
@@ -841,13 +676,6 @@ fn native_request(state: &AppState, method: Method, path: &str) -> reqwest::Requ
|
||||
request
|
||||
}
|
||||
|
||||
fn is_pending_history_status(status: StatusCode) -> bool {
|
||||
matches!(
|
||||
status,
|
||||
StatusCode::NOT_FOUND | StatusCode::ACCEPTED | StatusCode::NO_CONTENT
|
||||
)
|
||||
}
|
||||
|
||||
async fn native_json(
|
||||
state: &AppState,
|
||||
method: Method,
|
||||
@@ -874,35 +702,6 @@ async fn native_json(
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
async fn native_json_optional(
|
||||
state: &AppState,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<Value>,
|
||||
) -> AppResult<Option<Value>> {
|
||||
let mut request = native_request(state, method, path);
|
||||
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();
|
||||
if is_pending_history_status(status) {
|
||||
return Ok(None);
|
||||
}
|
||||
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(Some(value))
|
||||
}
|
||||
|
||||
async fn submit_prompt(state: &AppState, prompt: Value) -> AppResult<(String, Value)> {
|
||||
let body = json!({
|
||||
"client_id": format!("boosapi-digital-human-{}", uuid::Uuid::new_v4().simple()),
|
||||
@@ -920,50 +719,18 @@ async fn submit_prompt(state: &AppState, prompt: Value) -> AppResult<(String, Va
|
||||
Ok((prompt_id, value))
|
||||
}
|
||||
|
||||
async fn prompt_history_optional(state: &AppState, prompt_id: &str) -> AppResult<Option<Value>> {
|
||||
let path = if state.config.comfyui.uses_cloud_backend() {
|
||||
format!("/jobs/{prompt_id}")
|
||||
} else {
|
||||
format!("/history/{prompt_id}")
|
||||
};
|
||||
native_json_optional(state, Method::GET, &path, None).await
|
||||
}
|
||||
|
||||
fn prompt_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"))
|
||||
async fn prompt_history(state: &AppState, prompt_id: &str) -> AppResult<Value> {
|
||||
native_json(state, Method::GET, &format!("/history/{prompt_id}"), None).await
|
||||
}
|
||||
|
||||
fn output_objects(value: &Value) -> Vec<Value> {
|
||||
let mut out = Vec::new();
|
||||
fn push_unique(out: &mut Vec<Value>, item: Value) {
|
||||
let identity = output_identity(&item);
|
||||
if out.iter().any(|existing| output_identity(existing) == identity) {
|
||||
return;
|
||||
}
|
||||
out.push(item);
|
||||
}
|
||||
fn walk(value: &Value, out: &mut Vec<Value>) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
if output_filename(value).is_some() || output_remote_url(value).is_some() {
|
||||
push_unique(out, value.clone());
|
||||
}
|
||||
for key in ["videos", "gifs", "images", "audio"] {
|
||||
if let Some(items) = object.get(key).and_then(Value::as_array) {
|
||||
for item in items {
|
||||
walk(item, out);
|
||||
}
|
||||
out.extend(items.iter().filter(|item| item.is_object()).cloned());
|
||||
}
|
||||
}
|
||||
for value in object.values() {
|
||||
@@ -975,11 +742,6 @@ fn output_objects(value: &Value) -> Vec<Value> {
|
||||
walk(item, out);
|
||||
}
|
||||
}
|
||||
Value::String(value) => {
|
||||
if let Some(item) = output_item_from_reference(value) {
|
||||
push_unique(out, item);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -987,112 +749,10 @@ fn output_objects(value: &Value) -> Vec<Value> {
|
||||
out
|
||||
}
|
||||
|
||||
fn output_identity(item: &Value) -> String {
|
||||
output_remote_url(item)
|
||||
.or_else(|| output_filename(item))
|
||||
.unwrap_or_else(|| item.to_string())
|
||||
}
|
||||
|
||||
fn looks_like_media_reference(value: &str) -> bool {
|
||||
let trimmed = value.trim();
|
||||
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://") || trimmed.starts_with('/')) {
|
||||
return false;
|
||||
}
|
||||
let lower = trimmed
|
||||
.split(['?', '#'])
|
||||
.next()
|
||||
.unwrap_or(trimmed)
|
||||
.to_ascii_lowercase();
|
||||
lower.contains("/view")
|
||||
|| lower.ends_with(".mp4")
|
||||
|| lower.ends_with(".webm")
|
||||
|| lower.ends_with(".mov")
|
||||
|| lower.ends_with(".m4v")
|
||||
|| lower.ends_with(".gif")
|
||||
|| lower.ends_with(".png")
|
||||
|| lower.ends_with(".jpg")
|
||||
|| lower.ends_with(".jpeg")
|
||||
|| lower.ends_with(".webp")
|
||||
|| lower.ends_with(".mp3")
|
||||
|| lower.ends_with(".wav")
|
||||
|| lower.ends_with(".m4a")
|
||||
}
|
||||
|
||||
fn output_item_from_reference(value: &str) -> Option<Value> {
|
||||
let trimmed = value.trim();
|
||||
if !looks_like_media_reference(trimmed) {
|
||||
return None;
|
||||
}
|
||||
let filename = trimmed
|
||||
.split(['?', '#'])
|
||||
.next()
|
||||
.unwrap_or(trimmed)
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or("digital-human-output")
|
||||
.to_string();
|
||||
Some(json!({
|
||||
"url": trimmed,
|
||||
"filename": filename,
|
||||
"type": "output"
|
||||
}))
|
||||
}
|
||||
|
||||
fn output_filename(item: &Value) -> Option<String> {
|
||||
["filename", "fileName", "name", "file"]
|
||||
.iter()
|
||||
.find_map(|key| scalar_string(item.get(*key)))
|
||||
}
|
||||
|
||||
fn output_remote_url(item: &Value) -> Option<String> {
|
||||
[
|
||||
"url",
|
||||
"remoteUrl",
|
||||
"remote_url",
|
||||
"videoUrl",
|
||||
"video_url",
|
||||
"audioUrl",
|
||||
"audio_url",
|
||||
"imageUrl",
|
||||
"image_url",
|
||||
"fileUrl",
|
||||
"file_url",
|
||||
"downloadUrl",
|
||||
"download_url",
|
||||
"video",
|
||||
"audio",
|
||||
"image",
|
||||
]
|
||||
.iter()
|
||||
.find_map(|key| scalar_string(item.get(*key)))
|
||||
.filter(|value| looks_like_media_reference(value))
|
||||
}
|
||||
|
||||
fn output_kind(item: &Value) -> &'static str {
|
||||
let explicit = scalar_string(item.get("kind"))
|
||||
.or_else(|| scalar_string(item.get("mediaType")))
|
||||
.or_else(|| scalar_string(item.get("mime")))
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if explicit.contains("video") {
|
||||
return "video";
|
||||
}
|
||||
if explicit.contains("audio") {
|
||||
return "audio";
|
||||
}
|
||||
if explicit.contains("image") {
|
||||
return "image";
|
||||
}
|
||||
let lower = output_filename(item)
|
||||
.or_else(|| output_remote_url(item))
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if lower.ends_with(".mp4")
|
||||
|| lower.ends_with(".webm")
|
||||
|| lower.ends_with(".mov")
|
||||
|| lower.ends_with(".m4v")
|
||||
|| lower.ends_with(".gif")
|
||||
{
|
||||
let filename = scalar_string(item.get("filename")).unwrap_or_default();
|
||||
let lower = filename.to_ascii_lowercase();
|
||||
if lower.ends_with(".mp4") || lower.ends_with(".webm") || lower.ends_with(".mov") {
|
||||
"video"
|
||||
} else if lower.ends_with(".mp3") || lower.ends_with(".wav") || lower.ends_with(".m4a") {
|
||||
"audio"
|
||||
@@ -1103,7 +763,7 @@ fn output_kind(item: &Value) -> &'static str {
|
||||
|
||||
fn output_query(item: &Value) -> HashMap<String, String> {
|
||||
let mut params = HashMap::new();
|
||||
if let Some(filename) = output_filename(item) {
|
||||
if let Some(filename) = scalar_string(item.get("filename")) {
|
||||
params.insert("filename".to_string(), filename);
|
||||
}
|
||||
if let Some(file_type) = scalar_string(item.get("type")) {
|
||||
@@ -1148,56 +808,6 @@ async fn archive_output(
|
||||
prompt_id: &str,
|
||||
script: &str,
|
||||
) -> Option<Value> {
|
||||
if let Some(remote_url) = output_remote_url(item) {
|
||||
let filename = output_filename(item).unwrap_or_else(|| {
|
||||
remote_url
|
||||
.split(['?', '#'])
|
||||
.next()
|
||||
.unwrap_or(&remote_url)
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or("digital-human-output")
|
||||
.to_string()
|
||||
});
|
||||
let kind = output_kind(item).to_string();
|
||||
let input = WorkspaceAssetInput {
|
||||
user_id: auth.user_id,
|
||||
source: "digital-human".to_string(),
|
||||
kind: kind.clone(),
|
||||
title: Some(filename.chars().take(80).collect()),
|
||||
prompt: Some(script.chars().take(1000).collect()),
|
||||
model: Some("comfy-native".to_string()),
|
||||
remote_url: Some(remote_url.clone()),
|
||||
metadata: json!({
|
||||
"workspaceAsset": { "role": "output" },
|
||||
"digitalHuman": {
|
||||
"promptId": prompt_id,
|
||||
"filename": filename,
|
||||
"raw": item,
|
||||
}
|
||||
}),
|
||||
};
|
||||
return match workspace::record_remote_asset(state, input).await {
|
||||
Ok(asset) => Some(json!({
|
||||
"publicId": asset.public_id,
|
||||
"kind": kind,
|
||||
"filename": filename,
|
||||
"status": asset.status,
|
||||
"remoteUrl": asset.remote_url,
|
||||
"fileName": asset.file_name,
|
||||
})),
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, "failed to archive remote digital human output");
|
||||
Some(json!({
|
||||
"kind": kind,
|
||||
"filename": filename,
|
||||
"status": "failed",
|
||||
"remoteUrl": remote_url,
|
||||
"error": err.to_string(),
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
let params = output_query(item);
|
||||
let filename = params.get("filename")?.clone();
|
||||
let kind = output_kind(item).to_string();
|
||||
@@ -1225,7 +835,6 @@ async fn archive_output(
|
||||
model: Some("comfy-native".to_string()),
|
||||
remote_url: None,
|
||||
metadata: json!({
|
||||
"workspaceAsset": { "role": "output" },
|
||||
"digitalHuman": {
|
||||
"promptId": prompt_id,
|
||||
"filename": filename,
|
||||
@@ -1332,9 +941,6 @@ async fn create_job(
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let auth = resolve_auth(&headers, &state).await?;
|
||||
if let Some(key_id) = auth.key_id {
|
||||
UsageTracker::new(state.db.clone()).check_rate_limit(key_id).await?;
|
||||
}
|
||||
if !state.config.comfyui.uses_native_backend() || state.config.comfyui.uses_runninghub_backend()
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
@@ -1357,11 +963,8 @@ async fn create_job(
|
||||
let initial_result = json!({
|
||||
"userId": auth.user_id,
|
||||
"role": auth.role,
|
||||
"keyId": auth.key_id,
|
||||
"keyName": auth.key_name,
|
||||
"templateId": template_id,
|
||||
"templateName": workflow.name,
|
||||
"script": script,
|
||||
"audioRole": optional_string(&body, &["audioRole", "audio_role"]).unwrap_or_else(|| "both".to_string()),
|
||||
"request": body,
|
||||
"mapping": mapping,
|
||||
@@ -1383,14 +986,6 @@ async fn create_job(
|
||||
let task_id_for_spawn = task_id.clone();
|
||||
let auth_for_task = auth.clone();
|
||||
tokio::spawn(async move {
|
||||
let failure_body = body.clone();
|
||||
let failure_script = script.clone();
|
||||
let billing_model = optional_string(
|
||||
&failure_body,
|
||||
&["comfyApiModel", "comfy_api_model", "apiModel", "api_model", "model"],
|
||||
)
|
||||
.map(|value| normalize_video_model(&value))
|
||||
.unwrap_or_else(|| workflow.name.clone());
|
||||
let result = async {
|
||||
update_progress(&state_for_task, &task_id_for_spawn, 2, "正在提交 Comfy 工作流")
|
||||
.await?;
|
||||
@@ -1398,11 +993,8 @@ async fn create_job(
|
||||
let mid_result = json!({
|
||||
"userId": auth_for_task.user_id,
|
||||
"role": auth_for_task.role,
|
||||
"keyId": auth_for_task.key_id,
|
||||
"keyName": auth_for_task.key_name,
|
||||
"templateId": template_id,
|
||||
"templateName": workflow.name,
|
||||
"script": script,
|
||||
"promptId": prompt_id,
|
||||
"submitResponse": submit_response,
|
||||
"request": body,
|
||||
@@ -1422,15 +1014,7 @@ async fn create_job(
|
||||
}
|
||||
update_progress(&state_for_task, &task_id_for_spawn, 3, "Comfy 正在生成视频")
|
||||
.await?;
|
||||
let Some(history) = prompt_history_optional(&state_for_task, &prompt_id).await?
|
||||
else {
|
||||
tokio::time::sleep(Duration::from_secs(DIGITAL_HUMAN_POLL_INTERVAL_SECS))
|
||||
.await;
|
||||
continue;
|
||||
};
|
||||
if let Some(message) = prompt_failure_message(&history) {
|
||||
return Err(AppError::UpstreamError(message));
|
||||
}
|
||||
let history = prompt_history(&state_for_task, &prompt_id).await?;
|
||||
let outputs = output_objects(&history);
|
||||
if !outputs.is_empty() {
|
||||
update_progress(
|
||||
@@ -1454,62 +1038,17 @@ async fn create_job(
|
||||
assets.push(asset);
|
||||
}
|
||||
}
|
||||
let tracker = UsageTracker::new(state_for_task.db.clone());
|
||||
let billing_response = json!({
|
||||
"promptId": prompt_id,
|
||||
"history": history,
|
||||
"outputs": outputs,
|
||||
"assets": assets,
|
||||
});
|
||||
let billing = match tracker
|
||||
.record_comfy_task_success(comfy_task_event(
|
||||
&auth_for_task,
|
||||
billing_model.clone(),
|
||||
0,
|
||||
0,
|
||||
configured_task_cost(state_for_task.config.comfyui.digital_human_video_cost),
|
||||
&script,
|
||||
&body,
|
||||
Some(&billing_response),
|
||||
None,
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(details) => details,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
%err,
|
||||
task_id = %task_id_for_spawn,
|
||||
"failed to record digital human comfy billing",
|
||||
);
|
||||
json!({
|
||||
"source": "comfy-native-task",
|
||||
"taskType": TASK_TYPE_DIGITAL_HUMAN,
|
||||
"status": "success",
|
||||
"charged": false,
|
||||
"failed": true,
|
||||
"error": err.to_string(),
|
||||
"totalCost": 0.0,
|
||||
"steps": []
|
||||
})
|
||||
}
|
||||
};
|
||||
let final_result = json!({
|
||||
"userId": auth_for_task.user_id,
|
||||
"role": auth_for_task.role,
|
||||
"keyId": auth_for_task.key_id,
|
||||
"keyName": auth_for_task.key_name,
|
||||
"templateId": template_id,
|
||||
"templateName": workflow.name,
|
||||
"model": billing_model.clone(),
|
||||
"script": script,
|
||||
"promptId": prompt_id,
|
||||
"request": body,
|
||||
"mapping": mapping,
|
||||
"history": history,
|
||||
"outputs": outputs,
|
||||
"assets": assets,
|
||||
"billing": billing,
|
||||
});
|
||||
sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
@@ -1529,27 +1068,6 @@ async fn create_job(
|
||||
}
|
||||
.await;
|
||||
if let Err(err) = result {
|
||||
let tracker = UsageTracker::new(state_for_task.db.clone());
|
||||
if let Err(log_err) = tracker
|
||||
.record_comfy_task_failure(comfy_task_event(
|
||||
&auth_for_task,
|
||||
billing_model,
|
||||
0,
|
||||
0,
|
||||
0.0,
|
||||
&failure_script,
|
||||
&failure_body,
|
||||
None,
|
||||
Some(err.to_string()),
|
||||
))
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
%log_err,
|
||||
task_id = %task_id_for_spawn,
|
||||
"failed to record digital human comfy failure log",
|
||||
);
|
||||
}
|
||||
let _ = sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
SET status = 'failed', message = '数字人任务失败', error = ?, updated_at = datetime('now'), finished_at = datetime('now')
|
||||
@@ -1712,10 +1230,7 @@ async fn upload_asset(
|
||||
prompt: None,
|
||||
model: None,
|
||||
remote_url: None,
|
||||
metadata: json!({
|
||||
"workspaceAsset": { "role": "input" },
|
||||
"digitalHumanUpload": { "purpose": purpose, "fileName": file_name },
|
||||
}),
|
||||
metadata: json!({ "digitalHumanUpload": { "purpose": purpose, "fileName": file_name } }),
|
||||
};
|
||||
let asset = workspace::record_bytes_asset(&state, input, &bytes, &mime_type).await?;
|
||||
let file_url = format!(
|
||||
@@ -1735,121 +1250,3 @@ async fn upload_asset(
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn workflow_prompt_from_graph_converts_node_edge_workflow_to_comfy_prompt() {
|
||||
let graph = json!({
|
||||
"nodes": [
|
||||
{ "id": "1", "type": "LoadImage", "params": { "image": "avatar.png" } },
|
||||
{ "id": "2", "type": "VHS_VideoCombine", "params": { "filename_prefix": "digital-human" } }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "1.IMAGE", "to": "2.images" }
|
||||
]
|
||||
});
|
||||
|
||||
let prompt = workflow_prompt_from_graph(&graph);
|
||||
assert_eq!(prompt.pointer("/1/class_type").and_then(Value::as_str), Some("LoadImage"));
|
||||
assert_eq!(prompt.pointer("/2/class_type").and_then(Value::as_str), Some("VHS_VideoCombine"));
|
||||
assert_eq!(prompt.pointer("/2/inputs/images"), Some(&json!(["1", 0])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_objects_collects_cloud_and_native_media_shapes() {
|
||||
let history = json!({
|
||||
"data": {
|
||||
"outputs": [
|
||||
"https://cdn.example.com/avatar-final.mp4",
|
||||
{ "audioUrl": "https://cdn.example.com/voice.wav" }
|
||||
]
|
||||
},
|
||||
"outputs": {
|
||||
"9": {
|
||||
"images": [
|
||||
{ "filename": "cover.webp", "subfolder": "previews", "type": "output" }
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let outputs = output_objects(&history);
|
||||
assert_eq!(outputs.len(), 3);
|
||||
assert!(outputs.iter().any(|item| output_remote_url(item).as_deref() == Some("https://cdn.example.com/avatar-final.mp4")));
|
||||
assert!(outputs.iter().any(|item| output_kind(item) == "audio"));
|
||||
assert!(outputs.iter().any(|item| output_filename(item).as_deref() == Some("cover.webp")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_objects_deduplicates_repeated_remote_urls() {
|
||||
let history = json!({
|
||||
"outputs": {
|
||||
"1": {
|
||||
"videoUrl": "https://cdn.example.com/final.mp4",
|
||||
"videos": [
|
||||
{ "url": "https://cdn.example.com/final.mp4", "filename": "final.mp4" }
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let outputs = output_objects(&history);
|
||||
assert_eq!(outputs.len(), 1);
|
||||
assert_eq!(output_remote_url(&outputs[0]).as_deref(), Some("https://cdn.example.com/final.mp4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_polling_treats_not_ready_statuses_as_pending() {
|
||||
assert!(is_pending_history_status(StatusCode::NOT_FOUND));
|
||||
assert!(is_pending_history_status(StatusCode::ACCEPTED));
|
||||
assert!(is_pending_history_status(StatusCode::NO_CONTENT));
|
||||
assert!(!is_pending_history_status(StatusCode::OK));
|
||||
assert!(!is_pending_history_status(StatusCode::BAD_GATEWAY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_digital_human_inputs_writes_optional_comfy_api_model() {
|
||||
let mut prompt = json!({
|
||||
"1": { "class_type": "PromptTextNode", "inputs": { "prompt": "" } },
|
||||
"2": { "class_type": "TTSVoiceNode", "inputs": { "text": "" } },
|
||||
"3": { "class_type": "LoadImage", "inputs": { "image": "" } },
|
||||
"4": { "class_type": "LoadAudio", "inputs": { "audio": "" } },
|
||||
"5": { "class_type": "ByteDanceDigitalHumanNode", "inputs": { "model": "seedance-1-0-lite-t2v-250428" } }
|
||||
});
|
||||
let mapping = json!({
|
||||
"prompt": { "nodeId": "1", "field": "prompt" },
|
||||
"script": { "nodeId": "2", "field": "text" },
|
||||
"avatar": { "nodeId": "3", "field": "image" },
|
||||
"referenceAudio": { "nodeId": "4", "field": "audio" },
|
||||
"model": { "nodeId": "5", "field": "model" }
|
||||
});
|
||||
let request = json!({
|
||||
"avatarUrl": "https://cdn.example.com/avatar.png",
|
||||
"referenceAudioUrl": "https://cdn.example.com/voice.wav",
|
||||
"productScript": "介绍新款咖啡机",
|
||||
"prompt": "真人主播讲解商品",
|
||||
"audioRole": "both",
|
||||
"comfyApiModel": "seedance-1-0-lite-t2v"
|
||||
});
|
||||
|
||||
let script = inject_digital_human_inputs(&mut prompt, &mapping, &request)
|
||||
.expect("digital human inputs should be injected");
|
||||
|
||||
assert_eq!(script, "介绍新款咖啡机");
|
||||
assert_eq!(
|
||||
prompt.pointer("/5/inputs/model").and_then(Value::as_str),
|
||||
Some("seedance-1-0-lite-t2v-250428")
|
||||
);
|
||||
assert_eq!(
|
||||
prompt.pointer("/1/inputs/prompt").and_then(Value::as_str),
|
||||
Some("真人主播讲解商品")
|
||||
);
|
||||
assert_eq!(
|
||||
prompt.pointer("/3/inputs/image").and_then(Value::as_str),
|
||||
Some("https://cdn.example.com/avatar.png")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-671
@@ -1,23 +1,14 @@
|
||||
// Events routes — system event notifications
|
||||
|
||||
use crate::{
|
||||
db::models::event::Event,
|
||||
error::{AppError, AppResult},
|
||||
services::runtime_logs,
|
||||
AppState,
|
||||
};
|
||||
use crate::{db::models::event::Event, error::AppResult, AppState};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::HeaderMap,
|
||||
middleware,
|
||||
response::Response,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::{debug, info};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListEventsQuery {
|
||||
@@ -28,35 +19,12 @@ pub struct ListEventsQuery {
|
||||
read: Option<String>, // "true" | "false"
|
||||
}
|
||||
|
||||
pub fn routes(state: AppState) -> Router<AppState> {
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/events", get(list_events).delete(clear_events))
|
||||
.route("/api/events/count", get(unread_count))
|
||||
.route("/api/events/{id}/read", post(mark_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
|
||||
@@ -64,29 +32,20 @@ async fn list_events(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ListEventsQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
debug!("Listing events");
|
||||
info!("Listing events");
|
||||
let limit = q.limit.unwrap_or(30).clamp(1, 500);
|
||||
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 id, type, title, message, level, read, related_id, related_type, created_at FROM events WHERE 1=1".to_string();
|
||||
let mut sql = "SELECT * FROM events WHERE 1=1".to_string();
|
||||
let mut bind_idx = 0;
|
||||
let mut params: Vec<String> = vec![];
|
||||
|
||||
if let Some(ref t) = filter_type {
|
||||
sql.push_str(" AND type = ?");
|
||||
if let Some(ref t) = q.filter_type {
|
||||
bind_idx += 1;
|
||||
sql.push_str(&format!(" AND type = ?{}", bind_idx));
|
||||
params.push(t.clone());
|
||||
}
|
||||
if let Some(r) = read_filter {
|
||||
if let Some(ref r) = q.read {
|
||||
if r == "true" {
|
||||
sql.push_str(" AND read = 1");
|
||||
} else if r == "false" {
|
||||
@@ -94,304 +53,16 @@ async fn list_events(
|
||||
}
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY created_at DESC LIMIT 1000");
|
||||
sql.push_str(" ORDER BY created_at DESC");
|
||||
sql.push_str(&format!(" LIMIT {} OFFSET {}", limit, offset));
|
||||
|
||||
let mut query = sqlx::query_as::<_, Event>(&sql);
|
||||
for p in ¶ms {
|
||||
query = query.bind(p);
|
||||
}
|
||||
let events: Vec<Event> = query.fetch_all(&*state.db).await?;
|
||||
let mut rows: Vec<EventListItem> = events.into_iter().map(EventListItem::from).collect();
|
||||
|
||||
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)
|
||||
Ok(Json(json!({ "data": events })))
|
||||
}
|
||||
|
||||
/// GET /api/events/count — unread count
|
||||
@@ -427,334 +98,5 @@ async fn clear_events(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
sqlx::query("DELETE FROM events")
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
runtime_logs::clear();
|
||||
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")));
|
||||
}
|
||||
}
|
||||
|
||||
+31
-130
@@ -10,14 +10,9 @@ use include_dir::{include_dir, Dir};
|
||||
use serde_json::{json, Value};
|
||||
use std::convert::Infallible;
|
||||
use tower::service_fn;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
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 auth;
|
||||
@@ -27,6 +22,7 @@ mod comfyui_agent;
|
||||
mod digital_human;
|
||||
mod events;
|
||||
mod health;
|
||||
mod monitor;
|
||||
mod money_printer;
|
||||
mod oauth;
|
||||
mod organizations;
|
||||
@@ -36,6 +32,7 @@ mod search;
|
||||
mod site_announcements;
|
||||
pub(crate) mod stats;
|
||||
mod tasks;
|
||||
mod update_center;
|
||||
mod user;
|
||||
mod user_api_keys;
|
||||
#[path = "video_agent_legacy.rs"]
|
||||
@@ -61,8 +58,9 @@ pub fn build_router(state: AppState) -> Router {
|
||||
|
||||
let api_routes = api_routes
|
||||
.merge(auth::routes())
|
||||
.merge(events::routes(state.clone()))
|
||||
.merge(events::routes())
|
||||
.merge(checkin::routes())
|
||||
.merge(monitor::routes())
|
||||
.merge(oauth::routes())
|
||||
.merge(organizations::routes())
|
||||
.merge(search::routes())
|
||||
@@ -70,6 +68,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(workflow_ui::routes())
|
||||
.merge(stats::routes())
|
||||
.merge(tasks::routes())
|
||||
.merge(update_center::routes())
|
||||
.merge(user::routes())
|
||||
.merge(user_api_keys::routes())
|
||||
.merge(workspace::routes())
|
||||
@@ -77,42 +76,40 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.with_state(state);
|
||||
|
||||
let router = Router::new().merge(api_routes);
|
||||
let router = match std::env::var("BOOSAPI_WEB_DIR")
|
||||
match std::env::var("BOOSAPI_WEB_DIR")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
Some(web_dir) => {
|
||||
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 web_dir_owned = web_dir.clone();
|
||||
let static_service = ServeDir::new(&web_dir)
|
||||
.append_index_html_on_directories(true)
|
||||
.fallback(service_fn(move |req: Request<Body>| {
|
||||
let web_dir = web_dir.clone();
|
||||
let website_dir = website_dir.clone();
|
||||
let web_dir = web_dir_owned.clone();
|
||||
async move {
|
||||
let path = req.uri().path();
|
||||
if is_api_path(path) {
|
||||
return Ok::<_, Infallible>(api_not_found_response(path));
|
||||
}
|
||||
|
||||
Ok::<_, Infallible>(filesystem_static_response(
|
||||
path,
|
||||
&web_dir,
|
||||
&website_dir,
|
||||
)
|
||||
.await)
|
||||
let index_path = format!("{}/index.html", web_dir);
|
||||
match tokio::fs::read_to_string(&index_path).await {
|
||||
Ok(html) => Ok::<_, Infallible>(html_response(html)),
|
||||
Err(_) => Ok::<_, Infallible>(
|
||||
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()),
|
||||
),
|
||||
}
|
||||
}
|
||||
}));
|
||||
router.fallback_service(static_service)
|
||||
}
|
||||
None => router.fallback(embedded_static_handler),
|
||||
};
|
||||
|
||||
router.layer(CompressionLayer::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn api_not_found_response(path: &str) -> Response {
|
||||
@@ -125,49 +122,6 @@ fn api_not_found_response(path: &str) -> 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 {
|
||||
let path = req.uri().path();
|
||||
if is_api_path(path) {
|
||||
@@ -175,64 +129,27 @@ async fn embedded_static_handler(req: Request<Body>) -> Response {
|
||||
}
|
||||
|
||||
let asset_path = embedded_asset_path(path);
|
||||
let static_dir = if should_serve_website(path) {
|
||||
&EMBEDDED_WEBSITE
|
||||
} else {
|
||||
&EMBEDDED_WEB
|
||||
};
|
||||
|
||||
if let Some(file) = static_dir.get_file(&asset_path) {
|
||||
if let Some(file) = EMBEDDED_WEB.get_file(&asset_path) {
|
||||
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") {
|
||||
return embedded_file_response("index.html", file.contents());
|
||||
}
|
||||
}
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||
.body(Body::from(
|
||||
"embedded frontend index.html not found; rebuild with ./build-and-run.sh --production or set BOOSAPI_WEB_DIR/BOOSAPI_WEBSITE_DIR",
|
||||
))
|
||||
.body(Body::from("embedded dist/web/index.html not found"))
|
||||
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
|
||||
}
|
||||
|
||||
fn is_api_path(path: &str) -> bool {
|
||||
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"
|
||||
path.starts_with("/api/") || path.starts_with("/v1/")
|
||||
}
|
||||
|
||||
fn embedded_asset_path(path: &str) -> String {
|
||||
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('/') {
|
||||
format!("{trimmed}index.html")
|
||||
} else {
|
||||
@@ -240,6 +157,14 @@ 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 {
|
||||
let content_type = mime_guess::from_path(path)
|
||||
.first_or_octet_stream()
|
||||
@@ -272,27 +197,3 @@ 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"));
|
||||
}
|
||||
}
|
||||
|
||||
+52
-882
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,297 @@
|
||||
// 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/")
|
||||
}
|
||||
+115
-4204
File diff suppressed because it is too large
Load Diff
@@ -1,155 +0,0 @@
|
||||
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,
|
||||
));
|
||||
}
|
||||
}
|
||||
+116
-25
@@ -18,11 +18,10 @@ use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::time::Duration;
|
||||
|
||||
const TEMPLATE_GALLERY_PAGE_URL: &str =
|
||||
"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 TEMPLATE_GALLERY_URL: &str =
|
||||
"https://weiyyin.github.io/linux-do-gpt-image/linux_do_2036927_prompts_images.filtered.json";
|
||||
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_RETRY_SECS: u64 = 180;
|
||||
const STALE_RUNNING_JOB_MINUTES: i64 = 12;
|
||||
@@ -32,7 +31,6 @@ struct PictureAgentAuth {
|
||||
user_id: Option<i64>,
|
||||
role: String,
|
||||
outbound_key: String,
|
||||
key_id: Option<i64>,
|
||||
key_name: String,
|
||||
}
|
||||
|
||||
@@ -124,13 +122,11 @@ async fn resolve_picture_agent_auth(
|
||||
let token = extract_bearer_token(headers)
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||
|
||||
let proxy_token = state.runtime_proxy_token();
|
||||
if token == state.config.auth.auth_token || token == proxy_token {
|
||||
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
|
||||
return Ok(PictureAgentAuth {
|
||||
user_id: None,
|
||||
role: "admin".to_string(),
|
||||
outbound_key: proxy_token,
|
||||
key_id: None,
|
||||
outbound_key: state.config.auth.proxy_token.clone(),
|
||||
key_name: "global-proxy".to_string(),
|
||||
});
|
||||
}
|
||||
@@ -151,14 +147,12 @@ async fn resolve_picture_agent_auth(
|
||||
user_id: Some(user_id),
|
||||
role: claims.role,
|
||||
outbound_key: key.key,
|
||||
key_id: Some(key.id),
|
||||
key_name: key.name,
|
||||
}),
|
||||
Err(_) if claims.role == "admin" => Ok(PictureAgentAuth {
|
||||
user_id: Some(user_id),
|
||||
role: claims.role,
|
||||
outbound_key: state.runtime_proxy_token(),
|
||||
key_id: None,
|
||||
outbound_key: state.config.auth.proxy_token.clone(),
|
||||
key_name: "global-proxy".to_string(),
|
||||
}),
|
||||
Err(message) => Err(AppError::Forbidden(message)),
|
||||
@@ -291,7 +285,6 @@ mod tests {
|
||||
assert!(!is_picture_model_pattern("deepseek-chat"));
|
||||
assert!(!is_picture_model_pattern("qwen-plus"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn prompt_title(prompt: &str, post_number: Option<i64>) -> String {
|
||||
@@ -351,28 +344,50 @@ async fn templates_handler(
|
||||
) -> AppResult<Json<Value>> {
|
||||
let _auth = resolve_picture_agent_auth(&headers, &state).await?;
|
||||
let limit = query.limit.unwrap_or(144).clamp(1, 240);
|
||||
let (templates, local_status) = match parse_gallery_templates(LINUX_DO_TEMPLATE_JSON, limit) {
|
||||
Ok(templates) => (templates, json!({ "ok": true, "source": "local-json" })),
|
||||
let mut templates = builtin_picture_templates(limit);
|
||||
let remote_status = match fetch_remote_gallery_templates(
|
||||
&state.config,
|
||||
limit.saturating_sub(templates.len()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mut remote_templates) => {
|
||||
templates.append(&mut remote_templates);
|
||||
json!({ "ok": true })
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, "Picture Agent local Linux Do template gallery unavailable");
|
||||
(Vec::new(), json!({ "ok": false, "message": err }))
|
||||
tracing::warn!(%err, "Picture Agent template gallery unavailable; using built-in templates");
|
||||
json!({ "ok": false, "message": err })
|
||||
}
|
||||
};
|
||||
templates.truncate(limit);
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"sourceUrl": TEMPLATE_GALLERY_PAGE_URL,
|
||||
"source": local_status,
|
||||
"sourceUrl": "https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html",
|
||||
"fallback": remote_status.get("ok").and_then(Value::as_bool) != Some(true),
|
||||
"remote": remote_status,
|
||||
"templates": templates,
|
||||
})))
|
||||
}
|
||||
|
||||
fn parse_gallery_templates(raw: &str, limit: usize) -> Result<Vec<Value>, String> {
|
||||
async fn fetch_remote_gallery_templates(
|
||||
config: &crate::config::AppConfig,
|
||||
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}"))?;
|
||||
let items = crate::template_http::client(config, TEMPLATE_GALLERY_TIMEOUT_SECS)
|
||||
.get(TEMPLATE_GALLERY_URL)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("failed to fetch templates: {err}"))?
|
||||
.error_for_status()
|
||||
.map_err(|err| format!("template gallery returned {err}"))?
|
||||
.json::<Vec<GalleryItem>>()
|
||||
.await
|
||||
.map_err(|err| format!("failed to parse templates: {err}"))?;
|
||||
|
||||
Ok(items
|
||||
.into_iter()
|
||||
@@ -402,7 +417,7 @@ fn parse_gallery_templates(raw: &str, limit: usize) -> Result<Vec<Value>, String
|
||||
"prompt": prompt,
|
||||
"source": {
|
||||
"name": "Linux Do GPT Image Gallery",
|
||||
"url": TEMPLATE_GALLERY_PAGE_URL,
|
||||
"url": "https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html",
|
||||
"postNumber": post_number,
|
||||
"username": item.username,
|
||||
},
|
||||
@@ -412,6 +427,84 @@ fn parse_gallery_templates(raw: &str, limit: usize) -> Result<Vec<Value>, String
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn builtin_picture_templates(limit: usize) -> Vec<Value> {
|
||||
[
|
||||
(
|
||||
"boosapi-product-glass-robot",
|
||||
"玻璃机器人产品照",
|
||||
"商业",
|
||||
"1024x1024",
|
||||
"A precise editorial product photo of a translucent glass robot painting a small neon city, sharp focus, clean background, controlled studio lighting, premium advertising composition, ultra detailed",
|
||||
),
|
||||
(
|
||||
"boosapi-hanfu-cat-golden-hour",
|
||||
"汉服与白猫",
|
||||
"国风",
|
||||
"1024x1536",
|
||||
"A breathtakingly beautiful ancient Chinese woman in flowing jade-green and blush pink hanfu, golden peony embroidery, sitting by a moon-shaped window in warm afternoon sunlight, gently teasing a fluffy white cat with a silk tassel, celadon tea set and ink calligraphy nearby, serene romantic dreamlike Song Dynasty aesthetics, cinematic depth of field, ultra detailed digital art",
|
||||
),
|
||||
(
|
||||
"boosapi-cyberpunk-street",
|
||||
"赛博街景海报",
|
||||
"海报",
|
||||
"1536x1024",
|
||||
"A cinematic cyberpunk street market after rain, neon signs reflected on wet pavement, a courier in a translucent raincoat walking through steam, layered storefronts and holographic menus, dramatic teal and amber lighting, editorial poster composition, high detail",
|
||||
),
|
||||
(
|
||||
"boosapi-character-turnaround",
|
||||
"角色四视图",
|
||||
"角色",
|
||||
"1536x1024",
|
||||
"Character design sheet, same original fantasy explorer shown in front view, side view, back view and three-quarter view, consistent outfit and proportions, clean light background, readable silhouette, detailed fabric and accessories, professional concept art",
|
||||
),
|
||||
(
|
||||
"boosapi-food-photography",
|
||||
"高级餐饮摄影",
|
||||
"商业",
|
||||
"1024x1024",
|
||||
"Premium food photography of a porcelain bowl of hand-pulled noodles with chili oil, scallions and steam, dark stone tabletop, soft side lighting, shallow depth of field, appetizing texture, refined restaurant campaign style",
|
||||
),
|
||||
(
|
||||
"boosapi-architecture-interior",
|
||||
"现代茶室空间",
|
||||
"空间",
|
||||
"1536x1024",
|
||||
"A quiet contemporary Chinese tea room interior with warm timber lattice, low stone tea table, linen cushions, bamboo courtyard visible through sliding doors, natural morning light, calm luxury hospitality design, realistic architectural visualization",
|
||||
),
|
||||
(
|
||||
"boosapi-kids-book-dragon",
|
||||
"绘本小龙",
|
||||
"插画",
|
||||
"1024x1024",
|
||||
"A gentle children's book illustration of a tiny green dragon learning to fly above a village garden, soft watercolor texture, expressive friendly eyes, warm pastel palette, charming composition, lots of small story details",
|
||||
),
|
||||
(
|
||||
"boosapi-film-still-office",
|
||||
"悬疑电影帧",
|
||||
"电影",
|
||||
"1536x1024",
|
||||
"A suspenseful cinematic film still in a dim modern office at night, a figure with a glowing screen-like face stands motionless near frosted glass, subtle volumetric light, muted colors, realistic lens depth, unsettling quiet atmosphere",
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|(id, title, tag, size, prompt)| {
|
||||
json!({
|
||||
"id": id,
|
||||
"title": title,
|
||||
"tag": tag,
|
||||
"size": size,
|
||||
"image": Value::Null,
|
||||
"prompt": prompt,
|
||||
"source": {
|
||||
"name": "BossAI 内置模板",
|
||||
"url": Value::Null,
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn picture_generation_client() -> Client {
|
||||
picture_generation_client_with_timeout(PICTURE_GENERATION_TIMEOUT_SECS)
|
||||
}
|
||||
@@ -660,7 +753,6 @@ async fn create_generation_job(
|
||||
let initial_result = json!({
|
||||
"userId": auth.user_id,
|
||||
"role": auth.role,
|
||||
"keyId": auth.key_id,
|
||||
"keyName": auth.key_name,
|
||||
"prompt": prompt,
|
||||
"model": model,
|
||||
@@ -742,7 +834,6 @@ async fn create_generation_job(
|
||||
let final_result = json!({
|
||||
"userId": auth_for_task.user_id,
|
||||
"role": auth_for_task.role,
|
||||
"keyId": auth_for_task.key_id,
|
||||
"keyName": auth_for_task.key_name,
|
||||
"prompt": prompt,
|
||||
"model": model,
|
||||
|
||||
+335
-765
File diff suppressed because it is too large
Load Diff
+56
-700
@@ -8,8 +8,8 @@ use axum::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{QueryBuilder, Row, Sqlite};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use sqlx::Row;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -25,333 +25,6 @@ pub struct SiteDistributionQuery {
|
||||
_refresh: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ProxyDebugTraceQuery {
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
session_id: Option<String>,
|
||||
model: Option<String>,
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum ProxyLogClientFilter {
|
||||
App(String),
|
||||
Family(String),
|
||||
Raw(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct ProxyLogFilters {
|
||||
status: Option<String>,
|
||||
search_like: Option<String>,
|
||||
client: Option<ProxyLogClientFilter>,
|
||||
site_id: Option<i64>,
|
||||
downstream_key_id: Option<i64>,
|
||||
from: Option<String>,
|
||||
to: Option<String>,
|
||||
}
|
||||
|
||||
fn normalize_proxy_log_status(value: Option<&String>) -> Option<String> {
|
||||
match value
|
||||
.map(|item| item.trim().to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("success") => Some("success".to_string()),
|
||||
Some("failed") => Some("failed".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_positive_i64(value: Option<&String>) -> Option<i64> {
|
||||
value
|
||||
.and_then(|item| item.trim().parse::<i64>().ok())
|
||||
.filter(|item| *item > 0)
|
||||
}
|
||||
|
||||
fn normalize_non_empty(value: Option<&String>) -> Option<String> {
|
||||
value
|
||||
.map(|item| item.trim())
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn parse_proxy_log_client_filter(value: Option<&String>) -> Option<ProxyLogClientFilter> {
|
||||
let text = normalize_non_empty(value)?;
|
||||
if let Some((scope, token)) = text.split_once(':') {
|
||||
let token = token.trim();
|
||||
if !token.is_empty() {
|
||||
return match scope.trim().to_ascii_lowercase().as_str() {
|
||||
"app" => Some(ProxyLogClientFilter::App(token.to_string())),
|
||||
"family" => Some(ProxyLogClientFilter::Family(token.to_ascii_lowercase())),
|
||||
_ => Some(ProxyLogClientFilter::Raw(text)),
|
||||
};
|
||||
}
|
||||
}
|
||||
Some(ProxyLogClientFilter::Raw(text))
|
||||
}
|
||||
|
||||
fn parse_proxy_log_filters(q: &std::collections::HashMap<String, String>) -> ProxyLogFilters {
|
||||
let search = normalize_non_empty(q.get("search").or_else(|| q.get("q")));
|
||||
|
||||
ProxyLogFilters {
|
||||
status: normalize_proxy_log_status(q.get("status")),
|
||||
search_like: search.map(|value| format!("%{value}%")),
|
||||
client: parse_proxy_log_client_filter(q.get("client")),
|
||||
site_id: normalize_positive_i64(q.get("siteId").or_else(|| q.get("site_id"))),
|
||||
downstream_key_id: normalize_positive_i64(
|
||||
q.get("downstreamKeyId")
|
||||
.or_else(|| q.get("downstream_key_id"))
|
||||
.or_else(|| q.get("keyId"))
|
||||
.or_else(|| q.get("key_id")),
|
||||
),
|
||||
from: normalize_non_empty(q.get("from")),
|
||||
to: normalize_non_empty(q.get("to")),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_proxy_log_condition_prefix(
|
||||
builder: &mut QueryBuilder<'_, Sqlite>,
|
||||
condition_count: &mut usize,
|
||||
) {
|
||||
builder.push(if *condition_count == 0 {
|
||||
" WHERE "
|
||||
} else {
|
||||
" AND "
|
||||
});
|
||||
*condition_count += 1;
|
||||
}
|
||||
|
||||
fn push_proxy_log_raw_condition(
|
||||
builder: &mut QueryBuilder<'_, Sqlite>,
|
||||
condition_count: &mut usize,
|
||||
condition: &str,
|
||||
) {
|
||||
push_proxy_log_condition_prefix(builder, condition_count);
|
||||
builder.push(condition);
|
||||
}
|
||||
|
||||
fn push_proxy_log_where(
|
||||
builder: &mut QueryBuilder<'_, Sqlite>,
|
||||
filters: &ProxyLogFilters,
|
||||
) -> usize {
|
||||
let mut condition_count = 0;
|
||||
|
||||
if let Some(status) = &filters.status {
|
||||
push_proxy_log_condition_prefix(builder, &mut condition_count);
|
||||
builder.push("l.status = ");
|
||||
builder.push_bind(status.clone());
|
||||
}
|
||||
|
||||
if let Some(search_like) = &filters.search_like {
|
||||
push_proxy_log_condition_prefix(builder, &mut condition_count);
|
||||
builder.push("(l.model_requested LIKE ");
|
||||
builder.push_bind(search_like.clone());
|
||||
builder.push(" OR l.model_actual LIKE ");
|
||||
builder.push_bind(search_like.clone());
|
||||
builder.push(" OR l.error_message LIKE ");
|
||||
builder.push_bind(search_like.clone());
|
||||
builder.push(" OR l.content_summary LIKE ");
|
||||
builder.push_bind(search_like.clone());
|
||||
builder.push(" OR dk.name LIKE ");
|
||||
builder.push_bind(search_like.clone());
|
||||
builder.push(" OR dk.group_name LIKE ");
|
||||
builder.push_bind(search_like.clone());
|
||||
builder.push(" OR dk.tags LIKE ");
|
||||
builder.push_bind(search_like.clone());
|
||||
builder.push(" OR a.username LIKE ");
|
||||
builder.push_bind(search_like.clone());
|
||||
builder.push(" OR s.name LIKE ");
|
||||
builder.push_bind(search_like.clone());
|
||||
builder.push(")");
|
||||
}
|
||||
|
||||
if let Some(client) = &filters.client {
|
||||
push_proxy_log_condition_prefix(builder, &mut condition_count);
|
||||
match client {
|
||||
ProxyLogClientFilter::App(value) => {
|
||||
builder.push("(l.client_app_id = ");
|
||||
builder.push_bind(value.clone());
|
||||
builder.push(" OR l.client_app_name = ");
|
||||
builder.push_bind(value.clone());
|
||||
builder.push(")");
|
||||
}
|
||||
ProxyLogClientFilter::Family(value) => {
|
||||
builder.push("LOWER(l.client_family) = ");
|
||||
builder.push_bind(value.clone());
|
||||
}
|
||||
ProxyLogClientFilter::Raw(value) => {
|
||||
builder.push("(l.client_app_id = ");
|
||||
builder.push_bind(value.clone());
|
||||
builder.push(" OR l.client_family = ");
|
||||
builder.push_bind(value.clone());
|
||||
builder.push(" OR l.client_app_name = ");
|
||||
builder.push_bind(value.clone());
|
||||
builder.push(")");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(site_id) = filters.site_id {
|
||||
push_proxy_log_condition_prefix(builder, &mut condition_count);
|
||||
builder.push("a.site_id = ");
|
||||
builder.push_bind(site_id);
|
||||
}
|
||||
|
||||
if let Some(downstream_key_id) = filters.downstream_key_id {
|
||||
push_proxy_log_condition_prefix(builder, &mut condition_count);
|
||||
builder.push("l.downstream_api_key_id = ");
|
||||
builder.push_bind(downstream_key_id);
|
||||
}
|
||||
|
||||
if let Some(from) = &filters.from {
|
||||
push_proxy_log_condition_prefix(builder, &mut condition_count);
|
||||
builder.push("datetime(l.created_at) >= datetime(");
|
||||
builder.push_bind(from.clone());
|
||||
builder.push(")");
|
||||
}
|
||||
|
||||
if let Some(to) = &filters.to {
|
||||
push_proxy_log_condition_prefix(builder, &mut condition_count);
|
||||
builder.push("datetime(l.created_at) <= datetime(");
|
||||
builder.push_bind(to.clone());
|
||||
builder.push(")");
|
||||
}
|
||||
|
||||
condition_count
|
||||
}
|
||||
|
||||
fn push_proxy_log_joined_from(builder: &mut QueryBuilder<'_, Sqlite>) {
|
||||
builder.push(
|
||||
r#"
|
||||
FROM proxy_logs l
|
||||
LEFT JOIN accounts a ON a.id = l.account_id
|
||||
LEFT JOIN sites s ON s.id = a.site_id
|
||||
LEFT JOIN downstream_api_keys dk ON dk.id = l.downstream_api_key_id
|
||||
"#,
|
||||
);
|
||||
}
|
||||
|
||||
fn normalize_owned_non_empty(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|item| item.trim().to_string())
|
||||
.filter(|item| !item.is_empty())
|
||||
}
|
||||
|
||||
fn proxy_log_client_family_label(value: &str) -> String {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"codex" => "Codex".to_string(),
|
||||
"claude_code" => "Claude Code".to_string(),
|
||||
"gemini_cli" => "Gemini CLI".to_string(),
|
||||
"generic" => "通用".to_string(),
|
||||
_ => value.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_proxy_log_client_option(
|
||||
options: &mut Vec<Value>,
|
||||
seen: &mut HashSet<String>,
|
||||
value: String,
|
||||
label: String,
|
||||
) {
|
||||
if seen.insert(value.clone()) {
|
||||
options.push(json!({ "value": value, "label": label }));
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_proxy_log_client_options(
|
||||
state: &AppState,
|
||||
filters: &ProxyLogFilters,
|
||||
) -> AppResult<Vec<Value>> {
|
||||
let mut scoped = filters.clone();
|
||||
scoped.client = None;
|
||||
|
||||
let mut options = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
let mut app_query =
|
||||
QueryBuilder::<Sqlite>::new("SELECT DISTINCT l.client_app_id, l.client_app_name ");
|
||||
push_proxy_log_joined_from(&mut app_query);
|
||||
let mut condition_count = push_proxy_log_where(&mut app_query, &scoped);
|
||||
push_proxy_log_raw_condition(
|
||||
&mut app_query,
|
||||
&mut condition_count,
|
||||
"((l.client_app_id IS NOT NULL AND TRIM(l.client_app_id) <> '') OR (l.client_app_name IS NOT NULL AND TRIM(l.client_app_name) <> ''))",
|
||||
);
|
||||
app_query.push(
|
||||
" ORDER BY LOWER(COALESCE(NULLIF(l.client_app_name, ''), l.client_app_id)) ASC LIMIT 200",
|
||||
);
|
||||
|
||||
let app_rows: Vec<(Option<String>, Option<String>)> =
|
||||
app_query.build_query_as().fetch_all(&*state.db).await?;
|
||||
for (client_app_id, client_app_name) in app_rows {
|
||||
let app_id = normalize_owned_non_empty(client_app_id);
|
||||
let app_name = normalize_owned_non_empty(client_app_name);
|
||||
let Some(token) = app_id.clone().or_else(|| app_name.clone()) else {
|
||||
continue;
|
||||
};
|
||||
let label_name = app_name.or(app_id).unwrap_or_else(|| token.clone());
|
||||
push_proxy_log_client_option(
|
||||
&mut options,
|
||||
&mut seen,
|
||||
format!("app:{token}"),
|
||||
format!("应用 · {label_name}"),
|
||||
);
|
||||
}
|
||||
|
||||
let mut family_query = QueryBuilder::<Sqlite>::new("SELECT DISTINCT l.client_family ");
|
||||
push_proxy_log_joined_from(&mut family_query);
|
||||
let mut condition_count = push_proxy_log_where(&mut family_query, &scoped);
|
||||
push_proxy_log_raw_condition(
|
||||
&mut family_query,
|
||||
&mut condition_count,
|
||||
"l.client_family IS NOT NULL AND TRIM(l.client_family) <> ''",
|
||||
);
|
||||
family_query.push(" ORDER BY LOWER(l.client_family) ASC LIMIT 200");
|
||||
|
||||
let family_rows: Vec<(Option<String>,)> =
|
||||
family_query.build_query_as().fetch_all(&*state.db).await?;
|
||||
for (client_family,) in family_rows {
|
||||
let Some(family) = normalize_owned_non_empty(client_family) else {
|
||||
continue;
|
||||
};
|
||||
let token = family.to_ascii_lowercase();
|
||||
push_proxy_log_client_option(
|
||||
&mut options,
|
||||
&mut seen,
|
||||
format!("family:{token}"),
|
||||
format!("协议 · {}", proxy_log_client_family_label(&family)),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
async fn build_proxy_log_site_options(
|
||||
state: &AppState,
|
||||
filters: &ProxyLogFilters,
|
||||
) -> AppResult<Vec<Value>> {
|
||||
let mut scoped = filters.clone();
|
||||
scoped.site_id = None;
|
||||
|
||||
let mut site_query = QueryBuilder::<Sqlite>::new(
|
||||
"SELECT DISTINCT s.id, COALESCE(NULLIF(s.name, ''), '站点 #' || s.id) AS name, s.status ",
|
||||
);
|
||||
push_proxy_log_joined_from(&mut site_query);
|
||||
let mut condition_count = push_proxy_log_where(&mut site_query, &scoped);
|
||||
push_proxy_log_raw_condition(&mut site_query, &mut condition_count, "s.id IS NOT NULL");
|
||||
site_query
|
||||
.push(" ORDER BY LOWER(COALESCE(NULLIF(s.name, ''), '站点 #' || s.id)) ASC LIMIT 200");
|
||||
|
||||
let rows: Vec<(i64, String, Option<String>)> =
|
||||
site_query.build_query_as().fetch_all(&*state.db).await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, name, status)| json!({ "id": id, "name": name, "status": status }))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/stats/dashboard", get(dashboard))
|
||||
@@ -372,45 +45,6 @@ async fn dashboard(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<std::collections::HashMap<String, String>>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
match q.get("view").map(String::as_str) {
|
||||
Some("summary") => return Ok(Json(build_dashboard_summary(&state).await?)),
|
||||
Some("insights") => {
|
||||
let site_availability = build_site_availability(&state).await?;
|
||||
let model_analysis = build_model_analysis(&state, 7).await?;
|
||||
return Ok(Json(json!({
|
||||
"generatedAt": chrono::Utc::now().to_rfc3339(),
|
||||
"insights": [],
|
||||
"siteAvailability": site_availability,
|
||||
"modelAnalysis": model_analysis,
|
||||
})));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let summary = build_dashboard_summary(&state).await?;
|
||||
let site_availability = build_site_availability(&state).await?;
|
||||
let model_analysis = build_model_analysis(&state, 7).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"generatedAt": summary["generatedAt"].clone(),
|
||||
"totalBalance": summary["totalBalance"].clone(),
|
||||
"totalUsed": summary["totalUsed"].clone(),
|
||||
"todaySpend": summary["todaySpend"].clone(),
|
||||
"todayReward": summary["todayReward"].clone(),
|
||||
"activeAccounts": summary["activeAccounts"].clone(),
|
||||
"totalAccounts": summary["totalAccounts"].clone(),
|
||||
"todayCheckin": summary["todayCheckin"].clone(),
|
||||
"proxy24h": summary["proxy24h"].clone(),
|
||||
"performance": summary["performance"].clone(),
|
||||
"siteAvailability": site_availability,
|
||||
"modelAnalysis": model_analysis,
|
||||
"summary": summary["summary"].clone(),
|
||||
"insights": [],
|
||||
"stats": summary["stats"].clone(),
|
||||
})))
|
||||
}
|
||||
|
||||
async fn build_dashboard_summary(state: &AppState) -> AppResult<Value> {
|
||||
let sites: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sites")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
@@ -491,8 +125,10 @@ async fn build_dashboard_summary(state: &AppState) -> AppResult<Value> {
|
||||
)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let site_availability = build_site_availability(&state).await?;
|
||||
let model_analysis = build_model_analysis(&state, 7).await?;
|
||||
|
||||
Ok(json!({
|
||||
let payload = json!({
|
||||
"generatedAt": chrono::Utc::now().to_rfc3339(),
|
||||
"totalBalance": total_balance,
|
||||
"totalUsed": total_used,
|
||||
@@ -514,6 +150,8 @@ async fn build_dashboard_summary(state: &AppState) -> AppResult<Value> {
|
||||
"requestsPerMinute": perf_requests as f64 * 60.0 / perf_window_secs as f64,
|
||||
"tokensPerMinute": perf_tokens as f64 * 60.0 / perf_window_secs as f64,
|
||||
},
|
||||
"siteAvailability": site_availability,
|
||||
"modelAnalysis": model_analysis,
|
||||
"summary": {
|
||||
"sites": sites,
|
||||
"accounts": accounts,
|
||||
@@ -531,7 +169,31 @@ async fn build_dashboard_summary(state: &AppState) -> AppResult<Value> {
|
||||
"successRequests": success_24h,
|
||||
"totalCost": total_cost_24h,
|
||||
},
|
||||
}))
|
||||
});
|
||||
|
||||
match q.get("view").map(String::as_str) {
|
||||
Some("summary") => Ok(Json(json!({
|
||||
"generatedAt": payload["generatedAt"],
|
||||
"summary": payload["summary"],
|
||||
"stats": payload["stats"],
|
||||
"totalBalance": payload["totalBalance"],
|
||||
"totalUsed": payload["totalUsed"],
|
||||
"todaySpend": payload["todaySpend"],
|
||||
"todayReward": payload["todayReward"],
|
||||
"activeAccounts": payload["activeAccounts"],
|
||||
"totalAccounts": payload["totalAccounts"],
|
||||
"todayCheckin": payload["todayCheckin"],
|
||||
"proxy24h": payload["proxy24h"],
|
||||
"performance": payload["performance"],
|
||||
}))),
|
||||
Some("insights") => Ok(Json(json!({
|
||||
"generatedAt": payload["generatedAt"],
|
||||
"insights": payload["insights"],
|
||||
"siteAvailability": payload["siteAvailability"],
|
||||
"modelAnalysis": payload["modelAnalysis"],
|
||||
}))),
|
||||
_ => Ok(Json(payload)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_model_analysis(state: &AppState, days: i64) -> AppResult<Value> {
|
||||
@@ -735,15 +397,8 @@ async fn proxy_logs(
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
let view = q.get("view").map(String::as_str);
|
||||
let meta_only = matches!(view, Some("meta"));
|
||||
let include_meta = !matches!(view, Some("query"));
|
||||
let filters = parse_proxy_log_filters(&q);
|
||||
|
||||
let items: Vec<Value> = if meta_only {
|
||||
Vec::new()
|
||||
} else {
|
||||
let mut list_query = QueryBuilder::<Sqlite>::new(
|
||||
let items: Vec<Value> = sqlx::query_as::<_, ProxyLogRow>(
|
||||
r#"
|
||||
SELECT
|
||||
l.id, l.route_id, l.channel_id, l.account_id, l.downstream_api_key_id,
|
||||
@@ -752,7 +407,6 @@ async fn proxy_logs(
|
||||
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
|
||||
l.error_message, l.retry_count, l.is_stream,
|
||||
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
|
||||
l.billing_details, l.content_summary, l.request_content, l.response_content,
|
||||
l.created_at,
|
||||
a.site_id AS site_id,
|
||||
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
|
||||
@@ -761,51 +415,29 @@ async fn proxy_logs(
|
||||
dk.name AS downstream_key_name,
|
||||
dk.group_name AS downstream_key_group_name,
|
||||
dk.tags AS downstream_key_tags
|
||||
"#,
|
||||
);
|
||||
push_proxy_log_joined_from(&mut list_query);
|
||||
push_proxy_log_where(&mut list_query, &filters);
|
||||
list_query.push(
|
||||
r#"
|
||||
FROM proxy_logs l
|
||||
LEFT JOIN accounts a ON a.id = l.account_id
|
||||
LEFT JOIN sites s ON s.id = a.site_id
|
||||
LEFT JOIN downstream_api_keys dk ON dk.id = l.downstream_api_key_id
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"#,
|
||||
);
|
||||
list_query.push(" LIMIT ");
|
||||
list_query.push_bind(limit);
|
||||
list_query.push(" OFFSET ");
|
||||
list_query.push_bind(offset);
|
||||
|
||||
list_query
|
||||
.build_query_as::<ProxyLogRow>()
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| serde_json::to_value(row).unwrap_or_else(|_| json!({})))
|
||||
.collect()
|
||||
};
|
||||
.collect();
|
||||
|
||||
let mut summary_query = QueryBuilder::<Sqlite>::new(
|
||||
r#"
|
||||
SELECT
|
||||
COUNT(l.id) AS total,
|
||||
COALESCE(SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END), 0) AS success,
|
||||
COALESCE(SUM(CASE WHEN l.status != 'success' OR l.status IS NULL THEN 1 ELSE 0 END), 0) AS failed,
|
||||
COALESCE(SUM(l.estimated_cost), 0.0) AS cost,
|
||||
COALESCE(SUM(l.total_tokens), 0) AS total_tokens
|
||||
"#,
|
||||
);
|
||||
push_proxy_log_joined_from(&mut summary_query);
|
||||
push_proxy_log_where(&mut summary_query, &filters);
|
||||
let (total, success, failed, cost, total_tokens): (i64, i64, i64, f64, i64) =
|
||||
summary_query.build_query_as().fetch_one(&*state.db).await?;
|
||||
let (client_options, sites) = if include_meta {
|
||||
(
|
||||
build_proxy_log_client_options(&state, &filters).await?,
|
||||
build_proxy_log_site_options(&state, &filters).await?,
|
||||
)
|
||||
} else {
|
||||
(Vec::new(), Vec::new())
|
||||
};
|
||||
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM proxy_logs")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let success: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM proxy_logs WHERE status = 'success'")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"items": items,
|
||||
@@ -816,12 +448,10 @@ async fn proxy_logs(
|
||||
"summary": {
|
||||
"total": total,
|
||||
"success": success,
|
||||
"failed": failed,
|
||||
"cost": cost,
|
||||
"totalTokens": total_tokens,
|
||||
"failed": total - success,
|
||||
},
|
||||
"clientOptions": client_options,
|
||||
"sites": sites,
|
||||
"clientOptions": [],
|
||||
"sites": [],
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -856,10 +486,6 @@ pub struct ProxyLogRow {
|
||||
client_app_id: Option<String>,
|
||||
client_app_name: Option<String>,
|
||||
client_confidence: Option<String>,
|
||||
billing_details: Option<String>,
|
||||
content_summary: Option<String>,
|
||||
request_content: Option<String>,
|
||||
response_content: Option<String>,
|
||||
created_at: Option<String>,
|
||||
}
|
||||
|
||||
@@ -876,7 +502,6 @@ async fn proxy_log_detail(
|
||||
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
|
||||
l.error_message, l.retry_count, l.is_stream,
|
||||
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
|
||||
l.billing_details, l.content_summary, l.request_content, l.response_content,
|
||||
l.created_at,
|
||||
a.site_id AS site_id,
|
||||
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
|
||||
@@ -898,215 +523,14 @@ async fn proxy_log_detail(
|
||||
Ok(Json(json!({ "item": item, "data": item })))
|
||||
}
|
||||
|
||||
async fn proxy_debug_traces(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ProxyDebugTraceQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let limit = q.limit.unwrap_or(50).clamp(1, 200);
|
||||
let offset = q.offset.unwrap_or(0).max(0);
|
||||
let mut where_sql = Vec::new();
|
||||
let mut bind_values = Vec::new();
|
||||
|
||||
if let Some(session_id) = q
|
||||
.session_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
where_sql.push("session_id = ?");
|
||||
bind_values.push(session_id.to_string());
|
||||
}
|
||||
if let Some(model) = q.model.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
|
||||
where_sql.push("requested_model LIKE ?");
|
||||
bind_values.push(format!("%{model}%"));
|
||||
}
|
||||
if let Some(status) = q.status.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
|
||||
where_sql.push("final_status = ?");
|
||||
bind_values.push(status.to_string());
|
||||
}
|
||||
|
||||
let filter = if where_sql.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" WHERE {}", where_sql.join(" AND "))
|
||||
};
|
||||
|
||||
let list_sql = format!(
|
||||
r#"
|
||||
SELECT
|
||||
id, downstream_path, client_kind, session_id, trace_hint,
|
||||
requested_model, downstream_api_key_id, sticky_session_key,
|
||||
sticky_hit_channel_id, selected_channel_id, selected_route_id,
|
||||
selected_account_id, selected_site_id, selected_site_platform,
|
||||
final_status, final_http_status, final_upstream_path,
|
||||
created_at, updated_at
|
||||
FROM proxy_debug_traces
|
||||
{filter}
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"#
|
||||
);
|
||||
let mut list_query = sqlx::query_as::<_, ProxyDebugTraceRow>(&list_sql);
|
||||
for value in &bind_values {
|
||||
list_query = list_query.bind(value);
|
||||
}
|
||||
let items: Vec<Value> = list_query
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| serde_json::to_value(row).unwrap_or_else(|_| json!({})))
|
||||
.collect();
|
||||
|
||||
let count_sql = format!("SELECT COUNT(*) FROM proxy_debug_traces{filter}");
|
||||
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
|
||||
for value in &bind_values {
|
||||
count_query = count_query.bind(value);
|
||||
}
|
||||
let total = count_query.fetch_one(&*state.db).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"items": items,
|
||||
"data": items,
|
||||
"total": total,
|
||||
"page": (offset / limit) + 1,
|
||||
"pageSize": limit,
|
||||
})))
|
||||
async fn proxy_debug_traces() -> Json<Value> {
|
||||
Json(json!({ "items": [], "data": [], "total": 0 }))
|
||||
}
|
||||
|
||||
async fn proxy_debug_trace_detail(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path(id): axum::extract::Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let trace: Option<ProxyDebugTraceDetailRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
id, downstream_path, client_kind, session_id, trace_hint,
|
||||
requested_model, downstream_api_key_id, request_headers_json,
|
||||
request_body_json, sticky_session_key, sticky_hit_channel_id,
|
||||
selected_channel_id, selected_route_id, selected_account_id,
|
||||
selected_site_id, selected_site_platform, endpoint_candidates_json,
|
||||
endpoint_runtime_state_json, decision_summary_json, final_status,
|
||||
final_http_status, final_upstream_path, final_response_headers_json,
|
||||
final_response_body_json, created_at, updated_at
|
||||
FROM proxy_debug_traces
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
let Some(trace) = trace else {
|
||||
return Err(crate::error::AppError::NotFound(
|
||||
"proxy debug trace not found".into(),
|
||||
));
|
||||
};
|
||||
|
||||
let attempts: Vec<Value> = sqlx::query_as::<_, ProxyDebugAttemptRow>(
|
||||
r#"
|
||||
SELECT
|
||||
id, trace_id, attempt_index, endpoint, request_path, target_url,
|
||||
runtime_executor, request_headers_json, request_body_json,
|
||||
response_status, response_headers_json, response_body_json,
|
||||
raw_error_text, recover_applied, downgrade_decision,
|
||||
downgrade_reason, memory_write_json, created_at
|
||||
FROM proxy_debug_attempts
|
||||
WHERE trace_id = ?
|
||||
ORDER BY attempt_index ASC, id ASC
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| serde_json::to_value(row).unwrap_or_else(|_| json!({})))
|
||||
.collect();
|
||||
|
||||
let data = serde_json::to_value(trace).unwrap_or_else(|_| json!({}));
|
||||
Ok(Json(json!({
|
||||
"id": id,
|
||||
"item": data,
|
||||
"data": data,
|
||||
"attempts": attempts,
|
||||
"events": attempts,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct ProxyDebugTraceRow {
|
||||
id: i64,
|
||||
downstream_path: String,
|
||||
client_kind: Option<String>,
|
||||
session_id: Option<String>,
|
||||
trace_hint: Option<String>,
|
||||
requested_model: Option<String>,
|
||||
downstream_api_key_id: Option<i64>,
|
||||
sticky_session_key: Option<String>,
|
||||
sticky_hit_channel_id: Option<i64>,
|
||||
selected_channel_id: Option<i64>,
|
||||
selected_route_id: Option<i64>,
|
||||
selected_account_id: Option<i64>,
|
||||
selected_site_id: Option<i64>,
|
||||
selected_site_platform: Option<String>,
|
||||
final_status: Option<String>,
|
||||
final_http_status: Option<i64>,
|
||||
final_upstream_path: Option<String>,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct ProxyDebugTraceDetailRow {
|
||||
id: i64,
|
||||
downstream_path: String,
|
||||
client_kind: Option<String>,
|
||||
session_id: Option<String>,
|
||||
trace_hint: Option<String>,
|
||||
requested_model: Option<String>,
|
||||
downstream_api_key_id: Option<i64>,
|
||||
request_headers_json: Option<String>,
|
||||
request_body_json: Option<String>,
|
||||
sticky_session_key: Option<String>,
|
||||
sticky_hit_channel_id: Option<i64>,
|
||||
selected_channel_id: Option<i64>,
|
||||
selected_route_id: Option<i64>,
|
||||
selected_account_id: Option<i64>,
|
||||
selected_site_id: Option<i64>,
|
||||
selected_site_platform: Option<String>,
|
||||
endpoint_candidates_json: Option<String>,
|
||||
endpoint_runtime_state_json: Option<String>,
|
||||
decision_summary_json: Option<String>,
|
||||
final_status: Option<String>,
|
||||
final_http_status: Option<i64>,
|
||||
final_upstream_path: Option<String>,
|
||||
final_response_headers_json: Option<String>,
|
||||
final_response_body_json: Option<String>,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct ProxyDebugAttemptRow {
|
||||
id: i64,
|
||||
trace_id: i64,
|
||||
attempt_index: i64,
|
||||
endpoint: String,
|
||||
request_path: String,
|
||||
target_url: String,
|
||||
runtime_executor: Option<String>,
|
||||
request_headers_json: Option<String>,
|
||||
request_body_json: Option<String>,
|
||||
response_status: Option<i64>,
|
||||
response_headers_json: Option<String>,
|
||||
response_body_json: Option<String>,
|
||||
raw_error_text: Option<String>,
|
||||
recover_applied: Option<bool>,
|
||||
downgrade_decision: Option<bool>,
|
||||
downgrade_reason: Option<String>,
|
||||
memory_write_json: Option<String>,
|
||||
created_at: Option<String>,
|
||||
) -> Json<Value> {
|
||||
Json(json!({ "id": id, "events": [], "data": null }))
|
||||
}
|
||||
|
||||
/// GET /api/stats/model-by-site?siteId=...&days=...
|
||||
@@ -1339,71 +763,3 @@ async fn site_trend(
|
||||
|
||||
Ok(Json(json!({ "trend": trend })))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn proxy_log_filters_accept_downstream_key_aliases() {
|
||||
let mut params = std::collections::HashMap::new();
|
||||
params.insert("status".to_string(), "FAILED".to_string());
|
||||
params.insert("q".to_string(), " gpt-4o ".to_string());
|
||||
params.insert("site_id".to_string(), "9".to_string());
|
||||
params.insert("key_id".to_string(), "17".to_string());
|
||||
params.insert("client".to_string(), "family:codex".to_string());
|
||||
|
||||
let filters = parse_proxy_log_filters(¶ms);
|
||||
|
||||
assert_eq!(filters.status.as_deref(), Some("failed"));
|
||||
assert_eq!(filters.search_like.as_deref(), Some("%gpt-4o%"));
|
||||
assert_eq!(filters.site_id, Some(9));
|
||||
assert_eq!(filters.downstream_key_id, Some(17));
|
||||
assert_eq!(
|
||||
filters.client,
|
||||
Some(ProxyLogClientFilter::Family("codex".to_string())),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_log_filters_parse_client_scopes() {
|
||||
let mut params = std::collections::HashMap::new();
|
||||
|
||||
params.insert("client".to_string(), " app:cherry_studio ".to_string());
|
||||
let filters = parse_proxy_log_filters(¶ms);
|
||||
assert_eq!(
|
||||
filters.client,
|
||||
Some(ProxyLogClientFilter::App("cherry_studio".to_string())),
|
||||
);
|
||||
|
||||
params.insert("client".to_string(), "FAMILY:Codex".to_string());
|
||||
let filters = parse_proxy_log_filters(¶ms);
|
||||
assert_eq!(
|
||||
filters.client,
|
||||
Some(ProxyLogClientFilter::Family("codex".to_string())),
|
||||
);
|
||||
|
||||
params.insert("client".to_string(), "legacy-client".to_string());
|
||||
let filters = parse_proxy_log_filters(¶ms);
|
||||
assert_eq!(
|
||||
filters.client,
|
||||
Some(ProxyLogClientFilter::Raw("legacy-client".to_string())),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_log_filters_drop_invalid_values() {
|
||||
let mut params = std::collections::HashMap::new();
|
||||
params.insert("status".to_string(), "warning".to_string());
|
||||
params.insert("downstreamKeyId".to_string(), "-3".to_string());
|
||||
params.insert("siteId".to_string(), "0".to_string());
|
||||
params.insert("search".to_string(), " ".to_string());
|
||||
|
||||
let filters = parse_proxy_log_filters(¶ms);
|
||||
|
||||
assert!(filters.status.is_none());
|
||||
assert!(filters.downstream_key_id.is_none());
|
||||
assert!(filters.site_id.is_none());
|
||||
assert!(filters.search_like.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
// Background tasks routes — list and inspect async operations
|
||||
|
||||
use std::convert::Infallible;
|
||||
|
||||
use crate::{db::models::background_task::BackgroundTask, error::AppResult, AppState};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
response::{
|
||||
sse::{Event, Sse},
|
||||
IntoResponse,
|
||||
},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use futures::stream::Stream;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
@@ -19,6 +26,10 @@ pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/tasks", get(list_tasks))
|
||||
.route("/api/tasks/{id}", get(get_task))
|
||||
.route(
|
||||
"/api/update-center/tasks/{id}/stream",
|
||||
get(stream_task_logs),
|
||||
)
|
||||
}
|
||||
|
||||
/// GET /api/tasks — list background tasks
|
||||
@@ -56,3 +67,113 @@ 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))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
// 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,
|
||||
})
|
||||
},
|
||||
))
|
||||
}
|
||||
+61
-1642
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,6 @@ use std::convert::Infallible;
|
||||
use tracing::info;
|
||||
|
||||
use crate::{
|
||||
auth::jwt::validate_jwt,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
@@ -201,28 +200,15 @@ async fn external_comfyui_status_value(state: &AppState) -> Value {
|
||||
}
|
||||
|
||||
fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
|
||||
let token = headers
|
||||
let expected = format!("Bearer {}", state.config.auth.auth_token);
|
||||
|
||||
let actual = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.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()))?;
|
||||
.unwrap_or("");
|
||||
|
||||
if token == state.config.auth.auth_token {
|
||||
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(),
|
||||
));
|
||||
if actual != expected {
|
||||
return Err(AppError::BadRequest("unauthorized".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -287,7 +273,7 @@ async fn call_llm(state: &AppState, prompt: &str, system: &str) -> AppResult<Str
|
||||
|
||||
let resp = client
|
||||
.post(url)
|
||||
.bearer_auth(state.runtime_proxy_token())
|
||||
.bearer_auth(&state.config.auth.proxy_token)
|
||||
.json(&json!({
|
||||
"model": &state.config.comfyui.agent_model,
|
||||
"messages": [
|
||||
@@ -333,7 +319,7 @@ async fn call_image_gen(state: &AppState, prompt: &str) -> AppResult<String> {
|
||||
|
||||
let resp = client
|
||||
.post(url)
|
||||
.bearer_auth(state.runtime_proxy_token())
|
||||
.bearer_auth(&state.config.auth.proxy_token)
|
||||
.json(&json!({
|
||||
"model": "gpt-image-2",
|
||||
"prompt": prompt,
|
||||
@@ -376,7 +362,7 @@ async fn call_tts(state: &AppState, text: &str, voice: &str) -> AppResult<Vec<u8
|
||||
|
||||
let resp = client
|
||||
.post(url)
|
||||
.bearer_auth(state.runtime_proxy_token())
|
||||
.bearer_auth(&state.config.auth.proxy_token)
|
||||
.json(&json!({
|
||||
"model": "tts-1",
|
||||
"input": text,
|
||||
@@ -647,101 +633,3 @@ 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(_))));
|
||||
}
|
||||
}
|
||||
|
||||
+94
-202
@@ -30,7 +30,7 @@ use serde_json::{json, Map, Value};
|
||||
use sqlx::Row;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::{Path as StdPath, PathBuf},
|
||||
path::PathBuf,
|
||||
sync::{mpsc, Arc},
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
@@ -46,7 +46,6 @@ const NATIVE_WORKFLOW_CACHE_MAX_AGE: Duration = Duration::from_secs(21600);
|
||||
const WORKFLOW_UPLOAD_BODY_LIMIT_BYTES: usize = 256 * 1024 * 1024;
|
||||
const OFFICIAL_WORKFLOW_TEMPLATES_BASE: &str =
|
||||
"https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates";
|
||||
const DEFAULT_TEXT_TO_VIDEO_MODEL: &str = "seedance-1-0-lite-t2v-250428";
|
||||
static EMBEDDED_WORKFLOW_UI: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/workflow-ui");
|
||||
static EMBEDDED_WORKFLOW_UI_EXTERNAL: Dir<'_> =
|
||||
include_dir!("$CARGO_MANIFEST_DIR/dist/workflow-ui-external");
|
||||
@@ -211,9 +210,9 @@ pub fn routes() -> Router<AppState> {
|
||||
|
||||
fn workflow_ui_root_for_config(config: &crate::config::ComfyUIConfig) -> PathBuf {
|
||||
if let Ok(path) = std::env::var("BOOSAPI_WORKFLOW_UI_DIR") {
|
||||
return resolve_workflow_ui_dir(PathBuf::from(path));
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
let preferred = resolve_workflow_ui_dir(if config.uses_cloud_backend() {
|
||||
let preferred = if config.uses_cloud_backend() {
|
||||
std::env::var("BOOSAPI_WORKFLOW_UI_CLOUD_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("dist/workflow-ui-cloud"))
|
||||
@@ -223,23 +222,11 @@ fn workflow_ui_root_for_config(config: &crate::config::ComfyUIConfig) -> PathBuf
|
||||
.unwrap_or_else(|_| PathBuf::from("dist/workflow-ui-external"))
|
||||
} else {
|
||||
PathBuf::from("dist/workflow-ui")
|
||||
});
|
||||
};
|
||||
if workflow_ui_dir_is_usable(&preferred) {
|
||||
preferred
|
||||
} else {
|
||||
resolve_workflow_ui_dir(PathBuf::from("dist/workflow-ui"))
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_workflow_ui_dir(path: PathBuf) -> PathBuf {
|
||||
if path.is_absolute() || workflow_ui_dir_is_usable(&path) {
|
||||
return path;
|
||||
}
|
||||
let manifest_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(&path);
|
||||
if workflow_ui_dir_is_usable(&manifest_path) {
|
||||
manifest_path
|
||||
} else {
|
||||
path
|
||||
PathBuf::from("dist/workflow-ui")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,24 +272,14 @@ async fn workflow_ui_runtime(State(state): State<AppState>) -> Json<Value> {
|
||||
} else {
|
||||
"boosapi"
|
||||
};
|
||||
let frontend_dir = workflow_ui_root_for_config(&state.config.comfyui);
|
||||
Json(json!({
|
||||
"mode": mode,
|
||||
"baseUrl": state.config.comfyui.target_base_url(),
|
||||
"frontendDir": frontend_dir,
|
||||
"frontendBuild": workflow_ui_build_metadata(&frontend_dir),
|
||||
"frontendDir": workflow_ui_root_for_config(&state.config.comfyui),
|
||||
"cloudApiKeyConfigured": workflow_cloud_api_key(&state.config.comfyui).is_some(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn workflow_ui_build_metadata(dir: &StdPath) -> Value {
|
||||
let path = dir.join("boosapi-workflow-ui.json");
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn workflow_cloud_api_key(config: &crate::config::ComfyUIConfig) -> Option<&str> {
|
||||
config
|
||||
.api_key
|
||||
@@ -330,8 +307,7 @@ fn caller_from_headers(state: &AppState, headers: &HeaderMap) -> AppResult<Workf
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing authorization token".into()))?;
|
||||
|
||||
let proxy_token = state.runtime_proxy_token();
|
||||
if token == state.config.auth.auth_token || token == proxy_token {
|
||||
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
|
||||
return Ok(WorkflowCaller::Admin);
|
||||
}
|
||||
|
||||
@@ -567,28 +543,28 @@ fn core_workflow_template_index() -> Value {
|
||||
json!([
|
||||
{
|
||||
"moduleName": "default",
|
||||
"title": "BoosAPI Examples",
|
||||
"title": "BossAI Examples",
|
||||
"category": "basics",
|
||||
"type": "core",
|
||||
"isEssential": true,
|
||||
"templates": [
|
||||
{
|
||||
"name": "boosapi-text-to-image",
|
||||
"title": "BoosAPI 文生图",
|
||||
"description": "使用 BoosAPI 模型路由生成图片并预览输出。",
|
||||
"title": "BossAI 文生图",
|
||||
"description": "使用 BossAI 模型路由生成图片并预览输出。",
|
||||
"mediaType": "image",
|
||||
"mediaSubtype": "svg",
|
||||
"tags": ["boosapi", "image"],
|
||||
"models": ["BoosAPI"]
|
||||
"models": ["BossAI"]
|
||||
},
|
||||
{
|
||||
"name": "boosapi-text-to-video",
|
||||
"title": "BoosAPI 文生视频",
|
||||
"description": "使用 BoosAPI 视频节点生成视频并保存输出。",
|
||||
"title": "BossAI 文生视频",
|
||||
"description": "使用 BossAI 视频节点生成视频并保存输出。",
|
||||
"mediaType": "image",
|
||||
"mediaSubtype": "svg",
|
||||
"tags": ["boosapi", "video"],
|
||||
"models": ["BoosAPI"]
|
||||
"models": ["BossAI"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -698,7 +674,7 @@ fn text_to_video_workflow_graph() -> Value {
|
||||
{ "name": "raw", "type": "JSON", "links": null, "slot_index": 2 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "boosapi.text_to_video" },
|
||||
"widgets_values": [DEFAULT_TEXT_TO_VIDEO_MODEL, "16:9", 5, "720p", 24, true, 5, 180]
|
||||
"widgets_values": ["seedance-1-0-lite-t2v", "16:9", 5, "720p", 24, true, 5, 180]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
@@ -714,7 +690,7 @@ fn text_to_video_workflow_graph() -> Value {
|
||||
{ "name": "raw", "type": "JSON", "links": null, "slot_index": 1 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "boosapi.video_status" },
|
||||
"widgets_values": [DEFAULT_TEXT_TO_VIDEO_MODEL]
|
||||
"widgets_values": ["seedance-1-0-lite-t2v"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
@@ -1096,10 +1072,11 @@ fn workflow_folder_models(folder: &str, models: &[String]) -> Vec<String> {
|
||||
.cloned()
|
||||
.collect(),
|
||||
"clip" => models.to_vec(),
|
||||
// Comfy's video_models folder is a native Comfy API model folder. Keep it
|
||||
// separate from BoosAPI route models so video apps submit real Comfy API
|
||||
// model names instead of OpenAI-compatible route aliases.
|
||||
"video_models" => Vec::new(),
|
||||
"video_models" => models
|
||||
.iter()
|
||||
.filter(|model| is_video_workflow_model(model))
|
||||
.cloned()
|
||||
.collect(),
|
||||
"audio_tts_models" => models
|
||||
.iter()
|
||||
.filter(|model| is_audio_workflow_model(model))
|
||||
@@ -1124,32 +1101,6 @@ fn workflow_folder_models(folder: &str, models: &[String]) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn workflow_folder_is_api_only(folder: &str) -> bool {
|
||||
matches!(
|
||||
folder.trim().to_ascii_lowercase().as_str(),
|
||||
"boosapi" | "boosapi_models" | "api_models"
|
||||
)
|
||||
}
|
||||
|
||||
fn workflow_folder_has_route_models(folder: &str) -> bool {
|
||||
matches!(
|
||||
folder.trim().to_ascii_lowercase().as_str(),
|
||||
"boosapi"
|
||||
| "boosapi_models"
|
||||
| "api_models"
|
||||
| "image_models"
|
||||
| "chat_models"
|
||||
| "audio_tts_models"
|
||||
| "embeddings"
|
||||
| "3d_models"
|
||||
| "vision_models"
|
||||
)
|
||||
}
|
||||
|
||||
fn workflow_folder_route_model_items(folder: &str, route_models: &[String]) -> Vec<Value> {
|
||||
model_payload_items_from_names(workflow_folder_models(folder, route_models), 0)
|
||||
}
|
||||
|
||||
fn combo_choices_from_node_input(
|
||||
object_info: &Value,
|
||||
node_type: &str,
|
||||
@@ -1279,7 +1230,7 @@ fn inject_model_choices(defs: &mut Map<String, Value>, models: &[String]) {
|
||||
{
|
||||
optional.insert(
|
||||
"model".to_string(),
|
||||
comfy_combo_input(&video_models, DEFAULT_TEXT_TO_VIDEO_MODEL),
|
||||
comfy_combo_input(&video_models, "seedance-1-0-lite-t2v"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1546,7 +1497,7 @@ async fn workflow_ui_index(State(state): State<AppState>) -> Html<String> {
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BoosAPI Workflow Studio</title>
|
||||
<title>BossAI Workflow Studio</title>
|
||||
<style>
|
||||
html, body { margin: 0; width: 100%; height: 100%; background: #111827; color: #e5e7eb; font-family: system-ui, sans-serif; }
|
||||
.wrap { display: grid; place-items: center; height: 100%; padding: 24px; text-align: center; }
|
||||
@@ -1713,9 +1664,9 @@ fn local_workflow_template_asset(path: &str) -> Option<Response> {
|
||||
"boosapi-text-to-video.json" => Some(Json(text_to_video_workflow_graph()).into_response()),
|
||||
"boosapi-text-to-image.svg" | "boosapi-text-to-video.svg" => {
|
||||
let label = if path.contains("video") {
|
||||
"BoosAPI Video"
|
||||
"BossAI Video"
|
||||
} else {
|
||||
"BoosAPI Image"
|
||||
"BossAI Image"
|
||||
};
|
||||
Some(
|
||||
(
|
||||
@@ -1836,7 +1787,7 @@ async fn serve_file(path: PathBuf) -> AppResult<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
fn content_type_for_path(path: &StdPath) -> &'static str {
|
||||
fn content_type_for_path(path: &PathBuf) -> &'static str {
|
||||
match path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
@@ -1857,15 +1808,15 @@ fn content_type_for_path(path: &StdPath) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_control_for_path(path: &StdPath) -> &'static str {
|
||||
fn cache_control_for_path(path: &PathBuf) -> &'static str {
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"html" | "js" | "mjs" | "css" => "no-cache",
|
||||
"woff2" | "png" | "jpg" | "jpeg" | "webp" | "svg" => {
|
||||
"html" => "no-cache",
|
||||
"js" | "mjs" | "css" | "woff2" | "png" | "jpg" | "jpeg" | "webp" | "svg" => {
|
||||
"public, max-age=604800"
|
||||
}
|
||||
_ => "public, max-age=3600",
|
||||
@@ -3473,7 +3424,7 @@ async fn queue_boosapi_prompt(
|
||||
if graph.nodes.is_empty() {
|
||||
return Ok(prompt_error_response(
|
||||
"画布里还没有可执行节点",
|
||||
"请先从左侧节点库添加 BoosAPI 节点并连接后再执行。",
|
||||
"请先从左侧节点库添加 BossAI 节点并连接后再执行。",
|
||||
));
|
||||
}
|
||||
let name = payload
|
||||
@@ -4126,7 +4077,6 @@ async fn archive_native_workflow_asset(
|
||||
model: None,
|
||||
remote_url: None,
|
||||
metadata: json!({
|
||||
"workspaceAsset": { "role": "output" },
|
||||
"workflowUi": {
|
||||
"filename": filename,
|
||||
"type": file_type,
|
||||
@@ -4358,7 +4308,7 @@ fn run_status_messages(run: &crate::workflow::models::WorkflowRunSummary) -> Vec
|
||||
"node_type": failed_node.map(|node| node.node_type.as_str()).unwrap_or("Workflow"),
|
||||
"executed": run.node_runs.iter().filter(|node| node.status == "completed").map(|node| node.node_id.clone()).collect::<Vec<_>>(),
|
||||
"exception_message": run.error_message.clone().unwrap_or_else(|| "Workflow execution failed".to_string()),
|
||||
"exception_type": "BoosAPIWorkflowError",
|
||||
"exception_type": "BossAIWorkflowError",
|
||||
"traceback": [],
|
||||
"current_inputs": failed_node.map(|node| node.inputs.clone()).unwrap_or_else(|| json!({})),
|
||||
"current_outputs": failed_node.and_then(|node| node.outputs.clone()).unwrap_or_else(|| json!({})),
|
||||
@@ -4480,7 +4430,7 @@ fn workflow_run_to_prompt_tuple(run: &crate::workflow::models::WorkflowRunSummar
|
||||
.workflow_name
|
||||
.as_deref()
|
||||
.or(run.workflow_public_id.as_deref())
|
||||
.unwrap_or("BoosAPI workflow");
|
||||
.unwrap_or("BossAI workflow");
|
||||
let workflow = json!({ "name": workflow_name, "nodes": [], "links": [] });
|
||||
let prompt = run
|
||||
.node_runs
|
||||
@@ -4684,7 +4634,7 @@ fn workflow_progress_callback_with_run_sender(
|
||||
"node_id": "workflow",
|
||||
"node_type": "Workflow",
|
||||
"exception_message": error.unwrap_or_else(|| "Workflow execution failed".to_string()),
|
||||
"exception_type": "BoosAPIWorkflowError",
|
||||
"exception_type": "BossAIWorkflowError",
|
||||
"traceback": [],
|
||||
})
|
||||
},
|
||||
@@ -4762,7 +4712,7 @@ fn publish_run_events(
|
||||
"node_id": run.node_runs.last().map(|node| node.node_id.as_str()).unwrap_or("workflow"),
|
||||
"node_type": run.node_runs.last().map(|node| node.node_type.as_str()).unwrap_or("Workflow"),
|
||||
"exception_message": run.error_message.clone().unwrap_or_else(|| "Workflow execution failed".to_string()),
|
||||
"exception_type": "BoosAPIWorkflowError",
|
||||
"exception_type": "BossAIWorkflowError",
|
||||
"traceback": [],
|
||||
})
|
||||
} else {
|
||||
@@ -4840,29 +4790,16 @@ async fn experiment_models_in_folder(
|
||||
) -> AppResult<Json<Value>> {
|
||||
if state.config.comfyui.uses_cloud_backend() {
|
||||
let _ = auth_caller_optional(&state, &headers);
|
||||
if workflow_folder_has_route_models(&folder) {
|
||||
let normalized_folder = folder.trim().to_ascii_lowercase();
|
||||
if matches!(
|
||||
normalized_folder.as_str(),
|
||||
"boosapi" | "boosapi_models" | "api_models"
|
||||
) {
|
||||
let route_models = workflow_route_models(&state).await.unwrap_or_default();
|
||||
let api_model_items = workflow_folder_route_model_items(&folder, &route_models);
|
||||
if workflow_folder_is_api_only(&folder) {
|
||||
let api_model_items =
|
||||
model_payload_items_from_names(workflow_folder_models(&folder, &route_models), 0);
|
||||
return Ok(Json(Value::Array(api_model_items)));
|
||||
}
|
||||
let mut native_items = Vec::<Value>::new();
|
||||
let path = format!("/experiment/models/{}", simple_urlencode(&folder));
|
||||
if let Ok(value) = native_workflow_json_cached(
|
||||
&state,
|
||||
Method::GET,
|
||||
&path,
|
||||
None,
|
||||
native_workflow_metadata_ttl(&state),
|
||||
)
|
||||
.await
|
||||
{
|
||||
if let Value::Array(items) = value.0 {
|
||||
native_items.extend(items);
|
||||
}
|
||||
}
|
||||
return Ok(Json(merge_model_payload_items(api_model_items, native_items)));
|
||||
}
|
||||
let path = format!("/experiment/models/{}", simple_urlencode(&folder));
|
||||
return native_workflow_json_cached(
|
||||
&state,
|
||||
@@ -4874,7 +4811,7 @@ async fn experiment_models_in_folder(
|
||||
.await;
|
||||
}
|
||||
let route_models = workflow_route_models(&state).await.unwrap_or_default();
|
||||
let api_model_items = workflow_folder_route_model_items(&folder, &route_models);
|
||||
let api_model_items = model_payload_items_from_names(workflow_folder_models(&folder, &route_models), 0);
|
||||
|
||||
if state.config.comfyui.uses_native_backend() {
|
||||
let _ = auth_caller_optional(&state, &headers);
|
||||
@@ -5246,30 +5183,55 @@ async fn workflow_context(state: &AppState, caller: &WorkflowCaller) -> AppResul
|
||||
}
|
||||
|
||||
fn inject_bootstrap(html: String, config: &crate::config::ComfyUIConfig) -> String {
|
||||
let cloud_api_key = workflow_cloud_api_key(config);
|
||||
if config.uses_native_backend() && !config.uses_cloud_backend() {
|
||||
return inject_external_bootstrap(html);
|
||||
return inject_external_bootstrap(html, cloud_api_key);
|
||||
}
|
||||
inject_enhanced_bootstrap(html)
|
||||
inject_enhanced_bootstrap(html, cloud_api_key)
|
||||
}
|
||||
|
||||
fn workflow_secret_cleanup_bootstrap() -> &'static str {
|
||||
r#"<script>
|
||||
(function () {
|
||||
const clearLeakedComfyApiKey = () => {
|
||||
try {
|
||||
localStorage.removeItem('comfy_api_key');
|
||||
sessionStorage.removeItem('comfy_api_key');
|
||||
document.cookie = 'comfy_api_key=; Path=/workflow-ui; Max-Age=0; SameSite=Lax';
|
||||
} catch (err) {}
|
||||
fn workflow_api_key_bootstrap(api_key: Option<&str>) -> String {
|
||||
let Some(api_key) = api_key else {
|
||||
return String::new();
|
||||
};
|
||||
clearLeakedComfyApiKey();
|
||||
window.addEventListener('DOMContentLoaded', clearLeakedComfyApiKey);
|
||||
})();
|
||||
let api_key = serde_json::to_string(api_key).unwrap_or_else(|_| "null".to_string());
|
||||
format!(
|
||||
r#"<script>
|
||||
(function () {{
|
||||
const apiKey = {api_key};
|
||||
if (!apiKey) return;
|
||||
const installComfyApiKey = () => {{
|
||||
try {{
|
||||
localStorage.setItem('comfy_api_key', apiKey);
|
||||
sessionStorage.setItem('comfy_api_key', apiKey);
|
||||
for (const api of [window.app?.api, window.comfyAPI?.api, window.api]) {{
|
||||
if (api) {{
|
||||
api.apiKey = api.apiKey || apiKey;
|
||||
if (!api.__boosapiCloudApiKeyQueuePatch && typeof api.queuePrompt === 'function') {{
|
||||
const originalQueuePrompt = api.queuePrompt.bind(api);
|
||||
api.queuePrompt = function() {{
|
||||
if (!this.apiKey) this.apiKey = apiKey;
|
||||
return originalQueuePrompt(...arguments);
|
||||
}};
|
||||
api.__boosapiCloudApiKeyQueuePatch = true;
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
document.cookie = 'comfy_api_key=' + encodeURIComponent(apiKey) + '; Path=/workflow-ui; SameSite=Lax';
|
||||
}} catch (err) {{}}
|
||||
}};
|
||||
installComfyApiKey();
|
||||
window.addEventListener('DOMContentLoaded', installComfyApiKey);
|
||||
window.setTimeout(installComfyApiKey, 300);
|
||||
window.setTimeout(installComfyApiKey, 1200);
|
||||
window.setInterval(installComfyApiKey, 5000);
|
||||
}})();
|
||||
</script>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn inject_external_bootstrap(html: String) -> String {
|
||||
let secret_cleanup_bootstrap = workflow_secret_cleanup_bootstrap();
|
||||
fn inject_external_bootstrap(html: String, cloud_api_key: Option<&str>) -> String {
|
||||
let api_key_bootstrap = workflow_api_key_bootstrap(cloud_api_key);
|
||||
let shell = r#"<script>
|
||||
(function () {
|
||||
const renameComfyUiBrand = () => {
|
||||
@@ -5348,12 +5310,12 @@ fn inject_external_bootstrap(html: String) -> String {
|
||||
window.setTimeout(installWorkflowValidationButton, 1800);
|
||||
})();
|
||||
</script>"#;
|
||||
let bootstrap = format!("{secret_cleanup_bootstrap}{shell}");
|
||||
let bootstrap = format!("{api_key_bootstrap}{shell}");
|
||||
html.replace("</head>", &format!("{bootstrap}\n</head>"))
|
||||
}
|
||||
|
||||
fn inject_enhanced_bootstrap(html: String) -> String {
|
||||
let secret_cleanup_bootstrap = workflow_secret_cleanup_bootstrap();
|
||||
fn inject_enhanced_bootstrap(html: String, cloud_api_key: Option<&str>) -> String {
|
||||
let api_key_bootstrap = workflow_api_key_bootstrap(cloud_api_key);
|
||||
let shell = r#"<style id="boosapi-workflow-shell-style">
|
||||
</style>
|
||||
<script>
|
||||
@@ -5418,16 +5380,16 @@ fn inject_enhanced_bootstrap(html: String) -> String {
|
||||
'CLIP': 'CLIP',
|
||||
'VAE': 'VAE',
|
||||
'CONDITIONING': '条件',
|
||||
'BoosAPI': 'BoosAPI',
|
||||
'boosapi.image_to_video': 'BoosAPI 图生视频',
|
||||
'boosapi.text_to_video': 'BoosAPI 文生视频',
|
||||
'BossAI': 'BossAI',
|
||||
'boosapi.image_to_video': 'BossAI 图生视频',
|
||||
'boosapi.text_to_video': 'BossAI 文生视频',
|
||||
'boosapi.video_status': '查询视频任务',
|
||||
'output.preview_video': '输出:预览视频',
|
||||
'output.preview_image': '输出:预览图像',
|
||||
'input.text': '输入:文本',
|
||||
'boosapi.chat_completion': 'BoosAPI 对话',
|
||||
'boosapi.prompt_rewrite': 'BoosAPI 提示词改写',
|
||||
'boosapi.text_to_image': 'BoosAPI 文生图',
|
||||
'boosapi.chat_completion': 'BossAI 对话',
|
||||
'boosapi.prompt_rewrite': 'BossAI 提示词改写',
|
||||
'boosapi.text_to_image': 'BossAI 文生图',
|
||||
'output.save_file': '输出:保存文件'
|
||||
}));
|
||||
const translateWorkflowText = (text) => {
|
||||
@@ -5615,7 +5577,7 @@ fn inject_enhanced_bootstrap(html: String) -> String {
|
||||
const graph = app?.graph || window.LGraphCanvas?.active_canvas?.graph;
|
||||
const canvas = app?.canvas || window.LGraphCanvas?.active_canvas;
|
||||
if (!graph) return false;
|
||||
const node = window.LiteGraph.createNode('boosapi.text_to_video', 'BoosAPI 文生视频');
|
||||
const node = window.LiteGraph.createNode('boosapi.text_to_video', 'BossAI 文生视频');
|
||||
if (!node) return false;
|
||||
const area = canvas?.ds?.visible_area;
|
||||
node.pos = Array.isArray(area) ? [area[0] + area[2] / 2 - 190, area[1] + area[3] / 2 - 120] : [160, 160];
|
||||
@@ -5751,7 +5713,7 @@ fn inject_enhanced_bootstrap(html: String) -> String {
|
||||
window.setTimeout(patchWorkflowShell, 1200);
|
||||
})();
|
||||
</script>"#;
|
||||
let bootstrap = format!("{secret_cleanup_bootstrap}{shell}");
|
||||
let bootstrap = format!("{api_key_bootstrap}{shell}");
|
||||
html.replace("</head>", &format!("{bootstrap}\n</head>"))
|
||||
}
|
||||
|
||||
@@ -5762,73 +5724,3 @@ fn parse_workflow_cookie(raw: &str) -> Option<&str> {
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn video_model_folder_does_not_use_route_video_models() {
|
||||
let route_models = vec![
|
||||
"gpt-5.4".to_string(),
|
||||
"gpt-image-2".to_string(),
|
||||
"seedance-1-0-pro-t2v-250528".to_string(),
|
||||
"wan2.1-i2v".to_string(),
|
||||
"text-embedding-3-large".to_string(),
|
||||
];
|
||||
|
||||
let names = workflow_folder_models("video_models", &route_models);
|
||||
|
||||
assert!(names.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_backed_workflow_folders_are_not_native_only() {
|
||||
assert!(!workflow_folder_has_route_models("video_models"));
|
||||
assert!(workflow_folder_has_route_models("image_models"));
|
||||
assert!(workflow_folder_has_route_models("api_models"));
|
||||
assert!(workflow_folder_is_api_only("api_models"));
|
||||
assert!(!workflow_folder_is_api_only("video_models"));
|
||||
assert!(!workflow_folder_has_route_models("checkpoints"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_model_folder_prefers_native_comfy_items_without_route_aliases() {
|
||||
let route_models = vec![
|
||||
"seedance-1-0-pro-t2v-250528".to_string(),
|
||||
"wan2.1-t2v".to_string(),
|
||||
];
|
||||
let api_items = workflow_folder_route_model_items("video_models", &route_models);
|
||||
let native_items = vec![
|
||||
json!({ "name": "wan2.1-t2v", "pathIndex": 2 }),
|
||||
json!({ "name": "local-video-model.safetensors", "pathIndex": 1 }),
|
||||
];
|
||||
|
||||
let merged = merge_model_payload_items(api_items, native_items);
|
||||
let names = merged
|
||||
.as_array()
|
||||
.expect("merged model payload should be an array")
|
||||
.iter()
|
||||
.filter_map(|item| item.get("name").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"wan2.1-t2v",
|
||||
"local-video-model.safetensors",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_ui_runtime_assets_revalidate_after_install() {
|
||||
assert_eq!(cache_control_for_path(StdPath::new("index.html")), "no-cache");
|
||||
assert_eq!(cache_control_for_path(StdPath::new("assets/index.js")), "no-cache");
|
||||
assert_eq!(cache_control_for_path(StdPath::new("assets/index.css")), "no-cache");
|
||||
assert_eq!(
|
||||
cache_control_for_path(StdPath::new("assets/logo.svg")),
|
||||
"public, max-age=604800"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+17
-34
@@ -91,8 +91,7 @@ fn resolve_caller(
|
||||
let token = bearer(headers)
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||
|
||||
let proxy_token = state.runtime_proxy_token();
|
||||
if token == state.config.auth.auth_token || token == proxy_token {
|
||||
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
|
||||
return Ok(WorkspaceCaller::Admin);
|
||||
}
|
||||
|
||||
@@ -173,20 +172,6 @@ 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(
|
||||
state: AppState,
|
||||
caller: WorkspaceCaller,
|
||||
@@ -199,12 +184,15 @@ async fn list_assets(
|
||||
};
|
||||
let is_admin = matches!(caller, WorkspaceCaller::Admin);
|
||||
|
||||
let list_sql = format!(
|
||||
let items = sqlx::query(
|
||||
"SELECT *
|
||||
FROM workspace_assets
|
||||
WHERE deleted_at IS NULL
|
||||
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
|
||||
{WORKSPACE_OUTPUT_FILTER_SQL}
|
||||
AND (
|
||||
source != 'workflow-ui'
|
||||
OR json_extract(metadata, '$.workflowUi.type') = 'output'
|
||||
)
|
||||
AND (? IS NULL OR source = ?)
|
||||
AND (? IS NULL OR kind = ?)
|
||||
AND (
|
||||
@@ -215,9 +203,8 @@ async fn list_assets(
|
||||
OR source LIKE ?
|
||||
)
|
||||
ORDER BY datetime(created_at) DESC, id DESC
|
||||
LIMIT ? OFFSET ?"
|
||||
);
|
||||
let items = sqlx::query(&list_sql)
|
||||
LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.bind(scoped_user_id)
|
||||
@@ -238,12 +225,15 @@ async fn list_assets(
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let count_sql = format!(
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*)
|
||||
FROM workspace_assets
|
||||
WHERE deleted_at IS NULL
|
||||
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
|
||||
{WORKSPACE_OUTPUT_FILTER_SQL}
|
||||
AND (
|
||||
source != 'workflow-ui'
|
||||
OR json_extract(metadata, '$.workflowUi.type') = 'output'
|
||||
)
|
||||
AND (? IS NULL OR source = ?)
|
||||
AND (? IS NULL OR kind = ?)
|
||||
AND (
|
||||
@@ -252,9 +242,8 @@ async fn list_assets(
|
||||
OR prompt LIKE ?
|
||||
OR model LIKE ?
|
||||
OR source LIKE ?
|
||||
)"
|
||||
);
|
||||
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||
)",
|
||||
)
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.bind(scoped_user_id)
|
||||
@@ -299,7 +288,7 @@ async fn get_asset_row(
|
||||
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(&public_id)
|
||||
.bind(public_id)
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.bind(scoped_user_id)
|
||||
@@ -339,7 +328,7 @@ async fn delete_asset(
|
||||
AND deleted_at IS NULL
|
||||
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)
|
||||
@@ -350,9 +339,6 @@ async fn delete_asset(
|
||||
if result.rows_affected() == 0 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -398,9 +384,6 @@ async fn delete_assets_batch(
|
||||
.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);
|
||||
|
||||
+1
-826
@@ -2,10 +2,8 @@ use crate::{
|
||||
error::{AppError, AppResult},
|
||||
services::shared::{enforce_key_request_limit, increment_key_usage},
|
||||
};
|
||||
use chrono::Timelike;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{FromRow, Row, SqlitePool};
|
||||
use sqlx::{FromRow, SqlitePool};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -23,23 +21,6 @@ pub struct UsageSummary {
|
||||
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 {
|
||||
pub fn new(db: Arc<SqlitePool>) -> Self {
|
||||
Self { db }
|
||||
@@ -137,112 +118,6 @@ impl UsageTracker {
|
||||
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<()> {
|
||||
Self::create_daily_usage_snapshot_for_db(&self.db).await
|
||||
}
|
||||
@@ -277,215 +152,6 @@ impl UsageTracker {
|
||||
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<()> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS usage_logs (
|
||||
@@ -524,283 +190,8 @@ impl UsageTracker {
|
||||
)
|
||||
.execute(db)
|
||||
.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(())
|
||||
}
|
||||
|
||||
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)]
|
||||
@@ -844,56 +235,6 @@ mod tests {
|
||||
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]
|
||||
async fn records_and_aggregates_usage() {
|
||||
let tracker = UsageTracker::new(Arc::new(pool().await));
|
||||
@@ -941,170 +282,4 @@ mod tests {
|
||||
.unwrap();
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,10 +79,6 @@ impl CronScheduler {
|
||||
|
||||
async fn aggregate_usage(db: Arc<SqlitePool>) -> AppResult<()> {
|
||||
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(
|
||||
"UPDATE downstream_api_keys
|
||||
SET updated_at = datetime('now')
|
||||
@@ -127,7 +123,6 @@ impl CronScheduler {
|
||||
)
|
||||
.execute(&*db)
|
||||
.await?;
|
||||
crate::proxy::token_router::router::invalidate_route_cache();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+1
-3
@@ -5,11 +5,9 @@ pub mod comfyui_process;
|
||||
pub mod cron;
|
||||
pub mod notification;
|
||||
pub mod oauth;
|
||||
pub mod payment;
|
||||
pub mod register_captcha;
|
||||
pub mod registration_limiter;
|
||||
pub mod route_rebuild;
|
||||
pub mod runtime_auth;
|
||||
pub mod runtime_logs;
|
||||
pub mod shared;
|
||||
pub mod update_center;
|
||||
pub mod workspace;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::error::AppResult;
|
||||
use chrono::{FixedOffset, SecondsFormat, TimeZone, Utc};
|
||||
use lettre::{
|
||||
message::Mailbox, transport::smtp::authentication::Credentials, Message, SmtpTransport,
|
||||
@@ -36,19 +36,13 @@ pub struct Notification {
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct NotificationConfig {
|
||||
telegram_enabled: bool,
|
||||
telegram_bot_token: Option<String>,
|
||||
telegram_chat_id: Option<String>,
|
||||
telegram_thread_id: Option<i64>,
|
||||
wecom_enabled: bool,
|
||||
wecom_webhook: Option<String>,
|
||||
feishu_enabled: bool,
|
||||
feishu_webhook: Option<String>,
|
||||
webhook_enabled: bool,
|
||||
webhook_url: Option<String>,
|
||||
bark_enabled: bool,
|
||||
bark_url: Option<String>,
|
||||
serverchan_enabled: bool,
|
||||
serverchan_key: Option<String>,
|
||||
smtp: SmtpConfig,
|
||||
cooldown_sec: i64,
|
||||
@@ -56,10 +50,8 @@ struct NotificationConfig {
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct SmtpConfig {
|
||||
enabled: bool,
|
||||
host: Option<String>,
|
||||
port: Option<u16>,
|
||||
secure: Option<bool>,
|
||||
user: Option<String>,
|
||||
pass: Option<String>,
|
||||
from: Option<String>,
|
||||
@@ -108,33 +100,6 @@ impl NotificationService {
|
||||
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> {
|
||||
let rows: Vec<(String, Option<String>)> =
|
||||
sqlx::query_as("SELECT key, value FROM settings WHERE key LIKE 'notify_%'")
|
||||
@@ -147,26 +112,18 @@ impl NotificationService {
|
||||
.collect();
|
||||
|
||||
Ok(NotificationConfig {
|
||||
telegram_enabled: setting_bool(&map, "notify_telegram_enabled", true),
|
||||
telegram_bot_token: setting(&map, "notify_telegram_bot_token"),
|
||||
telegram_chat_id: setting(&map, "notify_telegram_chat_id"),
|
||||
telegram_thread_id: setting(&map, "notify_telegram_thread_id")
|
||||
.and_then(|value| value.parse().ok()),
|
||||
wecom_enabled: setting_bool(&map, "notify_wecom_enabled", true),
|
||||
wecom_webhook: setting(&map, "notify_wecom_webhook"),
|
||||
feishu_enabled: setting_bool(&map, "notify_feishu_enabled", true),
|
||||
feishu_webhook: setting(&map, "notify_feishu_webhook"),
|
||||
webhook_enabled: setting_bool(&map, "notify_webhook_enabled", true),
|
||||
webhook_url: setting(&map, "notify_webhook_url"),
|
||||
bark_enabled: setting_bool(&map, "notify_bark_enabled", true),
|
||||
bark_url: setting(&map, "notify_bark_url"),
|
||||
serverchan_enabled: setting_bool(&map, "notify_serverchan_enabled", true),
|
||||
serverchan_key: setting(&map, "notify_serverchan_key"),
|
||||
smtp: SmtpConfig {
|
||||
enabled: setting_bool(&map, "notify_smtp_enabled", true),
|
||||
host: setting(&map, "notify_smtp_host"),
|
||||
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"),
|
||||
pass: setting(&map, "notify_smtp_pass"),
|
||||
from: setting(&map, "notify_smtp_from"),
|
||||
@@ -231,28 +188,25 @@ impl NotificationService {
|
||||
|
||||
fn enabled_channels(&self, config: &NotificationConfig) -> Vec<String> {
|
||||
let mut channels = Vec::new();
|
||||
if config.telegram_enabled
|
||||
&& config.telegram_bot_token.is_some()
|
||||
&& config.telegram_chat_id.is_some()
|
||||
{
|
||||
if config.telegram_bot_token.is_some() && config.telegram_chat_id.is_some() {
|
||||
channels.push("telegram".to_string());
|
||||
}
|
||||
if config.wecom_enabled && config.wecom_webhook.is_some() {
|
||||
if config.wecom_webhook.is_some() {
|
||||
channels.push("wecom".to_string());
|
||||
}
|
||||
if config.feishu_enabled && config.feishu_webhook.is_some() {
|
||||
if config.feishu_webhook.is_some() {
|
||||
channels.push("feishu".to_string());
|
||||
}
|
||||
if config.webhook_enabled && config.webhook_url.is_some() {
|
||||
if config.webhook_url.is_some() {
|
||||
channels.push("webhook".to_string());
|
||||
}
|
||||
if config.bark_enabled && config.bark_url.is_some() {
|
||||
if config.bark_url.is_some() {
|
||||
channels.push("bark".to_string());
|
||||
}
|
||||
if config.serverchan_enabled && config.serverchan_key.is_some() {
|
||||
if config.serverchan_key.is_some() {
|
||||
channels.push("serverchan".to_string());
|
||||
}
|
||||
if config.smtp.enabled && config.smtp.is_configured() {
|
||||
if config.smtp.is_configured() {
|
||||
channels.push("smtp".to_string());
|
||||
}
|
||||
channels
|
||||
@@ -431,7 +385,6 @@ impl SmtpConfig {
|
||||
fn send(self, notif: &Notification) -> anyhow::Result<()> {
|
||||
let host = required(self.host.as_deref(), "smtp host")?;
|
||||
let port = self.port.unwrap_or(587);
|
||||
let secure = self.secure.unwrap_or(true);
|
||||
let user = required(self.user.as_deref(), "smtp user")?;
|
||||
let pass = required(self.pass.as_deref(), "smtp pass")?;
|
||||
let from = required(self.from.as_deref(), "smtp from")?;
|
||||
@@ -447,12 +400,7 @@ impl SmtpConfig {
|
||||
))
|
||||
.body(format_notification_text(notif))?;
|
||||
|
||||
let builder = if secure {
|
||||
SmtpTransport::relay(host)?
|
||||
} else {
|
||||
SmtpTransport::builder_dangerous(host)
|
||||
};
|
||||
let mailer = builder
|
||||
let mailer = SmtpTransport::relay(host)?
|
||||
.port(port)
|
||||
.credentials(Credentials::new(user.to_string(), pass.to_string()))
|
||||
.build();
|
||||
@@ -465,17 +413,6 @@ fn setting(map: &HashMap<String, String>, key: &str) -> Option<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> {
|
||||
value.ok_or_else(|| anyhow::anyhow!("{name} is not configured"))
|
||||
}
|
||||
|
||||
+44
-517
@@ -1,16 +1,13 @@
|
||||
use crate::error::{AppError, AppResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use serde_json::Value;
|
||||
use sqlx::{FromRow, SqlitePool};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub const SYSTEM_PROXY_URL_SETTING_KEY: &str = "runtime.system_proxy_url";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OAuthService {
|
||||
db: Arc<SqlitePool>,
|
||||
#[cfg(test)]
|
||||
client_override: Option<reqwest::Client>,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
@@ -29,7 +26,6 @@ pub struct OAuthRouteUnit {
|
||||
pub struct RefreshedToken {
|
||||
pub account_id: i64,
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
@@ -37,16 +33,7 @@ impl OAuthService {
|
||||
pub fn new(db: Arc<SqlitePool>) -> Self {
|
||||
Self {
|
||||
db,
|
||||
#[cfg(test)]
|
||||
client_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn with_client(db: Arc<SqlitePool>, client: reqwest::Client) -> Self {
|
||||
Self {
|
||||
db,
|
||||
client_override: Some(client),
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +55,6 @@ impl OAuthService {
|
||||
return Ok(RefreshedToken {
|
||||
account_id,
|
||||
access_token: current_access_token,
|
||||
refresh_token,
|
||||
expires_at: token_expiry(extra_config.as_deref()),
|
||||
});
|
||||
}
|
||||
@@ -77,24 +63,21 @@ impl OAuthService {
|
||||
.filter(|token| !token.trim().is_empty())
|
||||
.ok_or_else(|| AppError::BadRequest("OAuth account has no refresh token".into()))?;
|
||||
|
||||
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?;
|
||||
let mut refreshed = self.perform_refresh(site_id, &refresh_token).await?;
|
||||
refreshed.account_id = account_id;
|
||||
let next_extra_config =
|
||||
merge_refreshed_extra_config(extra_config.as_deref(), &refreshed, &refresh_token)?;
|
||||
sqlx::query(
|
||||
"UPDATE accounts
|
||||
SET access_token = ?, api_token = ?, extra_config = ?, updated_at = datetime('now')
|
||||
SET access_token = ?, extra_config = ?, updated_at = datetime('now')
|
||||
WHERE id = ? AND site_id = ?",
|
||||
)
|
||||
.bind(&refreshed.access_token)
|
||||
.bind(refreshed.refresh_token.as_deref().unwrap_or(&refresh_token))
|
||||
.bind(next_extra_config)
|
||||
.bind(
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"expires_at": refreshed.expires_at,
|
||||
"refreshed_at": chrono::Utc::now().to_rfc3339(),
|
||||
}))
|
||||
.map_err(|err| AppError::Internal(err.into()))?,
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(site_id)
|
||||
.execute(&*self.db)
|
||||
@@ -107,19 +90,6 @@ impl OAuthService {
|
||||
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>> {
|
||||
if let Some(site_id) = site_id {
|
||||
sqlx::query_as("SELECT * FROM oauth_route_units WHERE site_id = ? ORDER BY id")
|
||||
@@ -162,37 +132,29 @@ impl OAuthService {
|
||||
|
||||
async fn perform_refresh(
|
||||
&self,
|
||||
client: &reqwest::Client,
|
||||
site_id: i64,
|
||||
refresh_token: &str,
|
||||
) -> AppResult<RefreshedToken> {
|
||||
let raw_custom_headers: Option<String> =
|
||||
sqlx::query_scalar("SELECT custom_headers FROM sites WHERE id = ?")
|
||||
let token_endpoint: Option<String> = sqlx::query_scalar(
|
||||
"SELECT json_extract(custom_headers, '$.oauth_token_url') FROM sites WHERE id = ?",
|
||||
)
|
||||
.bind(site_id)
|
||||
.fetch_optional(&*self.db)
|
||||
.await?;
|
||||
|
||||
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 {
|
||||
let Some(token_endpoint) = token_endpoint.filter(|url| !url.trim().is_empty()) else {
|
||||
return Err(AppError::BadRequest(
|
||||
"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 = client
|
||||
let response: Value = self
|
||||
.client
|
||||
.post(token_endpoint)
|
||||
.form(&form)
|
||||
.form(&[
|
||||
("grant_type", "refresh_token"),
|
||||
("refresh_token", refresh_token),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(format!("OAuth refresh failed: {err}")))?
|
||||
@@ -200,481 +162,46 @@ impl OAuthService {
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(format!("Invalid OAuth response: {err}")))?;
|
||||
|
||||
let access_token = string_field(&response, &["access_token", "accessToken"])
|
||||
let access_token = response
|
||||
.get("access_token")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| AppError::UpstreamError("OAuth response missing access_token".into()))?
|
||||
.to_string();
|
||||
let refresh_token =
|
||||
string_field(&response, &["refresh_token", "refreshToken"]).map(str::to_string);
|
||||
let expires_at = token_expiry_from_response(&response);
|
||||
let expires_at = response
|
||||
.get("expires_at")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
|
||||
Ok(RefreshedToken {
|
||||
account_id: 0,
|
||||
access_token,
|
||||
refresh_token,
|
||||
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 {
|
||||
token_expiry_datetime(extra_config)
|
||||
let Some(extra_config) = extra_config else {
|
||||
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))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
fn token_expiry(extra_config: Option<&str>) -> Option<String> {
|
||||
token_expiry_datetime(extra_config).map(|expires_at| expires_at.to_rfc3339())
|
||||
}
|
||||
|
||||
fn token_expiry_datetime(extra_config: Option<&str>) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
let value = extra_config.and_then(|config| serde_json::from_str::<Value>(config).ok())?;
|
||||
if let Some(expires_at) = value
|
||||
extra_config
|
||||
.and_then(|config| serde_json::from_str::<Value>(config).ok())
|
||||
.and_then(|value| {
|
||||
value
|
||||
.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"
|
||||
.map(str::to_string)
|
||||
})
|
||||
.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
@@ -51,16 +51,13 @@ pub struct RebuildSummary {
|
||||
struct AvailRow {
|
||||
account_id: i64,
|
||||
model_name: String,
|
||||
oauth_route_unit_id: Option<i64>,
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// `route_channels` out of sync.
|
||||
pub async fn rebuild_routes(state: &AppState) -> AppResult<RebuildSummary> {
|
||||
let summary = rebuild_routes_inner(&state.db).await?;
|
||||
crate::proxy::token_router::router::invalidate_route_cache();
|
||||
Ok(summary)
|
||||
rebuild_routes_inner(&state.db).await
|
||||
}
|
||||
|
||||
/// Pool-only entry point. Identical to `rebuild_routes`, just decoupled from
|
||||
@@ -76,11 +73,10 @@ pub(crate) async fn rebuild_routes_inner(
|
||||
// lets us drop inactive accounts and apply site_disabled_models in one
|
||||
// query — keeping the in-memory set small for installs with hundreds of
|
||||
// accounts.
|
||||
let rows: Vec<(i64, String, Option<i64>)> = sqlx::query_as(
|
||||
"SELECT ma.account_id, ma.model_name, rum.unit_id
|
||||
let rows: Vec<(i64, String)> = sqlx::query_as(
|
||||
"SELECT ma.account_id, ma.model_name
|
||||
FROM model_availability ma
|
||||
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
|
||||
AND a.status = 'active'
|
||||
AND NOT EXISTS (
|
||||
@@ -95,10 +91,9 @@ pub(crate) async fn rebuild_routes_inner(
|
||||
|
||||
let avail: Vec<AvailRow> = rows
|
||||
.into_iter()
|
||||
.map(|(account_id, model_name, oauth_route_unit_id)| AvailRow {
|
||||
.map(|(account_id, model_name)| AvailRow {
|
||||
account_id,
|
||||
model_name,
|
||||
oauth_route_unit_id,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -115,16 +110,11 @@ pub(crate) async fn rebuild_routes_inner(
|
||||
|
||||
// 2. Group by model name. Each group becomes one token_routes row.
|
||||
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 {
|
||||
by_model
|
||||
.entry(row.model_name.clone())
|
||||
.or_default()
|
||||
.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
|
||||
@@ -219,41 +209,21 @@ pub(crate) async fn rebuild_routes_inner(
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let oauth_route_unit_id = route_unit_by_key.get(key).copied().flatten();
|
||||
sqlx::query(
|
||||
"INSERT INTO route_channels
|
||||
(route_id, account_id, token_id, source_model,
|
||||
oauth_route_unit_id, priority, weight, enabled, manual_override)
|
||||
VALUES (?, ?, ?, ?, ?, 0, 10, 1, 0)",
|
||||
priority, weight, enabled, manual_override)
|
||||
VALUES (?, ?, ?, ?, 0, 10, 1, 0)",
|
||||
)
|
||||
.bind(route_id)
|
||||
.bind(key.0)
|
||||
.bind(token_id)
|
||||
.bind(&key.1)
|
||||
.bind(oauth_route_unit_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
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
|
||||
// model, or the model is now in site_disabled_models).
|
||||
for (key, ch_id) in &auto_by_key {
|
||||
@@ -407,7 +377,6 @@ mod tests {
|
||||
route_id INTEGER NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
token_id INTEGER,
|
||||
oauth_route_unit_id INTEGER,
|
||||
source_model TEXT,
|
||||
priority INTEGER DEFAULT 0,
|
||||
weight INTEGER DEFAULT 10,
|
||||
@@ -420,20 +389,6 @@ mod tests {
|
||||
enabled INTEGER DEFAULT 1,
|
||||
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)
|
||||
.execute(&pool)
|
||||
@@ -567,69 +522,6 @@ mod tests {
|
||||
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
|
||||
/// `/routes` page, and clicking "rebuild" does nothing. Reproduces by
|
||||
/// pre-seeding `token_routes` rows that no account / availability row
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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())))
|
||||
}
|
||||
}
|
||||
+7
-110
@@ -160,23 +160,6 @@ async fn insert_asset(
|
||||
.execute(&*state.db)
|
||||
.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 {
|
||||
public_id: public_id.to_string(),
|
||||
file_name,
|
||||
@@ -186,51 +169,6 @@ 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(
|
||||
state: &AppState,
|
||||
input: WorkspaceAssetInput,
|
||||
@@ -250,7 +188,11 @@ pub async fn record_remote_asset(
|
||||
.await;
|
||||
};
|
||||
|
||||
match state.http.get(url).send().await {
|
||||
let client = reqwest::Client::builder()
|
||||
.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() => {
|
||||
let mime_type = response
|
||||
.headers()
|
||||
@@ -365,45 +307,6 @@ pub async fn record_bytes_asset(
|
||||
.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(
|
||||
state: &AppState,
|
||||
user_id: Option<i64>,
|
||||
@@ -435,10 +338,7 @@ pub async fn record_image_assets_from_response(
|
||||
prompt: prompt.clone(),
|
||||
model: model.clone(),
|
||||
remote_url: Some(url.trim().to_string()),
|
||||
metadata: json!({
|
||||
"workspaceAsset": { "role": "output" },
|
||||
"responseItem": item,
|
||||
}),
|
||||
metadata: json!({ "responseItem": item }),
|
||||
};
|
||||
match record_remote_asset(state, input).await {
|
||||
Ok(asset) => stored_assets.push(asset),
|
||||
@@ -457,10 +357,7 @@ pub async fn record_image_assets_from_response(
|
||||
prompt: prompt.clone(),
|
||||
model: model.clone(),
|
||||
remote_url: None,
|
||||
metadata: json!({
|
||||
"workspaceAsset": { "role": "output" },
|
||||
"responseItem": item,
|
||||
}),
|
||||
metadata: json!({ "responseItem": item }),
|
||||
};
|
||||
match record_base64_asset(state, input, b64).await {
|
||||
Ok(asset) => stored_assets.push(asset),
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,18 +1,7 @@
|
||||
import { act } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
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 { describe, expect, it, vi } from 'vitest';
|
||||
import { create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
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 {
|
||||
return (node.children || []).map((child) => {
|
||||
@@ -21,77 +10,6 @@ function collectText(node: ReactTestInstance): string {
|
||||
}).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', () => {
|
||||
it('renders a poster-style hero with a floating admin login panel', () => {
|
||||
const root = create(
|
||||
@@ -119,12 +37,12 @@ describe('Login surface', () => {
|
||||
));
|
||||
|
||||
expect(pageText).toContain('BossAI');
|
||||
expect(pageText).toContain('AI 应用平台');
|
||||
expect(pageText).toContain('聚合多模型多模态的 AI 应用平台');
|
||||
expect(pageText).not.toContain('一个 API Key,一个入口');
|
||||
expect(pageText).toContain('兼容 OpenAI / Claude Messages / Comfy Cloud / RunningHub / 火山方舟等多类上游');
|
||||
expect(pageText).toContain('统一 API 网关');
|
||||
expect(pageText).toContain('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API');
|
||||
expect(pageText).toContain('统一代理网关');
|
||||
expect(pageText).toContain('智能路由引擎');
|
||||
expect(pageText).toContain('工作流生产台');
|
||||
expect(pageText).toContain('自动模型发现');
|
||||
expect(lightBrandPanel).toBeTruthy();
|
||||
expect(authStage).toBeTruthy();
|
||||
expect(brandMarkCanvas).toBeTruthy();
|
||||
@@ -132,376 +50,15 @@ describe('Login surface', () => {
|
||||
node.type === 'input'
|
||||
&& 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) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('login-mode-tab')
|
||||
));
|
||||
expect(loginModeTabs.map((node) => collectText(node))).toEqual(['邮箱密码', 'API Key']);
|
||||
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');
|
||||
expect(tokenInput.props.type).toBe('password');
|
||||
expect(loginModeTabs.map((node) => collectText(node))).toEqual(['Auth Token', '账号密码']);
|
||||
} finally {
|
||||
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);');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
+4
-175
@@ -11,166 +11,17 @@ describe('App sidebar config', () => {
|
||||
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 accessGroupIndex = source.indexOf("label: '接入管理'");
|
||||
const consoleGroupIndex = source.indexOf("label: '控制台'");
|
||||
const downstreamIndex = source.indexOf("{ to: '/downstream-keys', label: '下游密钥'");
|
||||
const systemGroupIndex = source.indexOf("label: '系统'");
|
||||
|
||||
expect(accessGroupIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(downstreamIndex).toBeGreaterThan(accessGroupIndex);
|
||||
expect(consoleGroupIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(downstreamIndex).toBeGreaterThan(consoleGroupIndex);
|
||||
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', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||
|
||||
@@ -178,26 +29,4 @@ describe('App sidebar config', () => {
|
||||
expect(source).toContain("const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));");
|
||||
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 />} />');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
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);');
|
||||
});
|
||||
});
|
||||
@@ -1,685 +0,0 @@
|
||||
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,20 +3,16 @@ import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('App topbar tooltips', () => {
|
||||
it('removes hover-only tooltips from topbar and fixed desktop sidebar navigation', () => {
|
||||
it('removes topbar hover tooltips while preserving sidebar collapsed tooltips', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||
const topbarStart = source.indexOf('<div className="topbar-right">');
|
||||
const topbarEnd = source.indexOf('</header>');
|
||||
const sidebarItemStart = source.indexOf('const renderDesktopSidebarItem');
|
||||
const sidebarItemEnd = source.indexOf('return (', source.indexOf('<a'));
|
||||
|
||||
expect(topbarStart).toBeGreaterThanOrEqual(0);
|
||||
expect(topbarEnd).toBeGreaterThan(topbarStart);
|
||||
|
||||
const topbarSection = source.slice(topbarStart, topbarEnd);
|
||||
expect(topbarSection).not.toContain('data-tooltip=');
|
||||
expect(source).not.toContain('data-tooltip={sidebarCollapsed');
|
||||
expect(sidebarItemStart).toBeGreaterThanOrEqual(0);
|
||||
expect(sidebarItemEnd).toBeGreaterThan(sidebarItemStart);
|
||||
expect(source).toContain("data-tooltip={sidebarCollapsed ? t(item.label) : undefined}");
|
||||
});
|
||||
});
|
||||
|
||||
+164
-833
File diff suppressed because it is too large
Load Diff
+2
-385
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { api, buildSiteProbeStreamUrl, userApi, type ProxyTestRequestEnvelope } from './api.js';
|
||||
import { commitSession, persistAuthSession } from './authSession.js';
|
||||
import { api, type ProxyTestRequestEnvelope } from './api.js';
|
||||
import { persistAuthSession } from './authSession.js';
|
||||
|
||||
function createMemoryStorage() {
|
||||
const store = new Map<string, string>();
|
||||
@@ -122,20 +122,6 @@ describe('api proxy test timeout handling', () => {
|
||||
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 () => {
|
||||
installPendingFetch();
|
||||
|
||||
@@ -193,372 +179,3 @@ describe('api proxy test timeout handling', () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+58
-460
@@ -11,22 +11,6 @@ type RequestOptions = RequestInit & {
|
||||
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 {
|
||||
const token = getSessionToken('admin', localStorage);
|
||||
if (!token) {
|
||||
@@ -44,15 +28,13 @@ function requireAuthToken(): string {
|
||||
return token;
|
||||
}
|
||||
|
||||
async function extractResponseError(res: Response): Promise<ApiRequestError> {
|
||||
async function extractResponseErrorMessage(res: Response): Promise<string> {
|
||||
let message = `HTTP ${res.status}`;
|
||||
let payload: any = null;
|
||||
try {
|
||||
const text = await res.text();
|
||||
if (text) {
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
payload = json;
|
||||
if (json?.message && typeof json.message === "string") {
|
||||
message = json.message;
|
||||
} else if (json?.error && typeof json.error === "string") {
|
||||
@@ -65,19 +47,12 @@ async function extractResponseError(res: Response): Promise<ApiRequestError> {
|
||||
} else {
|
||||
message = `${message}: ${text.slice(0, 120)}`;
|
||||
}
|
||||
if (typeof json?.path === "string" && json.path.trim()) {
|
||||
message = `${message} (${json.path.trim()})`;
|
||||
}
|
||||
} catch {
|
||||
message = `${message}: ${text.slice(0, 120)}`;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return createApiRequestError(message, res.status, payload);
|
||||
}
|
||||
|
||||
async function extractResponseErrorMessage(res: Response): Promise<string> {
|
||||
return (await extractResponseError(res)).message;
|
||||
return message;
|
||||
}
|
||||
|
||||
async function parseJsonResponse<T = any>(res: Response): Promise<T> {
|
||||
@@ -273,54 +248,13 @@ export async function fetchSessionResponse(
|
||||
: 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>(
|
||||
url: string,
|
||||
options: RequestOptions = {},
|
||||
): Promise<T> {
|
||||
const res = await fetchUserResponse(url, options);
|
||||
if (!res.ok) {
|
||||
throw await extractResponseError(res);
|
||||
throw new Error(await extractResponseErrorMessage(res));
|
||||
}
|
||||
return parseJsonResponse<T>(res);
|
||||
}
|
||||
@@ -331,7 +265,7 @@ async function request<T = any>(
|
||||
): Promise<T> {
|
||||
const res = await fetchAuthenticatedResponse(url, options);
|
||||
if (!res.ok) {
|
||||
throw await extractResponseError(res);
|
||||
throw new Error(await extractResponseErrorMessage(res));
|
||||
}
|
||||
return parseJsonResponse<T>(res);
|
||||
}
|
||||
@@ -365,20 +299,6 @@ 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 {
|
||||
if (!row || typeof row !== "object") return row;
|
||||
return {
|
||||
@@ -472,11 +392,6 @@ function normalizeProxyLogRow(row: any): any {
|
||||
clientAppId: row.clientAppId ?? row.client_app_id ?? null,
|
||||
clientAppName: row.clientAppName ?? row.client_app_name ?? 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,
|
||||
};
|
||||
}
|
||||
@@ -554,144 +469,6 @@ 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(
|
||||
url: string,
|
||||
handlers: {
|
||||
@@ -915,8 +692,6 @@ export type RuntimeSettingsPayload = {
|
||||
barkUrl?: string;
|
||||
webhookEnabled?: boolean;
|
||||
barkEnabled?: boolean;
|
||||
feishuEnabled?: boolean;
|
||||
feishuWebhook?: string;
|
||||
serverChanEnabled?: boolean;
|
||||
serverChanKey?: string;
|
||||
telegramEnabled?: boolean;
|
||||
@@ -1009,10 +784,6 @@ export type ProxyLogListItem = {
|
||||
promptTokens?: number | null;
|
||||
completionTokens?: number | null;
|
||||
estimatedCost?: number | null;
|
||||
billingDetails?: ProxyLogBillingDetails;
|
||||
contentSummary?: string | null;
|
||||
requestContent?: string | null;
|
||||
responseContent?: string | null;
|
||||
};
|
||||
|
||||
export type ProxyLogDetail = ProxyLogListItem & {
|
||||
@@ -1037,7 +808,6 @@ export type ProxyLogsQuery = {
|
||||
search?: string;
|
||||
client?: string;
|
||||
siteId?: number;
|
||||
downstreamKeyId?: number;
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
@@ -1224,8 +994,6 @@ export type OAuthConnectionInfo = {
|
||||
modelsPreview: string[];
|
||||
status: "healthy" | "abnormal";
|
||||
quota?: OAuthQuotaInfo | null;
|
||||
expiresAt?: string | null;
|
||||
tokenExpiresAt?: number | null;
|
||||
routeChannelCount?: number;
|
||||
lastModelSyncAt?: string | null;
|
||||
lastModelSyncError?: string | null;
|
||||
@@ -1258,12 +1026,11 @@ export type OAuthQuotaBatchRefreshResponse = {
|
||||
export type OAuthImportResponse = {
|
||||
success: boolean;
|
||||
imported: number;
|
||||
updated?: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
items: Array<{
|
||||
name: string;
|
||||
status: "imported" | "updated" | "skipped" | "failed";
|
||||
status: "imported" | "skipped" | "failed";
|
||||
accountId?: number;
|
||||
provider?: string;
|
||||
message?: string;
|
||||
@@ -1417,22 +1184,6 @@ export type DigitalHumanJobsResponse = {
|
||||
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 = {
|
||||
@@ -1445,165 +1196,6 @@ export type MoneyPrinterJobsResponse = {
|
||||
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 = {
|
||||
// Sites
|
||||
getSites: async () => {
|
||||
@@ -1633,14 +1225,12 @@ export const api = {
|
||||
}),
|
||||
getSiteAvailableModels: (siteId: number) =>
|
||||
request(`/api/sites/${siteId}/available-models`),
|
||||
probeSiteNow: (siteId: number, options?: SiteProbeOptions) =>
|
||||
probeSiteNow: (siteId: number, options?: { scope?: 'single' | 'all'; modelName?: string; latencyThresholdMs?: number }) =>
|
||||
request(`/api/sites/${siteId}/probe-now`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(options || {}),
|
||||
timeoutMs: options?.scope === 'all' ? 120_000 : 30_000,
|
||||
}),
|
||||
probeSiteStream: (siteId: number, options?: SiteProbeOptions, signal?: AbortSignal) =>
|
||||
fetchSiteProbeStream(siteId, options, signal),
|
||||
|
||||
// Accounts
|
||||
getAccounts: async (params?: { includeOauth?: boolean }) => {
|
||||
@@ -2020,8 +1610,7 @@ export const api = {
|
||||
|
||||
// OAuth
|
||||
getOAuthProviders: () =>
|
||||
request("/api/oauth/providers")
|
||||
.then(normalizeOAuthProvidersResponse) as Promise<OAuthProvidersResponse>,
|
||||
request("/api/oauth/providers") as Promise<OAuthProvidersResponse>,
|
||||
startOAuthProvider: (
|
||||
provider: string,
|
||||
data?: {
|
||||
@@ -2050,14 +1639,14 @@ export const api = {
|
||||
getOAuthConnections: (params?: { limit?: number; offset?: number }) =>
|
||||
request(
|
||||
`/api/oauth/connections${buildQueryString(params)}`,
|
||||
).then(normalizeOAuthConnectionsResponse) as Promise<OAuthConnectionsResponse>,
|
||||
) as Promise<OAuthConnectionsResponse>,
|
||||
refreshOAuthConnectionQuota: (accountId: number) =>
|
||||
request(`/api/oauth/connections/${accountId}/refresh-quota`, {
|
||||
request(`/api/oauth/connections/${accountId}/quota/refresh`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
}) as Promise<{ success: true; quota: OAuthQuotaInfo }>,
|
||||
refreshOAuthConnectionQuotaBatch: (accountIds: number[]) =>
|
||||
request("/api/oauth/connections/refresh-quota-batch", {
|
||||
request("/api/oauth/connections/quota/refresh-batch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ accountIds }),
|
||||
}) as Promise<OAuthQuotaBatchRefreshResponse>,
|
||||
@@ -2066,7 +1655,7 @@ export const api = {
|
||||
data: { proxyUrl?: string | null; useSystemProxy?: boolean },
|
||||
) =>
|
||||
request(`/api/oauth/connections/${accountId}/proxy`, {
|
||||
method: "POST",
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data || {}),
|
||||
}) as Promise<{ success: true }>,
|
||||
rebindOAuthConnection: (
|
||||
@@ -2095,18 +1684,6 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}) 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) =>
|
||||
request(`/api/oauth/route-units/${routeUnitId}`, {
|
||||
method: "DELETE",
|
||||
@@ -2181,6 +1758,43 @@ export const api = {
|
||||
method: "PUT",
|
||||
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) =>
|
||||
request("/api/settings/system-proxy/test", {
|
||||
method: "POST",
|
||||
@@ -2313,6 +1927,15 @@ export const api = {
|
||||
testNotification: () =>
|
||||
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
|
||||
getModelsMarketplace: (options?: {
|
||||
refresh?: boolean;
|
||||
@@ -2438,8 +2061,6 @@ export const api = {
|
||||
}),
|
||||
getWorkflowNodeTypes: () =>
|
||||
request<{ success: boolean; nodeTypes: any[] }>("/api/workflow/node-types"),
|
||||
getWorkflowModelFolder: (folder: string) =>
|
||||
workflowModelFolderRequest(request, folder),
|
||||
getWorkflows: (params?: { include_public?: boolean }) =>
|
||||
request<{ success: boolean; items: WorkflowSummary[] }>(`/api/workflows${buildQueryString(params)}`),
|
||||
createWorkflow: (data: WorkflowSavePayload) =>
|
||||
@@ -2458,8 +2079,6 @@ export const api = {
|
||||
request<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/workflow-runs${buildQueryString(params)}`),
|
||||
getWorkflowRun: (publicId: string) =>
|
||||
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",
|
||||
@@ -2474,8 +2093,6 @@ export const api = {
|
||||
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",
|
||||
@@ -2514,23 +2131,10 @@ export const userApi = {
|
||||
userRequest(`/api/user-api-keys/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
deleteApiKey: (id: number) =>
|
||||
userRequest(`/api/user-api-keys/${id}`, { method: "DELETE" }),
|
||||
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 }) =>
|
||||
getLogs: (params?: { limit?: number; offset?: number; page?: number; page_size?: number; q?: string; status?: string; date_from?: string; date_to?: string }) =>
|
||||
userRequest(`/api/user/logs${buildQueryString(params)}`),
|
||||
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?: {
|
||||
refresh?: boolean;
|
||||
includePricing?: boolean;
|
||||
@@ -2560,8 +2164,6 @@ export const userApi = {
|
||||
}),
|
||||
getWorkflowNodeTypes: () =>
|
||||
userRequest<{ success: boolean; nodeTypes: any[] }>("/api/user/workflow/node-types"),
|
||||
getWorkflowModelFolder: (folder: string) =>
|
||||
workflowModelFolderRequest(userRequest, folder),
|
||||
getWorkflows: (params?: { include_public?: boolean }) =>
|
||||
userRequest<{ success: boolean; items: WorkflowSummary[] }>(`/api/user/workflows${buildQueryString(params)}`),
|
||||
createWorkflow: (data: WorkflowSavePayload) =>
|
||||
@@ -2580,8 +2182,6 @@ export const userApi = {
|
||||
userRequest<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/user/workflow-runs${buildQueryString(params)}`),
|
||||
getWorkflowRun: (publicId: string) =>
|
||||
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",
|
||||
@@ -2596,8 +2196,6 @@ export const userApi = {
|
||||
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",
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { clearSession } from './authSession.js';
|
||||
|
||||
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 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 LEGACY_FIRST_USE_DOC_REMINDER_KEY = 'metapi_first_use_docs_reminder_seen_v1';
|
||||
|
||||
type StorageLike = {
|
||||
getItem?: (key: string) => string | null;
|
||||
@@ -26,10 +24,8 @@ export function clearAppInstallationState(storage?: StorageLike | null): void {
|
||||
if (!target) return;
|
||||
clearSession('admin', target as never);
|
||||
target.removeItem(THEME_MODE_STORAGE_KEY);
|
||||
target.removeItem(THEME_PRESET_STORAGE_KEY);
|
||||
target.removeItem(LEGACY_THEME_STORAGE_KEY);
|
||||
target.removeItem(USER_PROFILE_STORAGE_KEY);
|
||||
target.removeItem(ADMIN_PROFILE_STORAGE_KEY);
|
||||
target.removeItem(FIRST_USE_DOC_REMINDER_KEY);
|
||||
target.removeItem(LEGACY_FIRST_USE_DOC_REMINDER_KEY);
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
|
||||
type MotionVariant = 'page' | 'login';
|
||||
|
||||
interface MotionDivProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
motionKey?: string;
|
||||
motionVariant?: MotionVariant;
|
||||
}
|
||||
|
||||
const PAGE_TARGET_SELECTOR = [
|
||||
':scope > .console-hero',
|
||||
':scope > .page-header',
|
||||
':scope > .card',
|
||||
':scope > .mobile-card',
|
||||
':scope > .workflow-shell',
|
||||
':scope > .picture-agent-shell',
|
||||
':scope > .workspace-layout',
|
||||
':scope > .dashboard-grid',
|
||||
':scope > .dashboard-war-room',
|
||||
':scope > .dashboard-command-strip',
|
||||
':scope > .digital-human-layout',
|
||||
':scope > .money-printer-layout',
|
||||
':scope .console-hero',
|
||||
':scope .page-header',
|
||||
':scope .stat-card',
|
||||
':scope .chart-container',
|
||||
':scope .dashboard-panel',
|
||||
':scope .dashboard-grid',
|
||||
':scope .dashboard-pulse-card',
|
||||
':scope .dashboard-risk-panel',
|
||||
':scope .dashboard-command-item',
|
||||
':scope .ops-monitor-log-card',
|
||||
':scope .card',
|
||||
':scope .mobile-card',
|
||||
':scope .data-table',
|
||||
':scope .workflow-shell',
|
||||
':scope .workspace-layout',
|
||||
':scope .picture-agent-shell',
|
||||
':scope .digital-human-layout',
|
||||
':scope .money-printer-layout',
|
||||
':scope .settings-card',
|
||||
':scope .settings-section-nav',
|
||||
':scope .admin-ops-toolbar',
|
||||
':scope .responsive-filter-panel',
|
||||
':scope .responsive-batch-action-bar',
|
||||
].join(', ');
|
||||
|
||||
const LOGIN_TARGET_SELECTOR = [
|
||||
'.login-brand-header',
|
||||
'.login-brand-copy-block',
|
||||
'.login-compat-line',
|
||||
'.login-capability-row',
|
||||
'.login-auth-eyebrow',
|
||||
'.login-auth-title',
|
||||
'.login-auth-copy',
|
||||
'.login-mode-tabs',
|
||||
'.login-auth-panel form',
|
||||
'.login-auth-footer',
|
||||
].join(', ');
|
||||
|
||||
function selectMotionTargets(root: HTMLDivElement, variant: MotionVariant, limit: number): Element[] {
|
||||
const selector = variant === 'login' ? LOGIN_TARGET_SELECTOR : PAGE_TARGET_SELECTOR;
|
||||
const selected: Element[] = [];
|
||||
|
||||
for (const element of Array.from(root.querySelectorAll(selector))) {
|
||||
if (selected.some((selectedElement) => selectedElement.contains(element))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selected.push(element);
|
||||
if (selected.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
export default function MotionDiv({
|
||||
children,
|
||||
className,
|
||||
motionKey,
|
||||
motionVariant = 'page',
|
||||
...props
|
||||
}: MotionDivProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const resolvedMotionKey = motionKey || motionVariant;
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return undefined;
|
||||
|
||||
const mm = gsap.matchMedia();
|
||||
mm.add(
|
||||
{
|
||||
reduceMotion: '(prefers-reduced-motion: reduce)',
|
||||
isDesktop: '(min-width: 900px)',
|
||||
},
|
||||
(context) => {
|
||||
const conditions = context.conditions || {};
|
||||
const targets = selectMotionTargets(
|
||||
root,
|
||||
motionVariant,
|
||||
motionVariant === 'login' ? 14 : (conditions.isDesktop ? 26 : 16),
|
||||
);
|
||||
|
||||
if (conditions.reduceMotion) {
|
||||
gsap.set([root, ...targets], { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const distance = motionVariant === 'login' ? 16 : (conditions.isDesktop ? 12 : 8);
|
||||
const timeline = gsap.timeline({
|
||||
defaults: {
|
||||
ease: 'power2.out',
|
||||
overwrite: 'auto',
|
||||
},
|
||||
});
|
||||
|
||||
timeline.fromTo(
|
||||
root,
|
||||
{ autoAlpha: 0, y: Math.max(6, distance - 4) },
|
||||
{
|
||||
autoAlpha: 1,
|
||||
y: 0,
|
||||
duration: motionVariant === 'login' ? 0.46 : 0.32,
|
||||
clearProps: 'transform,opacity,visibility',
|
||||
},
|
||||
);
|
||||
|
||||
if (targets.length) {
|
||||
timeline.fromTo(
|
||||
targets,
|
||||
{
|
||||
autoAlpha: 0,
|
||||
y: distance,
|
||||
scale: motionVariant === 'login' ? 0.992 : 0.996,
|
||||
},
|
||||
{
|
||||
autoAlpha: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
duration: motionVariant === 'login' ? 0.58 : 0.38,
|
||||
stagger: {
|
||||
each: motionVariant === 'login' ? 0.045 : 0.026,
|
||||
from: 'start',
|
||||
},
|
||||
clearProps: 'transform,opacity,visibility',
|
||||
},
|
||||
motionVariant === 'login' ? '-=0.2' : '-=0.18',
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
timeline.kill();
|
||||
gsap.killTweensOf([root, ...targets]);
|
||||
gsap.set([root, ...targets], { clearProps: 'transform,opacity,visibility' });
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return () => mm.revert();
|
||||
}, [resolvedMotionKey, motionVariant]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={['motion-root', className].filter(Boolean).join(' ')}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export function useIconCdn() {
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDark(document.documentElement.getAttribute('data-theme') === 'dark');
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-theme-preset'] });
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
return isDark ? ICON_CDN : ICON_CDN_LIGHT;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useEffect, useId, useRef } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { trapFocusInContainer } from './focusTrap.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
|
||||
type CenteredModalProps = {
|
||||
@@ -29,9 +28,6 @@ export default function CenteredModal({
|
||||
showCloseButton = true,
|
||||
}: CenteredModalProps) {
|
||||
const presence = useAnimatedVisibility(open, 220);
|
||||
const titleId = useId();
|
||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastActiveElementRef = useRef<HTMLElement | null>(null);
|
||||
const canUsePortal = typeof document !== 'undefined'
|
||||
&& !!document.body
|
||||
&& typeof document.body.appendChild === 'function'
|
||||
@@ -47,52 +43,9 @@ export default function CenteredModal({
|
||||
}, [canUsePortal, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const activeElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
lastActiveElementRef.current = typeof HTMLElement !== 'undefined' && activeElement instanceof HTMLElement
|
||||
? activeElement
|
||||
: null;
|
||||
const focusDialog = () => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
const currentActiveElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
if (typeof Node !== 'undefined' && currentActiveElement instanceof Node && dialog.contains(currentActiveElement)) return;
|
||||
dialog.focus({ preventScroll: true });
|
||||
};
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||
const animationFrame = window.requestAnimationFrame(focusDialog);
|
||||
return () => {
|
||||
if (typeof window.cancelAnimationFrame === 'function') {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
};
|
||||
}
|
||||
focusDialog();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (lastActiveElementRef.current?.isConnected !== false) {
|
||||
lastActiveElementRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
lastActiveElementRef.current = null;
|
||||
return undefined;
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (lastActiveElementRef.current?.isConnected !== false) {
|
||||
lastActiveElementRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
lastActiveElementRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !canUsePortal) return;
|
||||
if (!open || !closeOnEscape || !canUsePortal) return;
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && closeOnEscape) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
trapFocusInContainer(event, dialogRef.current);
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
@@ -102,33 +55,24 @@ export default function CenteredModal({
|
||||
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const interactive = open && presence.isVisible;
|
||||
|
||||
const modal = (
|
||||
<div
|
||||
className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
onClick={closeOnBackdrop ? onClose : undefined}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-hidden={!interactive}
|
||||
tabIndex={-1}
|
||||
style={{ maxWidth }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<div id={titleId} className="modal-title">{title}</div>
|
||||
<div className="modal-title">{title}</div>
|
||||
{showCloseButton ? (
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close-button"
|
||||
onClick={onClose}
|
||||
aria-label="关闭弹框"
|
||||
tabIndex={interactive ? 0 : -1}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from './Toast.js';
|
||||
import { commitSession } from '../authSession.js';
|
||||
import CenteredModal from './CenteredModal.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
|
||||
export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const presence = useAnimatedVisibility(open, 200);
|
||||
const [oldToken, setOldToken] = useState('');
|
||||
const [newToken, setNewToken] = useState('');
|
||||
const [confirmToken, setConfirmToken] = useState('');
|
||||
@@ -12,6 +14,15 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
|
||||
const [error, setError] = useState('');
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || typeof document === 'undefined') return;
|
||||
const previous = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = previous;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('');
|
||||
if (!oldToken || !newToken || !confirmToken) {
|
||||
@@ -47,30 +58,20 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
|
||||
}
|
||||
};
|
||||
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none',
|
||||
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
|
||||
};
|
||||
|
||||
return (
|
||||
<CenteredModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="修改管理员 Token"
|
||||
maxWidth={420}
|
||||
closeOnBackdrop
|
||||
closeOnEscape
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
footer={(
|
||||
<>
|
||||
<button type="button" onClick={onClose} className="btn btn-ghost">取消</button>
|
||||
<button type="button" onClick={handleSubmit} disabled={saving} className="btn btn-primary">
|
||||
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />更新中...</> : '确认修改'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
const modal = (
|
||||
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
|
||||
<div className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={e => e.stopPropagation()} style={{ maxWidth: 420 }}>
|
||||
<div className="modal-header">修改管理员 Token</div>
|
||||
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>旧 Token</label>
|
||||
<input
|
||||
@@ -107,6 +108,17 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</CenteredModal>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button onClick={onClose} className="btn btn-ghost">取消</button>
|
||||
<button onClick={handleSubmit} disabled={saving} className="btn btn-primary">
|
||||
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />更新中...</> : '确认修改'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
||||
}
|
||||
|
||||
@@ -28,12 +28,11 @@ export default function DeleteConfirmModal({
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
maxWidth={560}
|
||||
closeOnEscape
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
footer={(
|
||||
<>
|
||||
<button type="button" onClick={onClose} className="btn btn-ghost" disabled={loading}>{cancelText}</button>
|
||||
<button type="button" onClick={onConfirm} className="btn btn-danger" disabled={loading}>
|
||||
<button onClick={onClose} className="btn btn-ghost" disabled={loading}>{cancelText}</button>
|
||||
<button onClick={onConfirm} className="btn btn-danger" disabled={loading}>
|
||||
{loading
|
||||
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 删除中...</>
|
||||
: confirmText}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { create, type ReactTestInstance } from "react-test-renderer";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import DiagnosticActionGroup from "./DiagnosticActionGroup.js";
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || [])
|
||||
.map((child) => (typeof child === "string" ? child : collectText(child)))
|
||||
.join("");
|
||||
}
|
||||
|
||||
describe("DiagnosticActionGroup", () => {
|
||||
it("renders router links, buttons, and muted hints as a compact action group", () => {
|
||||
const onClick = vi.fn();
|
||||
const root = create(
|
||||
<MemoryRouter>
|
||||
<DiagnosticActionGroup
|
||||
actions={[
|
||||
{ label: "查看失败日志", href: "/logs?status=failed", tone: "danger" },
|
||||
{ label: "重新绑定", onClick, hint: "更新连接凭证" },
|
||||
{ label: "等待首次调用", hint: "首次代理请求后自动更新", tone: "muted" },
|
||||
]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const links = root.root.findAll((node) => node.type === "a");
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]!.props.href).toBe("/logs?status=failed");
|
||||
|
||||
const button = root.root.find((node) => node.type === "button");
|
||||
expect(collectText(button)).toBe("重新绑定");
|
||||
button.props.onClick({ stopPropagation: vi.fn() });
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(collectText(root.root)).toContain("等待首次调用");
|
||||
expect(collectText(root.root)).toContain("首次代理请求后自动更新");
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
it("falls back to normal anchors outside a router", () => {
|
||||
const root = create(
|
||||
<DiagnosticActionGroup
|
||||
actions={[{ label: "日志", href: "/logs" }]}
|
||||
/>,
|
||||
);
|
||||
const link = root.root.find((node) => node.type === "a");
|
||||
expect(link.props.href).toBe("/logs");
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
it("keeps nested action clicks from bubbling into parent row handlers", () => {
|
||||
const parentClick = vi.fn();
|
||||
const stopPropagation = vi.fn();
|
||||
const root = create(
|
||||
<div onClick={parentClick}>
|
||||
<MemoryRouter>
|
||||
<DiagnosticActionGroup
|
||||
actions={[{ label: "日志", href: "/logs" }]}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</div>,
|
||||
);
|
||||
|
||||
const link = root.root.find((node) => node.type === "a");
|
||||
link.props.onClick({ stopPropagation });
|
||||
|
||||
expect(stopPropagation).toHaveBeenCalledTimes(1);
|
||||
expect(parentClick).not.toHaveBeenCalled();
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
it("renders disabled actions as inert status pills", () => {
|
||||
const onClick = vi.fn();
|
||||
const root = create(
|
||||
<DiagnosticActionGroup
|
||||
actions={[{ label: "复制验证结果", onClick, disabled: true, hint: "等待一次验证回复" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(root.root.findAll((node) => node.type === "button")).toHaveLength(0);
|
||||
const disabledAction = root.root.find((node) => (
|
||||
node.type === "span"
|
||||
&& node.props["aria-disabled"] === "true"
|
||||
));
|
||||
expect(collectText(disabledAction)).toBe("复制验证结果");
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Link, useInRouterContext } from "react-router-dom";
|
||||
|
||||
export type DiagnosticAction = {
|
||||
label: string;
|
||||
hint?: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
tone?: "primary" | "danger" | "muted";
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type DiagnosticActionGroupProps = {
|
||||
actions: DiagnosticAction[];
|
||||
className?: string;
|
||||
interactive?: boolean;
|
||||
};
|
||||
|
||||
export default function DiagnosticActionGroup({
|
||||
actions,
|
||||
className = "",
|
||||
interactive = true,
|
||||
}: DiagnosticActionGroupProps) {
|
||||
const inRouter = useInRouterContext();
|
||||
const visibleActions = actions.filter(
|
||||
(action) => action.label && (action.href || action.onClick || action.hint),
|
||||
);
|
||||
if (visibleActions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<span className={`diagnostic-action-group ${className}`.trim()}>
|
||||
{visibleActions.map((action) => {
|
||||
const tone = action.tone || "primary";
|
||||
const key = `${action.label}-${action.href || action.hint || "action"}`;
|
||||
const body = action.disabled ? (
|
||||
<span
|
||||
className="diagnostic-action is-disabled"
|
||||
data-tone={tone}
|
||||
aria-disabled="true"
|
||||
>
|
||||
{action.label}
|
||||
</span>
|
||||
) : action.href ? (
|
||||
inRouter ? (
|
||||
<Link
|
||||
className="diagnostic-action"
|
||||
data-tone={tone}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
tabIndex={interactive ? undefined : -1}
|
||||
to={action.href}
|
||||
>
|
||||
{action.label}
|
||||
</Link>
|
||||
) : (
|
||||
<a
|
||||
className="diagnostic-action"
|
||||
data-tone={tone}
|
||||
href={action.href}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
tabIndex={interactive ? undefined : -1}
|
||||
>
|
||||
{action.label}
|
||||
</a>
|
||||
)
|
||||
) : action.onClick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="diagnostic-action"
|
||||
data-tone={tone}
|
||||
disabled={false}
|
||||
onClick={(event) => {
|
||||
event?.stopPropagation?.();
|
||||
action.onClick?.();
|
||||
}}
|
||||
tabIndex={interactive ? undefined : -1}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
) : (
|
||||
<span className="diagnostic-action is-muted" data-tone="muted">
|
||||
{action.label}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<span className="diagnostic-action-item" key={key}>
|
||||
{body}
|
||||
{action.hint ? (
|
||||
<span className="diagnostic-action-hint">{action.hint}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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('ListPresenceMotion animation contract', () => {
|
||||
it('uses GSAP matchMedia with reduced-motion cleanup for async list transitions', () => {
|
||||
const source = readSource('src/web/components/ListPresenceMotion.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(animatedNodes, { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });");
|
||||
expect(source).toContain('timeline.fromTo(');
|
||||
expect(source).toContain('stagger: targets.length > 1');
|
||||
expect(source).toContain('timeline.kill();');
|
||||
expect(source).toContain('gsap.killTweensOf(animatedNodes);');
|
||||
expect(source).toContain('return () => mm.revert();');
|
||||
});
|
||||
|
||||
it('keeps TokenRoutes full rendering while animating the loading-to-ready list swap', () => {
|
||||
const source = readSource('src/web/pages/TokenRoutes.tsx');
|
||||
|
||||
expect(source).toContain("import ListPresenceMotion from '../components/ListPresenceMotion.js';");
|
||||
expect(source).toContain('<ListPresenceMotion');
|
||||
expect(source).toContain("targetSelector=\".route-loading-card, .route-card-grid > *, .mobile-card-list > *\"");
|
||||
expect(source).toContain('maxTargets={isMobile ? 16 : 24}');
|
||||
expect(source).toContain("motionKey={`${initialLoading ? 'loading' : 'ready'}:${routeSummaries.length}:${isMobile ? 'mobile' : 'desktop'}`}");
|
||||
expect(source).toContain('() => filteredRoutes,\n [filteredRoutes],');
|
||||
expect(source).not.toContain('visibleRouteCount');
|
||||
expect(source).not.toContain('当前已加载路由');
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
|
||||
interface ListPresenceMotionProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
motionKey: string;
|
||||
targetSelector?: string;
|
||||
maxTargets?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TARGET_SELECTOR = ':scope > *';
|
||||
|
||||
function selectPresenceTargets(root: HTMLDivElement, selector: string, limit: number): Element[] {
|
||||
const selected: Element[] = [];
|
||||
|
||||
for (const element of Array.from(root.querySelectorAll(selector))) {
|
||||
if (selected.some((selectedElement) => selectedElement.contains(element))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selected.push(element);
|
||||
if (selected.length >= limit) break;
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
export default function ListPresenceMotion({
|
||||
children,
|
||||
className,
|
||||
motionKey,
|
||||
targetSelector = DEFAULT_TARGET_SELECTOR,
|
||||
maxTargets = 24,
|
||||
...props
|
||||
}: ListPresenceMotionProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return undefined;
|
||||
|
||||
const mm = gsap.matchMedia();
|
||||
mm.add(
|
||||
{
|
||||
reduceMotion: '(prefers-reduced-motion: reduce)',
|
||||
isDesktop: '(min-width: 900px)',
|
||||
},
|
||||
(context) => {
|
||||
const conditions = context.conditions || {};
|
||||
const targets = selectPresenceTargets(
|
||||
root,
|
||||
targetSelector,
|
||||
Math.max(1, maxTargets),
|
||||
);
|
||||
const animatedNodes = targets.length ? targets : [root];
|
||||
|
||||
if (conditions.reduceMotion) {
|
||||
gsap.set(animatedNodes, { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timeline = gsap.timeline({
|
||||
defaults: {
|
||||
ease: 'power2.out',
|
||||
overwrite: 'auto',
|
||||
},
|
||||
});
|
||||
const distance = conditions.isDesktop ? 10 : 7;
|
||||
|
||||
timeline.fromTo(
|
||||
animatedNodes,
|
||||
{
|
||||
autoAlpha: 0,
|
||||
y: distance,
|
||||
},
|
||||
{
|
||||
autoAlpha: 1,
|
||||
y: 0,
|
||||
duration: conditions.isDesktop ? 0.32 : 0.26,
|
||||
stagger: targets.length > 1
|
||||
? { each: conditions.isDesktop ? 0.018 : 0.014, from: 'start' }
|
||||
: 0,
|
||||
clearProps: 'transform,opacity,visibility',
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
timeline.kill();
|
||||
gsap.killTweensOf(animatedNodes);
|
||||
gsap.set(animatedNodes, { clearProps: 'transform,opacity,visibility' });
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return () => mm.revert();
|
||||
}, [maxTargets, motionKey, targetSelector]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={['list-presence-motion', className].filter(Boolean).join(' ')}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,8 @@ type MobileBatchBarProps = {
|
||||
|
||||
export default function MobileBatchBar({ info, children }: MobileBatchBarProps) {
|
||||
return (
|
||||
<div
|
||||
className="mobile-actions-bar mobile-batch-bar responsive-batch-action-bar"
|
||||
>
|
||||
<span className="mobile-actions-info" role="status" aria-live="polite">{info}</span>
|
||||
<div className="mobile-actions-bar mobile-batch-bar">
|
||||
<span className="mobile-actions-info">{info}</span>
|
||||
<div className="mobile-actions-row">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React, { useCallback, useEffect, useId, useRef } from 'react';
|
||||
import React, { useEffect, useId, useMemo, useState, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { trapFocusInContainer } from './focusTrap.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
|
||||
type MobileDrawerProps = {
|
||||
open: boolean;
|
||||
@@ -10,7 +8,6 @@ type MobileDrawerProps = {
|
||||
title?: React.ReactNode;
|
||||
closeLabel?: string;
|
||||
side?: 'left' | 'right';
|
||||
panelId?: string;
|
||||
};
|
||||
|
||||
function MobileDrawer({
|
||||
@@ -20,15 +17,28 @@ function MobileDrawer({
|
||||
title,
|
||||
closeLabel = '关闭导航',
|
||||
side = 'left',
|
||||
panelId,
|
||||
}: MobileDrawerProps) {
|
||||
const presence = useAnimatedVisibility(open, 280);
|
||||
const [shouldRender, setShouldRender] = useState(open);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const titleId = useId();
|
||||
const labelledBy = title ? titleId : undefined;
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastActiveElementRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setShouldRender(true);
|
||||
setIsClosing(false);
|
||||
} else if (shouldRender) {
|
||||
setIsClosing(true);
|
||||
const timer = setTimeout(() => {
|
||||
setShouldRender(false);
|
||||
setIsClosing(false);
|
||||
}, 280);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
@@ -41,53 +51,12 @@ function MobileDrawer({
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const activeElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
lastActiveElementRef.current = typeof HTMLElement !== 'undefined' && activeElement instanceof HTMLElement
|
||||
? activeElement
|
||||
: null;
|
||||
const focusPanel = () => {
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
const currentActiveElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
if (typeof Node !== 'undefined' && currentActiveElement instanceof Node && panel.contains(currentActiveElement)) return;
|
||||
panel.focus({ preventScroll: true });
|
||||
};
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||
const animationFrame = window.requestAnimationFrame(focusPanel);
|
||||
return () => {
|
||||
if (typeof window.cancelAnimationFrame === 'function') {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
};
|
||||
}
|
||||
focusPanel();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (lastActiveElementRef.current?.isConnected !== false) {
|
||||
lastActiveElementRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
lastActiveElementRef.current = null;
|
||||
return undefined;
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (lastActiveElementRef.current?.isConnected !== false) {
|
||||
lastActiveElementRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
lastActiveElementRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || typeof document === 'undefined') return;
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
trapFocusInContainer(event, panelRef.current);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
@@ -95,34 +64,23 @@ function MobileDrawer({
|
||||
};
|
||||
}, [handleClose, open]);
|
||||
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const interactive = open && presence.isVisible;
|
||||
if (!shouldRender) return null;
|
||||
|
||||
const drawer = (
|
||||
<div className={`mobile-drawer-root ${presence.isVisible ? '' : 'is-closing'}`.trim()}>
|
||||
<div className={`mobile-drawer-root ${isClosing ? 'is-closing' : ''}`}>
|
||||
<div
|
||||
className="mobile-drawer-backdrop"
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
id={panelId}
|
||||
ref={panelRef}
|
||||
className={`mobile-drawer-panel mobile-drawer-panel-${side}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={labelledBy}
|
||||
aria-hidden={!interactive}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className={`mobile-drawer-panel mobile-drawer-panel-${side}`} role="dialog" aria-modal="true" aria-labelledby={labelledBy}>
|
||||
<div className="mobile-drawer-toolbar">
|
||||
{title ? (
|
||||
<div className="mobile-drawer-title" id={titleId}>
|
||||
{title}
|
||||
</div>
|
||||
) : <div />}
|
||||
<button type="button" className="mobile-drawer-close" onClick={handleClose} aria-label={closeLabel} tabIndex={interactive ? 0 : -1}>
|
||||
<button type="button" className="mobile-drawer-close" onClick={handleClose} aria-label={closeLabel}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@ type MobileFilterSheetProps = {
|
||||
onClose: () => void;
|
||||
title?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
panelId?: string;
|
||||
};
|
||||
|
||||
export default function MobileFilterSheet({
|
||||
@@ -14,10 +13,9 @@ export default function MobileFilterSheet({
|
||||
onClose,
|
||||
title = '筛选',
|
||||
children,
|
||||
panelId,
|
||||
}: MobileFilterSheetProps) {
|
||||
return (
|
||||
<MobileDrawer open={open} onClose={onClose} title={title} closeLabel="关闭筛选" panelId={panelId}>
|
||||
<MobileDrawer open={open} onClose={onClose} title={title} closeLabel="关闭筛选">
|
||||
<div className="mobile-filter-panel">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import ModelAnalysisPanel from './ModelAnalysisPanel.js';
|
||||
|
||||
@@ -19,27 +17,6 @@ function collectText(node: ReactTestInstance): string {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function panelData() {
|
||||
return {
|
||||
totals: {
|
||||
spend: 2,
|
||||
calls: 10,
|
||||
tokens: 3000,
|
||||
},
|
||||
spendDistribution: [{ model: 'gpt-5', spend: 2, calls: 10 }],
|
||||
spendTrend: [{ day: '2026-06-07', spend: 2 }],
|
||||
callsDistribution: [{ model: 'gpt-5', calls: 10, share: 1 }],
|
||||
callRanking: [{ model: 'gpt-5', calls: 10, successRate: 99, avgLatencyMs: 1200, spend: 2, tokens: 3000 }],
|
||||
};
|
||||
}
|
||||
|
||||
function keyEvent(key: string) {
|
||||
return {
|
||||
key,
|
||||
preventDefault: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ModelAnalysisPanel token summaries', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalGetComputedStyle = globalThis.getComputedStyle;
|
||||
@@ -89,151 +66,6 @@ describe('ModelAnalysisPanel token summaries', () => {
|
||||
root?.unmount();
|
||||
});
|
||||
|
||||
it('uses shrink-safe summary and legend layout classes', () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
act(() => {
|
||||
root = create(
|
||||
<ModelAnalysisPanel data={panelData()} />,
|
||||
);
|
||||
});
|
||||
|
||||
const summaryGrid = root.root.find((node) => node.props.className === 'model-analysis-summary-grid');
|
||||
expect(summaryGrid).toBeTruthy();
|
||||
|
||||
const legend = root.root.find((node) => node.props.className === 'model-analysis-legend');
|
||||
expect(legend).toBeTruthy();
|
||||
expect(root.root.findAll((node) => node.props.className === 'model-analysis-legend-item').length).toBeGreaterThan(0);
|
||||
expect(root.root.findAll((node) => node.props.className === 'model-analysis-legend-name').length).toBeGreaterThan(0);
|
||||
|
||||
root?.unmount();
|
||||
});
|
||||
|
||||
it('keeps model analysis CSS free of fixed three-column summary grids', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/components/ModelAnalysisPanel.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(source).toContain('className="model-analysis-summary-grid"');
|
||||
expect(source).toContain('className="model-analysis-legend"');
|
||||
expect(source).toContain('className="model-analysis-legend-name"');
|
||||
expect(source).not.toContain("gridTemplateColumns: 'repeat(3, 1fr)'");
|
||||
expect(source).not.toContain("maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'");
|
||||
|
||||
expect(css).toContain('.model-analysis-summary-grid {');
|
||||
expect(css).toContain('grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr));');
|
||||
expect(css).toContain('.model-analysis-legend-item {');
|
||||
expect(css).toContain('min-width: 0;');
|
||||
expect(css).toContain('.model-analysis-legend-name {');
|
||||
expect(css).toContain('max-width: min(150px, 52vw);');
|
||||
});
|
||||
|
||||
it('exposes chart mode switches as tabs with a labelled tab panel', () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
act(() => {
|
||||
root = create(
|
||||
<ModelAnalysisPanel data={panelData()} />,
|
||||
);
|
||||
});
|
||||
|
||||
const tablist = root.root.find((node) => node.props.role === 'tablist');
|
||||
expect(tablist.props['aria-label']).toBe('模型分析视图');
|
||||
|
||||
const tabs = root.root.findAll((node) => node.props.role === 'tab');
|
||||
expect(tabs).toHaveLength(4);
|
||||
expect(tabs[0].props['aria-selected']).toBe(true);
|
||||
expect(tabs[0].props['aria-controls']).toBe('model-analysis-panel-spend');
|
||||
expect(tabs.map((tab) => tab.props.tabIndex)).toEqual([0, -1, -1, -1]);
|
||||
|
||||
let panel = root.root.find((node) => node.props.role === 'tabpanel');
|
||||
expect(panel.props.id).toBe('model-analysis-panel-spend');
|
||||
expect(panel.props['aria-labelledby']).toBe('model-analysis-tab-spend');
|
||||
expect(panel.props.className).toBe('model-analysis-tab-panel');
|
||||
|
||||
act(() => {
|
||||
tabs[1].props.onClick();
|
||||
});
|
||||
|
||||
const updatedTabs = root.root.findAll((node) => node.props.role === 'tab');
|
||||
expect(updatedTabs[0].props['aria-selected']).toBe(false);
|
||||
expect(updatedTabs[1].props['aria-selected']).toBe(true);
|
||||
expect(updatedTabs.map((tab) => tab.props.tabIndex)).toEqual([-1, 0, -1, -1]);
|
||||
|
||||
panel = root.root.find((node) => node.props.role === 'tabpanel');
|
||||
expect(panel.props.id).toBe('model-analysis-panel-trend');
|
||||
expect(panel.props['aria-labelledby']).toBe('model-analysis-tab-trend');
|
||||
|
||||
root?.unmount();
|
||||
});
|
||||
|
||||
it('supports arrow Home and End keyboard navigation across chart tabs', () => {
|
||||
const focusCalls = {
|
||||
spend: vi.fn(),
|
||||
trend: vi.fn(),
|
||||
calls: vi.fn(),
|
||||
rank: vi.fn(),
|
||||
};
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
act(() => {
|
||||
root = create(
|
||||
<ModelAnalysisPanel data={panelData()} />,
|
||||
{
|
||||
createNodeMock: (element) => {
|
||||
if (element.type !== 'button') return null;
|
||||
const id = String(element.props.id || '');
|
||||
if (!id.startsWith('model-analysis-tab-')) return null;
|
||||
const key = id.replace('model-analysis-tab-', '') as keyof typeof focusCalls;
|
||||
return {
|
||||
focus: focusCalls[key],
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const tablist = root.root.find((node) => node.props.role === 'tablist');
|
||||
let tabs = root.root.findAll((node) => node.props.role === 'tab');
|
||||
|
||||
const arrowRightEvent = keyEvent('ArrowRight');
|
||||
act(() => {
|
||||
tablist.props.onKeyDown(arrowRightEvent);
|
||||
});
|
||||
|
||||
expect(arrowRightEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(focusCalls.trend).toHaveBeenCalledWith({ preventScroll: true });
|
||||
tabs = root.root.findAll((node) => node.props.role === 'tab');
|
||||
expect(tabs.map((tab) => tab.props['aria-selected'])).toEqual([false, true, false, false]);
|
||||
|
||||
const endEvent = keyEvent('End');
|
||||
act(() => {
|
||||
tablist.props.onKeyDown(endEvent);
|
||||
});
|
||||
|
||||
expect(endEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(focusCalls.rank).toHaveBeenCalledWith({ preventScroll: true });
|
||||
tabs = root.root.findAll((node) => node.props.role === 'tab');
|
||||
expect(tabs.map((tab) => tab.props['aria-selected'])).toEqual([false, false, false, true]);
|
||||
|
||||
const homeEvent = keyEvent('Home');
|
||||
act(() => {
|
||||
tablist.props.onKeyDown(homeEvent);
|
||||
});
|
||||
|
||||
expect(homeEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(focusCalls.spend).toHaveBeenCalledWith({ preventScroll: true });
|
||||
tabs = root.root.findAll((node) => node.props.role === 'tab');
|
||||
expect(tabs.map((tab) => tab.props['aria-selected'])).toEqual([true, false, false, false]);
|
||||
|
||||
const ignoredEvent = keyEvent('PageDown');
|
||||
act(() => {
|
||||
tablist.props.onKeyDown(ignoredEvent);
|
||||
});
|
||||
|
||||
expect(ignoredEvent.preventDefault).not.toHaveBeenCalled();
|
||||
root?.unmount();
|
||||
});
|
||||
|
||||
it('renders with fallback label color when browser theme APIs are unavailable', async () => {
|
||||
globalThis.document = {
|
||||
documentElement: {},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState, type KeyboardEvent } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { VChart } from '@visactor/react-vchart';
|
||||
import { InlineBrandIcon } from './BrandIcon.js';
|
||||
import { formatCompactTokenMetric } from '../numberFormat.js';
|
||||
@@ -64,15 +64,7 @@ function EmptyBlock() {
|
||||
|
||||
export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('spend');
|
||||
const tabButtonRefs = useRef<Record<TabKey, HTMLButtonElement | null>>({
|
||||
spend: null,
|
||||
trend: null,
|
||||
calls: null,
|
||||
rank: null,
|
||||
});
|
||||
const labelColor = useThemeLabelColor();
|
||||
const activeTabPanelId = `model-analysis-panel-${activeTab}`;
|
||||
const activeTabId = `model-analysis-tab-${activeTab}`;
|
||||
|
||||
const totals = {
|
||||
spend: toSafeNumber(data?.totals?.spend),
|
||||
@@ -127,38 +119,10 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
|
||||
if (!hasData) return <EmptyBlock />;
|
||||
|
||||
const activateTab = (tabKey: TabKey, options?: { focus?: boolean }) => {
|
||||
setActiveTab(tabKey);
|
||||
if (options?.focus) {
|
||||
tabButtonRefs.current[tabKey]?.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
const currentIndex = tabs.findIndex((tab) => tab.key === activeTab);
|
||||
if (currentIndex < 0) return;
|
||||
|
||||
let nextIndex = currentIndex;
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
||||
nextIndex = (currentIndex + 1) % tabs.length;
|
||||
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
||||
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
||||
} else if (event.key === 'Home') {
|
||||
nextIndex = 0;
|
||||
} else if (event.key === 'End') {
|
||||
nextIndex = tabs.length - 1;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
activateTab(tabs[nextIndex].key, { focus: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Summary Cards */}
|
||||
<div className="model-analysis-summary-grid">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 20 }}>
|
||||
<div className="stat-summary-card stat-summary-purple">
|
||||
<div className="stat-summary-card-label">总消耗</div>
|
||||
<div className="stat-summary-card-value">{formatCurrency(totals.spend)}</div>
|
||||
@@ -175,21 +139,12 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
|
||||
{/* Pill Tabs */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="pill-tabs" role="tablist" aria-label="模型分析视图" onKeyDown={handleTabKeyDown}>
|
||||
<div className="pill-tabs">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
ref={(node) => {
|
||||
tabButtonRefs.current[tab.key] = node;
|
||||
}}
|
||||
id={`model-analysis-tab-${tab.key}`}
|
||||
type="button"
|
||||
className={`pill-tab ${activeTab === tab.key ? 'active' : ''}`}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.key}
|
||||
aria-controls={`model-analysis-panel-${tab.key}`}
|
||||
tabIndex={activeTab === tab.key ? 0 : -1}
|
||||
onClick={() => activateTab(tab.key)}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
<span className="pill-tab-code">{tab.code}</span>
|
||||
{tab.label}
|
||||
@@ -199,23 +154,16 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
</div>
|
||||
|
||||
{/* Chart Content */}
|
||||
<div
|
||||
key={activeTab}
|
||||
id={activeTabPanelId}
|
||||
className="model-analysis-tab-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby={activeTabId}
|
||||
>
|
||||
{activeTab === 'spend' && (
|
||||
<div>
|
||||
<div style={{ height: 300 }}>
|
||||
<VChart spec={spendBarSpec} />
|
||||
</div>
|
||||
<div className="model-analysis-legend">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', marginTop: 10, padding: '0 4px' }}>
|
||||
{spendDistribution.map(d => (
|
||||
<span key={d.model} className="model-analysis-legend-item">
|
||||
<span key={d.model} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--color-text-secondary)' }}>
|
||||
<InlineBrandIcon model={d.model} size={13} />
|
||||
<span className="model-analysis-legend-name">{d.model}</span>
|
||||
<span style={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.model}</span>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{formatCurrency(d.spend)}</span>
|
||||
</span>
|
||||
))}
|
||||
@@ -234,14 +182,14 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
<div style={{ height: 300 }}>
|
||||
<VChart spec={callsPieSpec} />
|
||||
</div>
|
||||
<div className="model-analysis-legend">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', marginTop: 10, padding: '0 4px' }}>
|
||||
{callsDistribution.map((d, idx) => {
|
||||
const pieColors = ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
|
||||
return (
|
||||
<span key={d.model} className="model-analysis-legend-item">
|
||||
<span key={d.model} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--color-text-secondary)' }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 2, background: pieColors[idx % pieColors.length], flexShrink: 0 }} />
|
||||
<InlineBrandIcon model={d.model} size={13} />
|
||||
<span className="model-analysis-legend-name">{d.model}</span>
|
||||
<span style={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.model}</span>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{d.calls}</span>
|
||||
</span>
|
||||
);
|
||||
@@ -345,6 +293,5 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import ModernSelect from './ModernSelect.js';
|
||||
|
||||
@@ -17,33 +17,6 @@ function collectInstanceText(node: ReactTestInstance): string {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function findTrigger(root: ReturnType<typeof create>) {
|
||||
return root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-trigger')
|
||||
));
|
||||
}
|
||||
|
||||
function findOptionButtons(root: ReturnType<typeof create>) {
|
||||
return root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-option')
|
||||
));
|
||||
}
|
||||
|
||||
function findListbox(root: ReturnType<typeof create>) {
|
||||
return root.root.find((node) => node.props.role === 'listbox');
|
||||
}
|
||||
|
||||
function keyEvent(key: string) {
|
||||
return {
|
||||
key,
|
||||
preventDefault: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ModernSelect', () => {
|
||||
it('renders icon nodes for the selected option', () => {
|
||||
const root = create(
|
||||
@@ -80,7 +53,11 @@ describe('ModernSelect', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = findTrigger(root);
|
||||
const trigger = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-trigger')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
trigger.props.onClick();
|
||||
@@ -95,264 +72,22 @@ describe('ModernSelect', () => {
|
||||
searchInput.props.onChange({ target: { value: 'codex' } });
|
||||
});
|
||||
|
||||
let optionButtons = findOptionButtons(root);
|
||||
let optionButtons = root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-option')
|
||||
));
|
||||
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual(['Beta Relay (codex)https://beta.example.com']);
|
||||
|
||||
await act(async () => {
|
||||
searchInput.props.onChange({ target: { value: 'third.example.com' } });
|
||||
});
|
||||
|
||||
optionButtons = findOptionButtons(root);
|
||||
optionButtons = root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-option')
|
||||
));
|
||||
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual(['Gamma APIhttps://third.example.com']);
|
||||
});
|
||||
|
||||
it('keeps closed searchable options out of the tab order', async () => {
|
||||
const root = create(
|
||||
<ModernSelect
|
||||
searchable
|
||||
searchPlaceholder="筛选站点"
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
options={[
|
||||
{ value: 'alpha', label: 'Alpha Mirror' },
|
||||
{ value: 'beta', label: 'Beta Relay', disabled: true },
|
||||
{ value: 'gamma', label: 'Gamma API' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(findListbox(root).props['aria-hidden']).toBe(true);
|
||||
expect(root.root.findByProps({ placeholder: '筛选站点' }).props.tabIndex).toBe(-1);
|
||||
expect(findOptionButtons(root).map((node) => node.props.tabIndex)).toEqual([-1, -1, -1]);
|
||||
|
||||
await act(async () => {
|
||||
findTrigger(root).props.onClick();
|
||||
});
|
||||
|
||||
expect(findListbox(root).props['aria-hidden']).toBe(false);
|
||||
expect(root.root.findByProps({ placeholder: '筛选站点' }).props.tabIndex).toBe(0);
|
||||
expect(findOptionButtons(root).map((node) => node.props.tabIndex)).toEqual([0, -1, 0]);
|
||||
});
|
||||
|
||||
it('falls back to stable text when options are missing labels', async () => {
|
||||
const root = create(
|
||||
<ModernSelect
|
||||
searchable
|
||||
value="codex"
|
||||
onChange={() => {}}
|
||||
options={[
|
||||
{ value: 'codex', label: undefined, description: undefined },
|
||||
{ value: 'claude', label: '', description: null },
|
||||
{ value: '', label: null, description: '' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(collectText(root.root)).toContain('codex');
|
||||
expect(collectText(root.root)).not.toContain('undefined');
|
||||
|
||||
const trigger = findTrigger(root);
|
||||
|
||||
await act(async () => {
|
||||
trigger.props.onClick();
|
||||
});
|
||||
|
||||
const optionButtons = findOptionButtons(root);
|
||||
|
||||
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual([
|
||||
'codex',
|
||||
'claude',
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats literal undefined/null option text as missing data', async () => {
|
||||
const root = create(
|
||||
<ModernSelect
|
||||
searchable
|
||||
value="codex"
|
||||
onChange={() => {}}
|
||||
options={[
|
||||
{ value: 'codex', label: 'undefined', description: 'null' },
|
||||
{ value: 'undefined', label: 'undefined', description: 'undefined' },
|
||||
{ value: 'null', label: 'null', description: 'null' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(collectText(root.root)).toContain('codex');
|
||||
expect(collectText(root.root)).not.toContain('undefined');
|
||||
expect(collectText(root.root)).not.toContain('null');
|
||||
|
||||
const trigger = findTrigger(root);
|
||||
|
||||
await act(async () => {
|
||||
trigger.props.onClick();
|
||||
});
|
||||
|
||||
const optionButtons = findOptionButtons(root);
|
||||
|
||||
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual([
|
||||
'codex',
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops fully empty options after normalizing polluted text', async () => {
|
||||
const root = create(
|
||||
<ModernSelect
|
||||
searchable
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
options={[
|
||||
{ value: 'undefined', label: 'undefined', description: 'undefined' },
|
||||
{ value: 'null', label: 'null', description: 'null' },
|
||||
{ value: '', label: '全部 Provider', description: '' },
|
||||
{ value: 'codex', label: 'Codex', description: 'codex' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = findTrigger(root);
|
||||
|
||||
await act(async () => {
|
||||
trigger.props.onClick();
|
||||
});
|
||||
|
||||
const optionButtons = findOptionButtons(root);
|
||||
|
||||
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual([
|
||||
'全部 Provider',
|
||||
'Codexcodex',
|
||||
]);
|
||||
expect(collectText(root.root)).not.toContain('undefined');
|
||||
expect(collectText(root.root)).not.toContain('null');
|
||||
});
|
||||
|
||||
it('supports listbox semantics and keyboard selection while skipping disabled options', async () => {
|
||||
const onChange = vi.fn();
|
||||
const root = create(
|
||||
<ModernSelect
|
||||
value=""
|
||||
onChange={onChange}
|
||||
options={[
|
||||
{ value: 'codex', label: 'Codex' },
|
||||
{ value: 'disabled', label: 'Disabled Provider', disabled: true },
|
||||
{ value: 'claude', label: 'Claude' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = findTrigger(root);
|
||||
const openEvent = keyEvent('ArrowDown');
|
||||
await act(async () => {
|
||||
trigger.props.onKeyDown(openEvent);
|
||||
});
|
||||
|
||||
expect(openEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(trigger.props.role).toBe('combobox');
|
||||
expect(trigger.props['aria-haspopup']).toBe('listbox');
|
||||
expect(trigger.props['aria-expanded']).toBe(true);
|
||||
expect(trigger.props['aria-controls']).toBeTruthy();
|
||||
expect(trigger.props['aria-activedescendant']).toContain('option-0');
|
||||
|
||||
let optionButtons = findOptionButtons(root);
|
||||
expect(optionButtons.map((node) => node.props.role)).toEqual(['option', 'option', 'option']);
|
||||
expect(optionButtons[0]!.props['aria-selected']).toBe(false);
|
||||
expect(String(optionButtons[0]!.props.className)).toContain('is-highlighted');
|
||||
|
||||
await act(async () => {
|
||||
trigger.props.onKeyDown(keyEvent('ArrowDown'));
|
||||
});
|
||||
optionButtons = findOptionButtons(root);
|
||||
expect(String(optionButtons[1]!.props.className)).not.toContain('is-highlighted');
|
||||
expect(String(optionButtons[2]!.props.className)).toContain('is-highlighted');
|
||||
|
||||
const enterEvent = keyEvent('Enter');
|
||||
await act(async () => {
|
||||
trigger.props.onKeyDown(enterEvent);
|
||||
});
|
||||
|
||||
expect(enterEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith('claude');
|
||||
expect(trigger.props['aria-expanded']).toBe(false);
|
||||
});
|
||||
|
||||
it('opens from the last enabled option when pressing ArrowUp from a closed trigger', async () => {
|
||||
const root = create(
|
||||
<ModernSelect
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
options={[
|
||||
{ value: 'codex', label: 'Codex' },
|
||||
{ value: 'disabled', label: 'Disabled Provider', disabled: true },
|
||||
{ value: 'claude', label: 'Claude' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = findTrigger(root);
|
||||
await act(async () => {
|
||||
trigger.props.onKeyDown(keyEvent('ArrowUp'));
|
||||
});
|
||||
|
||||
const optionButtons = findOptionButtons(root);
|
||||
expect(String(optionButtons[0]!.props.className)).not.toContain('is-highlighted');
|
||||
expect(String(optionButtons[1]!.props.className)).not.toContain('is-highlighted');
|
||||
expect(String(optionButtons[2]!.props.className)).toContain('is-highlighted');
|
||||
expect(trigger.props['aria-activedescendant']).toContain('option-2');
|
||||
});
|
||||
|
||||
it('supports keyboard selection while focus is inside the search input', async () => {
|
||||
const onChange = vi.fn();
|
||||
const root = create(
|
||||
<ModernSelect
|
||||
searchable
|
||||
searchPlaceholder="筛选 Provider"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
options={[
|
||||
{ value: 'codex', label: 'Codex' },
|
||||
{ value: 'codex-disabled', label: 'Codex Disabled', disabled: true },
|
||||
{ value: 'codex-plus', label: 'Codex Plus' },
|
||||
{ value: 'claude', label: 'Claude' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
findTrigger(root).props.onClick();
|
||||
});
|
||||
|
||||
const searchInput = root.root.findByProps({ placeholder: '筛选 Provider' });
|
||||
await act(async () => {
|
||||
searchInput.props.onChange({ target: { value: 'codex' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
searchInput.props.onKeyDown(keyEvent('ArrowDown'));
|
||||
});
|
||||
|
||||
const optionButtons = findOptionButtons(root);
|
||||
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual([
|
||||
'Codex',
|
||||
'Codex Disabled',
|
||||
'Codex Plus',
|
||||
]);
|
||||
expect(String(optionButtons[1]!.props.className)).not.toContain('is-highlighted');
|
||||
expect(String(optionButtons[2]!.props.className)).toContain('is-highlighted');
|
||||
|
||||
const spaceEvent = keyEvent(' ');
|
||||
await act(async () => {
|
||||
searchInput.props.onKeyDown(spaceEvent);
|
||||
});
|
||||
expect(spaceEvent.preventDefault).not.toHaveBeenCalled();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
const enterEvent = keyEvent('Enter');
|
||||
await act(async () => {
|
||||
searchInput.props.onKeyDown(enterEvent);
|
||||
});
|
||||
|
||||
expect(enterEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith('codex-plus');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
type ModernSelectOption = {
|
||||
value: string;
|
||||
label?: string | null;
|
||||
description?: string | null;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
iconNode?: ReactNode;
|
||||
iconUrl?: string | null;
|
||||
iconText?: string | null;
|
||||
iconUrl?: string;
|
||||
iconText?: string;
|
||||
};
|
||||
|
||||
type ModernSelectProps = {
|
||||
@@ -33,14 +25,6 @@ type ModernSelectProps = {
|
||||
searchPlaceholder?: string;
|
||||
};
|
||||
|
||||
function resolveSelectText(value: unknown): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
const text = value.trim();
|
||||
const lowerText = text.toLowerCase();
|
||||
if (lowerText === 'undefined' || lowerText === 'null') return '';
|
||||
return text;
|
||||
}
|
||||
|
||||
export default function ModernSelect({
|
||||
value,
|
||||
onChange,
|
||||
@@ -57,54 +41,18 @@ export default function ModernSelect({
|
||||
}: ModernSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const keyboardHighlightPendingRef = useRef(false);
|
||||
const panelId = useId();
|
||||
|
||||
const normalizedOptions = useMemo(
|
||||
() => options.flatMap((item, index) => {
|
||||
const optionValue = resolveSelectText(item.value);
|
||||
const rawLabel = resolveSelectText(item.label);
|
||||
const optionDescription = resolveSelectText(item.description);
|
||||
const optionIconUrl = resolveSelectText(item.iconUrl);
|
||||
const optionIconText = resolveSelectText(item.iconText);
|
||||
const hasVisibleContent = Boolean(
|
||||
optionValue
|
||||
|| rawLabel
|
||||
|| optionDescription
|
||||
|| optionIconUrl
|
||||
|| optionIconText
|
||||
|| item.iconNode,
|
||||
);
|
||||
if (!hasVisibleContent) return [];
|
||||
|
||||
const optionLabel = rawLabel
|
||||
|| optionValue
|
||||
|| `未命名选项 ${index + 1}`;
|
||||
|
||||
return [{
|
||||
...item,
|
||||
value: optionValue,
|
||||
label: optionLabel,
|
||||
description: optionDescription || undefined,
|
||||
iconUrl: optionIconUrl || undefined,
|
||||
iconText: optionIconText || undefined,
|
||||
}];
|
||||
}),
|
||||
[options],
|
||||
);
|
||||
|
||||
const selected = useMemo(
|
||||
() => normalizedOptions.find((item) => item.value === value),
|
||||
[normalizedOptions, value],
|
||||
() => options.find((item) => item.value === value),
|
||||
[options, value],
|
||||
);
|
||||
|
||||
const visibleOptions = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
if (!searchable || !query) return normalizedOptions;
|
||||
if (!searchable || !query) return options;
|
||||
|
||||
return normalizedOptions.filter((item) => {
|
||||
return options.filter((item) => {
|
||||
const haystack = [
|
||||
item.label,
|
||||
item.description,
|
||||
@@ -115,31 +63,7 @@ export default function ModernSelect({
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [normalizedOptions, searchQuery, searchable]);
|
||||
|
||||
const findNextEnabledIndex = (
|
||||
startIndex: number,
|
||||
direction: 1 | -1,
|
||||
): number => {
|
||||
if (visibleOptions.length === 0) return -1;
|
||||
for (let step = 0; step < visibleOptions.length; step += 1) {
|
||||
const index = (startIndex + step * direction + visibleOptions.length) % visibleOptions.length;
|
||||
if (!visibleOptions[index]?.disabled) return index;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (keyboardHighlightPendingRef.current) {
|
||||
keyboardHighlightPendingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedIndex = visibleOptions.findIndex((item) => item.value === value && !item.disabled);
|
||||
setHighlightedIndex(selectedIndex >= 0 ? selectedIndex : findNextEnabledIndex(0, 1));
|
||||
}, [open, value, visibleOptions]);
|
||||
}, [options, searchQuery, searchable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -152,7 +76,7 @@ export default function ModernSelect({
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: globalThis.KeyboardEvent) => {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false);
|
||||
};
|
||||
|
||||
@@ -187,77 +111,6 @@ export default function ModernSelect({
|
||||
return null;
|
||||
};
|
||||
|
||||
const selectOption = (index: number) => {
|
||||
const item = visibleOptions[index];
|
||||
if (!item || item.disabled) return;
|
||||
onChange(item.value);
|
||||
setHighlightedIndex(-1);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const moveHighlight = (direction: 1 | -1) => {
|
||||
keyboardHighlightPendingRef.current = true;
|
||||
setOpen(true);
|
||||
setHighlightedIndex((current) => {
|
||||
const startIndex = current >= 0
|
||||
? current + direction
|
||||
: direction === 1
|
||||
? 0
|
||||
: visibleOptions.length - 1;
|
||||
return findNextEnabledIndex(startIndex, direction);
|
||||
});
|
||||
};
|
||||
|
||||
const handleTriggerKeyDown = (
|
||||
event: ReactKeyboardEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
if (disabled) return;
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveHighlight(event.key === 'ArrowDown' ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
if (!open) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
selectOption(highlightedIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape' && open) {
|
||||
event.preventDefault();
|
||||
setHighlightedIndex(-1);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (disabled) return;
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveHighlight(event.key === 'ArrowDown' ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
selectOption(highlightedIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape' && open) {
|
||||
event.preventDefault();
|
||||
setHighlightedIndex(-1);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
@@ -268,19 +121,9 @@ export default function ModernSelect({
|
||||
type="button"
|
||||
className="modern-select-trigger"
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setOpen((prev) => {
|
||||
if (prev) setHighlightedIndex(-1);
|
||||
return !prev;
|
||||
});
|
||||
}
|
||||
if (!disabled) setOpen((prev) => !prev);
|
||||
}}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
role="combobox"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
aria-activedescendant={open && highlightedIndex >= 0 ? `${panelId}-option-${highlightedIndex}` : undefined}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className={`modern-select-value ${selected ? '' : 'is-placeholder'}`.trim()}>
|
||||
@@ -305,23 +148,15 @@ export default function ModernSelect({
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id={panelId}
|
||||
className="modern-select-panel"
|
||||
style={{ maxHeight: menuMaxHeight }}
|
||||
role="listbox"
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<div className="modern-select-panel" style={{ maxHeight: menuMaxHeight }}>
|
||||
{searchable && (
|
||||
<div className="modern-select-search-shell">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder={searchPlaceholder}
|
||||
className="modern-select-search-input"
|
||||
tabIndex={open ? 0 : -1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -329,24 +164,19 @@ export default function ModernSelect({
|
||||
{visibleOptions.length === 0 ? (
|
||||
<div className="modern-select-empty">{emptyLabel}</div>
|
||||
) : (
|
||||
visibleOptions.map((item, index) => {
|
||||
visibleOptions.map((item) => {
|
||||
const active = item.value === value;
|
||||
return (
|
||||
<button
|
||||
id={`${panelId}-option-${index}`}
|
||||
key={`${item.value || 'option'}-${index}`}
|
||||
key={item.value}
|
||||
type="button"
|
||||
className={`modern-select-option ${active ? 'is-active' : ''} ${highlightedIndex === index ? 'is-highlighted' : ''} ${item.disabled ? 'is-disabled' : ''}`.trim()}
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`modern-select-option ${active ? 'is-active' : ''} ${item.disabled ? 'is-disabled' : ''}`.trim()}
|
||||
onClick={() => {
|
||||
selectOption(index);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (!item.disabled) setHighlightedIndex(index);
|
||||
if (item.disabled) return;
|
||||
onChange(item.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={item.disabled}
|
||||
tabIndex={open && !item.disabled ? 0 : -1}
|
||||
>
|
||||
<div className="modern-select-option-main">
|
||||
{renderOptionIcon(item)}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api, userApi } from '../api.js';
|
||||
import { api } from '../api.js';
|
||||
import { formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
|
||||
import { buildEventNavigationPath } from '../pages/helpers/navigationFocus.js';
|
||||
import { useI18n } from '../i18n.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
import DiagnosticActionGroup, { type DiagnosticAction } from './DiagnosticActionGroup.js';
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
info: 'var(--color-info)',
|
||||
@@ -20,220 +19,48 @@ const typeLabels: Record<string, string> = {
|
||||
proxy: '代理',
|
||||
status: '状态',
|
||||
site_notice: '站点公告',
|
||||
user_announcement: '用户公告',
|
||||
};
|
||||
|
||||
type NotificationAudience = 'admin' | 'user';
|
||||
|
||||
const adminFilterTypes = ['', 'checkin', 'balance', 'token', 'proxy', 'status', 'site_notice'];
|
||||
const userFilterTypes = ['', 'user_announcement'];
|
||||
|
||||
function normalizeUserAnnouncement(row: any) {
|
||||
return {
|
||||
id: row?.id,
|
||||
type: 'user_announcement',
|
||||
level: row?.level || 'info',
|
||||
title: row?.title || '用户公告',
|
||||
message: row?.content || '',
|
||||
read: !!row?.read,
|
||||
relatedType: 'user_announcement',
|
||||
relatedId: row?.id,
|
||||
createdAt: row?.publishedAt || row?.firstSeenAt || row?.lastSeenAt || row?.createdAt || row?.created_at || null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildUserNotificationNavigationPath(event: any): string {
|
||||
if (String(event?.relatedType || '').toLowerCase() === 'user_announcement') {
|
||||
return '/user/announcements';
|
||||
}
|
||||
return '/user/announcements';
|
||||
}
|
||||
|
||||
function buildNotificationDiagnosticActions(event: any, targetPath: string): DiagnosticAction[] {
|
||||
const relatedType = String(event?.relatedType || '').toLowerCase();
|
||||
const eventType = String(event?.type || '').toLowerCase();
|
||||
const targetIsGeneric = !targetPath || targetPath === '/events';
|
||||
const tone = event?.level === 'error' || event?.level === 'warning' ? 'danger' : 'primary';
|
||||
|
||||
if (relatedType === 'proxy_log' || relatedType === 'proxy') {
|
||||
return [{
|
||||
label: '查看日志',
|
||||
href: targetPath,
|
||||
hint: '打开这条代理请求',
|
||||
tone,
|
||||
}];
|
||||
}
|
||||
if (relatedType === 'proxy_debug_trace' || relatedType === 'debug_trace') {
|
||||
return [{
|
||||
label: '查看追踪',
|
||||
href: targetPath,
|
||||
hint: '定位原始下游请求',
|
||||
tone,
|
||||
}];
|
||||
}
|
||||
if (relatedType === 'route') {
|
||||
return [{
|
||||
label: '诊断路由',
|
||||
href: targetPath,
|
||||
hint: '打开相关路由配置',
|
||||
tone,
|
||||
}];
|
||||
}
|
||||
if (relatedType === 'site') {
|
||||
return [{
|
||||
label: '站点管理',
|
||||
href: targetPath,
|
||||
hint: '检查站点健康和代理',
|
||||
tone,
|
||||
}];
|
||||
}
|
||||
if (relatedType === 'account') {
|
||||
return [{
|
||||
label: eventType === 'token' ? '重新绑定' : '账号详情',
|
||||
href: targetPath,
|
||||
hint: eventType === 'token' ? '更新连接凭证' : '检查账号状态',
|
||||
tone,
|
||||
}];
|
||||
}
|
||||
if (relatedType === 'site_announcement') {
|
||||
return [{
|
||||
label: '查看公告',
|
||||
href: targetPath,
|
||||
hint: '打开站点公告详情',
|
||||
tone: 'primary',
|
||||
}];
|
||||
}
|
||||
if (relatedType === 'user_announcement') {
|
||||
return [{
|
||||
label: '查看公告',
|
||||
href: targetPath,
|
||||
hint: '打开用户公告',
|
||||
tone: 'primary',
|
||||
}];
|
||||
}
|
||||
if (eventType === 'proxy' && !targetIsGeneric) {
|
||||
return [{
|
||||
label: '打开日志',
|
||||
href: targetPath,
|
||||
hint: '查看代理请求',
|
||||
tone,
|
||||
}];
|
||||
}
|
||||
return [{
|
||||
label: '查看详情',
|
||||
href: targetIsGeneric ? '/events' : targetPath,
|
||||
hint: '打开运行事件中心',
|
||||
tone: 'muted',
|
||||
}];
|
||||
}
|
||||
|
||||
export default function NotificationPanel({
|
||||
open,
|
||||
onClose,
|
||||
anchorRef,
|
||||
onUnreadCountChange,
|
||||
panelId,
|
||||
audience = 'admin',
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
anchorRef: React.RefObject<HTMLButtonElement | null>;
|
||||
onUnreadCountChange?: (count: number) => void;
|
||||
panelId?: string;
|
||||
audience?: NotificationAudience;
|
||||
}) {
|
||||
const { t: tr } = useI18n();
|
||||
const presence = useAnimatedVisibility(open, 160);
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
const [activeEventIndex, setActiveEventIndex] = useState(-1);
|
||||
const [activeFocusRequest, setActiveFocusRequest] = useState(0);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const eventRefs = useRef<Array<HTMLDivElement | null>>([]);
|
||||
const mountedRef = useRef(true);
|
||||
const openRef = useRef(open);
|
||||
const loadSeqRef = useRef(0);
|
||||
const focusActiveEventRef = useRef(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const focusAnchor = useCallback(() => {
|
||||
const anchor = anchorRef.current;
|
||||
if (!anchor || anchor.isConnected === false) return;
|
||||
anchor.focus({ preventScroll: true });
|
||||
}, [anchorRef]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const loadSeq = ++loadSeqRef.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = filter ? `type=${filter}` : '';
|
||||
let data: any[];
|
||||
if (audience === 'user') {
|
||||
const announcements = await userApi.getAnnouncements('limit=50');
|
||||
data = Array.isArray(announcements)
|
||||
? announcements.map(normalizeUserAnnouncement)
|
||||
: [];
|
||||
} else {
|
||||
data = await api.getEvents(params);
|
||||
}
|
||||
if (!mountedRef.current || !openRef.current || loadSeqRef.current !== loadSeq) return;
|
||||
const data = await api.getEvents(params);
|
||||
setEvents(data);
|
||||
|
||||
// Auto mark all as read on open
|
||||
const hasUnread = Array.isArray(data) && data.some((e: any) => !e.read);
|
||||
if (hasUnread) {
|
||||
if (audience === 'user') {
|
||||
userApi.markAllAnnouncementsRead().catch(() => {});
|
||||
} else {
|
||||
api.markAllEventsRead().catch(() => {});
|
||||
}
|
||||
onUnreadCountChange?.(0);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally {
|
||||
if (mountedRef.current && openRef.current && loadSeqRef.current === loadSeq) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [audience, filter, onUnreadCountChange]);
|
||||
|
||||
useEffect(() => () => {
|
||||
mountedRef.current = false;
|
||||
loadSeqRef.current += 1;
|
||||
}, []);
|
||||
finally { setLoading(false); }
|
||||
}, [filter, onUnreadCountChange]);
|
||||
|
||||
useEffect(() => {
|
||||
openRef.current = open;
|
||||
if (!open) {
|
||||
loadSeqRef.current += 1;
|
||||
focusActiveEventRef.current = false;
|
||||
setLoading(false);
|
||||
setActiveEventIndex(-1);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
panelRef.current?.focus({ preventScroll: true });
|
||||
load();
|
||||
if (open) load();
|
||||
}, [open, load]);
|
||||
|
||||
useEffect(() => {
|
||||
focusActiveEventRef.current = false;
|
||||
setActiveEventIndex(-1);
|
||||
}, [audience, filter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeEventIndex < 0) return;
|
||||
const activeEvent = eventRefs.current[activeEventIndex];
|
||||
activeEvent?.scrollIntoView({ block: 'nearest' });
|
||||
if (focusActiveEventRef.current) {
|
||||
activeEvent?.focus({ preventScroll: true });
|
||||
focusActiveEventRef.current = false;
|
||||
}
|
||||
}, [activeEventIndex, activeFocusRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (
|
||||
@@ -247,109 +74,54 @@ export default function NotificationPanel({
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open, onClose, anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
onClose();
|
||||
focusAnchor();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [focusAnchor, open, onClose]);
|
||||
|
||||
const clearAll = async () => {
|
||||
if (audience === 'user') {
|
||||
await userApi.markAllAnnouncementsRead();
|
||||
} else {
|
||||
await api.clearEvents();
|
||||
}
|
||||
if (!mountedRef.current || !openRef.current) return;
|
||||
setEvents([]);
|
||||
setActiveEventIndex(-1);
|
||||
onUnreadCountChange?.(0);
|
||||
};
|
||||
|
||||
const moveActiveEvent = (direction: 1 | -1) => {
|
||||
if (!events.length) return;
|
||||
focusActiveEventRef.current = true;
|
||||
setActiveEventIndex((current) => {
|
||||
if (current < 0) return direction > 0 ? 0 : events.length - 1;
|
||||
return (current + direction + events.length) % events.length;
|
||||
});
|
||||
setActiveFocusRequest((request) => request + 1);
|
||||
};
|
||||
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const interactive = open && presence.isVisible;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={panelId}
|
||||
ref={panelRef}
|
||||
className={`user-dropdown notification-panel ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
role="dialog"
|
||||
aria-label={tr('通知')}
|
||||
aria-hidden={!interactive}
|
||||
tabIndex={-1}
|
||||
style={{ right: 0, top: '100%', padding: 0, marginTop: 4 }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveActiveEvent(event.key === 'ArrowDown' ? 1 : -1);
|
||||
}
|
||||
}}
|
||||
className={`user-dropdown ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
style={{ right: 0, top: '100%', width: 360, maxHeight: 480, padding: 0, marginTop: 4 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>{tr('通知')}</span>
|
||||
<button type="button" onClick={clearAll} className="btn btn-link" tabIndex={interactive ? 0 : -1}>
|
||||
<button onClick={clearAll} className="btn btn-link">
|
||||
{tr('清空')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div style={{ display: 'flex', gap: 4, padding: '8px 12px', borderBottom: '1px solid var(--color-border-light)', flexWrap: 'wrap' }}>
|
||||
{(audience === 'user' ? userFilterTypes : adminFilterTypes).map((filterType) => {
|
||||
const filterLabel = filterType ? tr(typeLabels[filterType] || filterType) : tr('全部');
|
||||
const active = filter === filterType;
|
||||
return (
|
||||
<button
|
||||
key={filterType}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
aria-label={`${tr('通知筛选')}: ${filterLabel}`}
|
||||
tabIndex={interactive ? 0 : -1}
|
||||
onClick={() => setFilter(filterType)}
|
||||
{['', 'checkin', 'balance', 'token', 'proxy', 'status', 'site_notice'].map((filterType) => (
|
||||
<button key={filterType} onClick={() => setFilter(filterType)}
|
||||
style={{
|
||||
fontSize: 11, padding: '3px 8px', borderRadius: 12,
|
||||
border: active ? '1px solid var(--color-primary)' : '1px solid var(--color-border)',
|
||||
background: active ? 'var(--color-primary-light)' : 'transparent',
|
||||
color: active ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||
border: filter === filterType ? '1px solid var(--color-primary)' : '1px solid var(--color-border)',
|
||||
background: filter === filterType ? 'var(--color-primary-light)' : 'transparent',
|
||||
color: filter === filterType ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{filterLabel}
|
||||
}}>
|
||||
{filterType ? tr(typeLabels[filterType] || filterType) : tr('全部')}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Events list */}
|
||||
<div role="list" aria-busy={loading} aria-live="polite" className="notification-panel-list">
|
||||
<div style={{ maxHeight: 360, overflow: 'auto' }}>
|
||||
{loading && <div style={{ padding: 20, textAlign: 'center' }}><span className="spinner spinner-sm" /></div>}
|
||||
{!loading && events.length === 0 && (
|
||||
<div style={{ padding: '32px 16px', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 13 }}>
|
||||
{tr('暂无通知')}
|
||||
</div>
|
||||
)}
|
||||
{events.map((ev: any, index: number) => {
|
||||
const targetPath = audience === 'user'
|
||||
? buildUserNotificationNavigationPath(ev)
|
||||
: buildEventNavigationPath(ev);
|
||||
const diagnosticActions = buildNotificationDiagnosticActions(ev, targetPath);
|
||||
const active = activeEventIndex === index;
|
||||
{events.map((ev: any) => {
|
||||
const targetPath = buildEventNavigationPath(ev);
|
||||
const openTarget = () => {
|
||||
onClose();
|
||||
navigate(targetPath);
|
||||
@@ -357,10 +129,7 @@ export default function NotificationPanel({
|
||||
return (
|
||||
<div
|
||||
key={ev.id}
|
||||
ref={(node) => {
|
||||
eventRefs.current[index] = node;
|
||||
}}
|
||||
className={`notification-event-item ${active ? 'is-active' : ''}`.trim()}
|
||||
className="notification-event-item"
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid var(--color-border-light)',
|
||||
@@ -371,18 +140,8 @@ export default function NotificationPanel({
|
||||
}}
|
||||
onClick={openTarget}
|
||||
role="button"
|
||||
tabIndex={interactive ? 0 : -1}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
onMouseEnter={() => {
|
||||
focusActiveEventRef.current = false;
|
||||
setActiveEventIndex(index);
|
||||
}}
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveActiveEvent(event.key === 'ArrowDown' ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
openTarget();
|
||||
@@ -404,11 +163,6 @@ export default function NotificationPanel({
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
{formatDateTimeMinuteLocal(ev.createdAt)}
|
||||
</div>
|
||||
<DiagnosticActionGroup
|
||||
actions={diagnosticActions}
|
||||
className="notification-event-actions"
|
||||
interactive={interactive}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
type PageHeaderProps = {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
titleAs?: 'h1' | 'h2';
|
||||
};
|
||||
|
||||
export default function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
className = '',
|
||||
style,
|
||||
titleAs = 'h2',
|
||||
}: PageHeaderProps) {
|
||||
const Heading = titleAs;
|
||||
const classes = ['page-header', className].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className={classes} style={style}>
|
||||
<div className="page-header-copy">
|
||||
<Heading className="page-title">{title}</Heading>
|
||||
{subtitle ? <div className="page-subtitle">{subtitle}</div> : null}
|
||||
</div>
|
||||
{actions ? <div className="page-actions">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,12 +11,8 @@ describe('ResponsiveBatchActionBar', () => {
|
||||
</ResponsiveBatchActionBar>,
|
||||
);
|
||||
|
||||
const bar = root.root.find((node) => String(node.props.className || '').includes('mobile-batch-bar'));
|
||||
const bar = root.root.find((node) => node.props.className === 'mobile-actions-bar mobile-batch-bar');
|
||||
expect(bar).toBeTruthy();
|
||||
expect(bar.props.className).toContain('responsive-batch-action-bar');
|
||||
const info = root.root.find((node) => node.props.className === 'mobile-actions-info');
|
||||
expect(info.props.role).toBe('status');
|
||||
expect(info.props['aria-live']).toBe('polite');
|
||||
expect(root.root.findByType('button').children).toContain('批量启用');
|
||||
});
|
||||
|
||||
@@ -27,11 +23,8 @@ describe('ResponsiveBatchActionBar', () => {
|
||||
</ResponsiveBatchActionBar>,
|
||||
);
|
||||
|
||||
const card = root.root.find((node) => String(node.props.className || '').includes('responsive-batch-action-bar'));
|
||||
const card = root.root.find((node) => node.props.className === 'card');
|
||||
expect(card).toBeTruthy();
|
||||
expect(card.props.className).toContain('card');
|
||||
const info = root.root.find((node) => node.type === 'span' && node.children.includes('已选 3 项'));
|
||||
expect(info.props.role).toBe('status');
|
||||
expect(info.props['aria-live']).toBe('polite');
|
||||
expect(root.root.findAllByType('span').some((node) => node.children.includes('已选 3 项'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,11 +34,8 @@ export default function ResponsiveBatchActionBar({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card responsive-batch-action-bar"
|
||||
style={{ ...DEFAULT_DESKTOP_STYLE, ...desktopStyle }}
|
||||
>
|
||||
<span role="status" aria-live="polite" style={{ ...DEFAULT_INFO_STYLE, ...infoStyle }}>{info}</span>
|
||||
<div className="card" style={{ ...DEFAULT_DESKTOP_STYLE, ...desktopStyle }}>
|
||||
<span style={{ ...DEFAULT_INFO_STYLE, ...infoStyle }}>{info}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -32,8 +32,6 @@ export default function ResponsiveFilterPanel({
|
||||
mobileTriggerButtonClassName = 'btn btn-ghost',
|
||||
mobileTriggerButtonStyle = { border: '1px solid var(--color-border)' },
|
||||
}: ResponsiveFilterPanelProps) {
|
||||
const mobilePanelId = React.useId();
|
||||
|
||||
if (!isMobile) {
|
||||
return <>{desktopContent}</>;
|
||||
}
|
||||
@@ -46,16 +44,13 @@ export default function ResponsiveFilterPanel({
|
||||
type="button"
|
||||
className={mobileTriggerButtonClassName}
|
||||
style={mobileTriggerButtonStyle}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls={mobilePanelId}
|
||||
onClick={onMobileOpen}
|
||||
>
|
||||
{mobileTriggerLabel}
|
||||
</button>
|
||||
</div>
|
||||
) : null)}
|
||||
<MobileFilterSheet open={mobileOpen} onClose={onMobileClose} title={mobileTitle} panelId={mobilePanelId}>
|
||||
<MobileFilterSheet open={mobileOpen} onClose={onMobileClose} title={mobileTitle}>
|
||||
{mobileContent}
|
||||
</MobileFilterSheet>
|
||||
</>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useEffect, useRef, useCallback, useId, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { formatDateLocal, formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
|
||||
import { buildAccountFocusPath, buildSiteFocusPath, buildTokenFocusPath } from '../pages/helpers/navigationFocus.js';
|
||||
import { useI18n } from '../i18n.js';
|
||||
import { trapFocusInContainer } from './focusTrap.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
|
||||
interface SiteResult {
|
||||
@@ -66,106 +65,25 @@ interface SearchResult {
|
||||
models: ModelSearchResult[];
|
||||
}
|
||||
|
||||
type SearchResultAction = {
|
||||
key: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export default function SearchModal({
|
||||
open,
|
||||
onClose,
|
||||
dialogId,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
dialogId?: string;
|
||||
}) {
|
||||
export default function SearchModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const presence = useAnimatedVisibility(open, 180);
|
||||
const titleId = useId();
|
||||
const resultsId = useId();
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeResultIndex, setActiveResultIndex] = useState(-1);
|
||||
const [activeFocusRequest, setActiveFocusRequest] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||
const resultRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const lastActiveElementRef = useRef<HTMLElement | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const focusTimerRef = useRef<number | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const openRef = useRef(open);
|
||||
const searchSeqRef = useRef(0);
|
||||
const focusActiveResultRef = useRef(false);
|
||||
const canLockBodyScroll = typeof document !== 'undefined' && !!document.body?.style;
|
||||
|
||||
const clearSearchTimer = useCallback(() => {
|
||||
if (timerRef.current === null) return;
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const clearFocusTimer = useCallback(() => {
|
||||
if (focusTimerRef.current === null) return;
|
||||
window.clearTimeout(focusTimerRef.current);
|
||||
focusTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
mountedRef.current = false;
|
||||
clearSearchTimer();
|
||||
clearFocusTimer();
|
||||
}, [clearFocusTimer, clearSearchTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
openRef.current = open;
|
||||
}, [open]);
|
||||
const timerRef = useRef<number>();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const activeElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
lastActiveElementRef.current = typeof HTMLElement !== 'undefined' && activeElement instanceof HTMLElement
|
||||
? activeElement
|
||||
: null;
|
||||
searchSeqRef.current += 1;
|
||||
focusActiveResultRef.current = false;
|
||||
setQuery('');
|
||||
setResults(null);
|
||||
setActiveResultIndex(-1);
|
||||
clearFocusTimer();
|
||||
focusTimerRef.current = window.setTimeout(() => {
|
||||
focusTimerRef.current = null;
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
} else {
|
||||
searchSeqRef.current += 1;
|
||||
focusActiveResultRef.current = false;
|
||||
clearSearchTimer();
|
||||
clearFocusTimer();
|
||||
setLoading(false);
|
||||
setActiveResultIndex(-1);
|
||||
if (lastActiveElementRef.current?.isConnected !== false) {
|
||||
lastActiveElementRef.current?.focus({ preventScroll: true });
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
}
|
||||
lastActiveElementRef.current = null;
|
||||
}
|
||||
}, [clearFocusTimer, clearSearchTimer, open]);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !canLockBodyScroll) return undefined;
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [canLockBodyScroll, open]);
|
||||
|
||||
const doSearch = useCallback(async (q: string, searchSeq: number) => {
|
||||
const doSearch = useCallback(async (q: string) => {
|
||||
if (!q.trim()) {
|
||||
setResults(null);
|
||||
return;
|
||||
@@ -174,7 +92,6 @@ export default function SearchModal({
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.search(q);
|
||||
if (!mountedRef.current || !openRef.current || searchSeqRef.current !== searchSeq) return;
|
||||
setResults({
|
||||
models: Array.isArray(res?.models) ? res.models : [],
|
||||
accounts: Array.isArray(res?.accounts) ? res.accounts : [],
|
||||
@@ -183,25 +100,17 @@ export default function SearchModal({
|
||||
checkinLogs: Array.isArray(res?.checkinLogs) ? res.checkinLogs : [],
|
||||
proxyLogs: Array.isArray(res?.proxyLogs) ? res.proxyLogs : [],
|
||||
});
|
||||
setActiveResultIndex(-1);
|
||||
focusActiveResultRef.current = false;
|
||||
} catch {
|
||||
// ignore search errors in modal
|
||||
} finally {
|
||||
if (mountedRef.current && openRef.current && searchSeqRef.current === searchSeq) setLoading(false);
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleInput = (val: string) => {
|
||||
setQuery(val);
|
||||
focusActiveResultRef.current = false;
|
||||
setActiveResultIndex(-1);
|
||||
clearSearchTimer();
|
||||
const searchSeq = searchSeqRef.current;
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
void doSearch(val, searchSeq);
|
||||
}, 300);
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = window.setTimeout(() => doSearch(val), 300);
|
||||
};
|
||||
|
||||
const goTo = (path: string) => {
|
||||
@@ -210,24 +119,8 @@ export default function SearchModal({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeResultIndex < 0) return;
|
||||
const activeResult = resultRefs.current[activeResultIndex];
|
||||
activeResult?.scrollIntoView({ block: 'nearest' });
|
||||
if (focusActiveResultRef.current) {
|
||||
activeResult?.focus({ preventScroll: true });
|
||||
focusActiveResultRef.current = false;
|
||||
}
|
||||
}, [activeFocusRequest, activeResultIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return undefined;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (!open) return;
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
trapFocusInContainer(e, dialogRef.current);
|
||||
if (e.key === 'Escape' && open) onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
@@ -235,8 +128,6 @@ export default function SearchModal({
|
||||
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const interactive = open && presence.isVisible;
|
||||
|
||||
const hasResults = results && (
|
||||
results.models.length
|
||||
|| results.accounts.length
|
||||
@@ -245,103 +136,11 @@ export default function SearchModal({
|
||||
|| results.checkinLogs.length
|
||||
|| results.proxyLogs.length
|
||||
);
|
||||
const resultActions: SearchResultAction[] = results ? [
|
||||
...results.models.map((model) => ({
|
||||
key: `model:${model.name}`,
|
||||
path: `/models?q=${encodeURIComponent(model.name)}`,
|
||||
})),
|
||||
...results.sites.map((site) => ({
|
||||
key: `site:${site.id}`,
|
||||
path: buildSiteFocusPath(site.id),
|
||||
})),
|
||||
...results.accounts.map((account) => ({
|
||||
key: `account:${account.id}`,
|
||||
path: buildAccountFocusPath(account.id, {
|
||||
openRebind: account.status === 'expired',
|
||||
segment: account.segment,
|
||||
}),
|
||||
})),
|
||||
...results.accountTokens.map((token) => ({
|
||||
key: `token:${token.id}`,
|
||||
path: buildTokenFocusPath(token.id),
|
||||
})),
|
||||
...results.checkinLogs.map((log) => ({
|
||||
key: `checkin:${log.id}`,
|
||||
path: '/checkin',
|
||||
})),
|
||||
...results.proxyLogs.map((log) => ({
|
||||
key: `proxy:${log.id}`,
|
||||
path: '/logs',
|
||||
})),
|
||||
] : [];
|
||||
const activeResultId = activeResultIndex >= 0 && activeResultIndex < resultActions.length
|
||||
? `${resultsId}-option-${activeResultIndex}`
|
||||
: undefined;
|
||||
const moveActiveResult = (direction: 1 | -1, shouldFocusResult: boolean) => {
|
||||
if (!resultActions.length) return;
|
||||
focusActiveResultRef.current = shouldFocusResult;
|
||||
setActiveResultIndex((current) => {
|
||||
if (current < 0) return direction > 0 ? 0 : resultActions.length - 1;
|
||||
return (current + direction + resultActions.length) % resultActions.length;
|
||||
});
|
||||
if (shouldFocusResult) {
|
||||
setActiveFocusRequest((request) => request + 1);
|
||||
}
|
||||
};
|
||||
const buildResultProps = (action: SearchResultAction, index: number) => ({
|
||||
id: `${resultsId}-option-${index}`,
|
||||
ref: (node: HTMLButtonElement | null) => {
|
||||
resultRefs.current[index] = node;
|
||||
},
|
||||
role: 'option',
|
||||
'aria-selected': activeResultIndex === index,
|
||||
className: `search-result-item ${activeResultIndex === index ? 'is-active' : ''}`.trim(),
|
||||
tabIndex: interactive ? 0 : -1,
|
||||
onClick: () => goTo(action.path),
|
||||
onMouseEnter: () => {
|
||||
focusActiveResultRef.current = false;
|
||||
setActiveResultIndex(index);
|
||||
},
|
||||
onKeyDown: (event: ReactKeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveActiveResult(event.key === 'ArrowDown' ? 1 : -1, true);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
goTo(action.path);
|
||||
}
|
||||
},
|
||||
});
|
||||
const handleInputKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (!resultActions.length) return;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveActiveResult(event.key === 'ArrowDown' ? 1 : -1, false);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' && activeResultIndex >= 0 && activeResultIndex < resultActions.length) {
|
||||
event.preventDefault();
|
||||
goTo(resultActions[activeResultIndex].path);
|
||||
}
|
||||
};
|
||||
let resultIndex = 0;
|
||||
|
||||
return (
|
||||
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
|
||||
<div
|
||||
id={dialogId}
|
||||
ref={dialogRef}
|
||||
className={`modal-content search-modal ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-hidden={!interactive}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="search-modal-header">
|
||||
<span id={titleId} className="sr-only">{t('全局搜索')}</span>
|
||||
<div className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} style={{ maxWidth: 560, padding: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 16px', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="var(--color-text-muted)">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
@@ -349,12 +148,6 @@ export default function SearchModal({
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={e => handleInput(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
role="combobox"
|
||||
aria-expanded={Boolean(hasResults)}
|
||||
aria-controls={resultsId}
|
||||
aria-activedescendant={activeResultId}
|
||||
tabIndex={interactive ? 0 : -1}
|
||||
placeholder={t('搜索站点、账号、模型、日志...')}
|
||||
style={{ flex: 1, border: 'none', outline: 'none', fontSize: 14, background: 'transparent', color: 'var(--color-text-primary)' }}
|
||||
/>
|
||||
@@ -362,7 +155,7 @@ export default function SearchModal({
|
||||
<kbd style={{ fontSize: 11, padding: '2px 6px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 4, color: 'var(--color-text-muted)' }}>ESC</kbd>
|
||||
</div>
|
||||
|
||||
<div id={resultsId} role="listbox" aria-hidden={!interactive} aria-busy={loading} aria-live="polite" className="search-modal-results">
|
||||
<div style={{ maxHeight: 400, overflow: 'auto', padding: '8px 0' }}>
|
||||
{query && !loading && !hasResults && (
|
||||
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 13 }}>
|
||||
{t('没有找到匹配结果')}
|
||||
@@ -372,12 +165,8 @@ export default function SearchModal({
|
||||
{results?.models.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('模型广场')}</div>
|
||||
{results.models.map((m) => {
|
||||
const action = resultActions[resultIndex];
|
||||
const props = buildResultProps(action, resultIndex);
|
||||
resultIndex += 1;
|
||||
return (
|
||||
<button type="button" key={m.name} {...props}>
|
||||
{results.models.map((m) => (
|
||||
<button key={m.name} className="search-result-item" onClick={() => goTo(`/models?q=${encodeURIComponent(m.name)}`)}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L4 12l5.75-5M14.25 7L20 12l-5.75 5M14 4l-4 16" />
|
||||
</svg>
|
||||
@@ -388,20 +177,15 @@ export default function SearchModal({
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.sites.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('站点')}</div>
|
||||
{results.sites.map((s) => {
|
||||
const action = resultActions[resultIndex];
|
||||
const props = buildResultProps(action, resultIndex);
|
||||
resultIndex += 1;
|
||||
return (
|
||||
<button type="button" key={s.id} {...props}>
|
||||
{results.sites.map((s) => (
|
||||
<button key={s.id} className="search-result-item" onClick={() => goTo(buildSiteFocusPath(s.id))}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9" />
|
||||
</svg>
|
||||
@@ -410,23 +194,21 @@ export default function SearchModal({
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{s.url}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.accounts.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('账号')}</div>
|
||||
{results.accounts.map((a) => {
|
||||
const action = resultActions[resultIndex];
|
||||
const props = buildResultProps(action, resultIndex);
|
||||
resultIndex += 1;
|
||||
return (
|
||||
{results.accounts.map((a) => (
|
||||
<button
|
||||
type="button"
|
||||
key={a.id}
|
||||
{...props}
|
||||
className="search-result-item"
|
||||
onClick={() => goTo(buildAccountFocusPath(a.id, {
|
||||
openRebind: a.status === 'expired',
|
||||
segment: a.segment,
|
||||
}))}
|
||||
>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
@@ -443,23 +225,18 @@ export default function SearchModal({
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.accountTokens.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('账号令牌')}</div>
|
||||
{results.accountTokens.map((token) => {
|
||||
const action = resultActions[resultIndex];
|
||||
const props = buildResultProps(action, resultIndex);
|
||||
resultIndex += 1;
|
||||
return (
|
||||
{results.accountTokens.map((token) => (
|
||||
<button
|
||||
type="button"
|
||||
key={token.id}
|
||||
{...props}
|
||||
className="search-result-item"
|
||||
onClick={() => goTo(buildTokenFocusPath(token.id))}
|
||||
>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
@@ -474,20 +251,15 @@ export default function SearchModal({
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.checkinLogs.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('签到记录')}</div>
|
||||
{results.checkinLogs.map((l) => {
|
||||
const action = resultActions[resultIndex];
|
||||
const props = buildResultProps(action, resultIndex);
|
||||
resultIndex += 1;
|
||||
return (
|
||||
<button type="button" key={l.id} {...props}>
|
||||
{results.checkinLogs.map((l) => (
|
||||
<button key={l.id} className="search-result-item" onClick={() => goTo('/checkin')}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
@@ -498,20 +270,15 @@ export default function SearchModal({
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.proxyLogs.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('使用日志')}</div>
|
||||
{results.proxyLogs.map((l) => {
|
||||
const action = resultActions[resultIndex];
|
||||
const props = buildResultProps(action, resultIndex);
|
||||
resultIndex += 1;
|
||||
return (
|
||||
<button type="button" key={l.id} {...props}>
|
||||
{results.proxyLogs.map((l) => (
|
||||
<button key={l.id} className="search-result-item" onClick={() => goTo('/logs')}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
|
||||
</svg>
|
||||
@@ -522,13 +289,12 @@ export default function SearchModal({
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="search-modal-footer">
|
||||
<div style={{ padding: '8px 16px', borderTop: '1px solid var(--color-border-light)', fontSize: 11, color: 'var(--color-text-muted)', display: 'flex', gap: 12 }}>
|
||||
<span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>↑↓</kbd> {t('导航')}</span>
|
||||
<span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>Enter</kbd> {t('打开')}</span>
|
||||
<span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>Esc</kbd> {t('关闭')}</span>
|
||||
|
||||
@@ -74,16 +74,6 @@ describe('SiteCreatedModal', () => {
|
||||
]),
|
||||
);
|
||||
expect(choiceButtons.every((button) => !String(button.props.className || '').includes('btn-outline'))).toBe(true);
|
||||
expect(choiceButtons.every((button) => button.props.type === 'button')).toBe(true);
|
||||
expect(choiceButtons.find((button) => collectText(button) === '添加 API Key(推荐)')?.props['aria-label'])
|
||||
.toBe('站点创建推荐操作:添加 API Key(推荐)');
|
||||
expect(choiceButtons.find((button) => collectText(button) === '添加账号(用户名密码登录)')?.props['aria-label'])
|
||||
.toBe('站点创建后续操作:添加账号(用户名密码登录)');
|
||||
const laterButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& collectText(node) === '稍后配置'
|
||||
));
|
||||
expect(laterButton.props.type).toBe('button');
|
||||
|
||||
const sessionButton = choiceButtons.find((button) => collectText(button) === '添加账号(用户名密码登录)');
|
||||
await act(async () => {
|
||||
@@ -111,6 +101,5 @@ describe('SiteCreatedModal', () => {
|
||||
));
|
||||
|
||||
expect(buttons.some((button) => collectText(button) === '添加 OAuth 连接')).toBe(true);
|
||||
expect(buttons.every((button) => button.props.type === 'button')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user