Compare commits

..

91 Commits

Author SHA1 Message Date
boos d697146941 feat(audit): login audit log with account info and IPv4
- 审计日志记录具体账号(用户名<邮箱>)
- 优先显示 IPv4,保留 IPv6
- Program Logs 页面新增 account/IPv4 列
2026-06-10 19:54:32 +08:00
boos d1f3f4e93d feat(payment): Stripe Checkout payment module
- payment_orders 建表迁移,支持多 provider(stripe)
- Stripe Checkout Session 创建、Webhook 验签、到账
- 支付成功自动为 API Key 增加额度
- payment.enabled 开关,关闭时不影响旧 Comfy Checkout
2026-06-10 19:54:27 +08:00
boos df97633300 fix(recharge): per-amount distinct checkout URLs
- 前端 /0/0/0 各自携带独立 checkoutUrl
- 后端按金额解析独立 Checkout URL 配置
- 同时校验 optionId 和 amountUsd 防止金额错配
- 移除旧单一固定 checkout URL 兜底
2026-06-10 19:54:20 +08:00
boos 3d54c7d529 fix: support user recharge record cancellation 2026-06-09 17:22:23 +08:00
boos 55b3d4d99a feat: enhance new website experiment motion 2026-06-09 16:59:04 +08:00
boos 59106e6a96 feat: refine BoosAPI console and user workspace 2026-06-09 16:48:18 +08:00
boos aaba6f152e perf: optimize proxy streaming and database access 2026-06-07 00:00:24 +08:00
boos f3d2d50f9a fix: make website boot animation resilient 2026-06-06 22:38:31 +08:00
boos 16162095f7 style: polish admin operations pages 2026-06-06 22:19:42 +08:00
boos 6629d9d0a3 fix: stabilize console topbar layout 2026-06-06 22:09:44 +08:00
boos 3c6f061709 fix: improve assistant thinking and image progress 2026-06-06 22:02:45 +08:00
boos d7d14e077e fix: stabilize website boot loader 2026-06-06 21:54:25 +08:00
boos 3b3292b934 feat: add admin operations war room 2026-06-06 21:50:23 +08:00
boos f7916a42e5 feat: enhance website flow and user workbench 2026-06-06 21:44:58 +08:00
boos f725de28bd feat: polish website and console dashboards 2026-06-06 21:15:33 +08:00
boos 97113964c8 fix: remove sidebar identity card 2026-06-06 21:01:20 +08:00
boos b429695cba fix: stabilize topbar layout 2026-06-06 20:57:33 +08:00
boos d6bf7352a0 feat: support local avatar uploads 2026-06-06 20:52:34 +08:00
boos d800ed1f9f fix: keep admin sidebar actions visible 2026-06-06 20:44:34 +08:00
boos 4855db3fc8 feat: polish user auth entrance 2026-06-06 20:38:18 +08:00
boos 6cd0a8caca fix: stream real agent reasoning 2026-06-06 20:26:31 +08:00
boos 44dc89a0dd refactor: share console page header 2026-06-06 20:18:28 +08:00
boos 823ac3c25d fix: show only generated outputs in workspace 2026-06-06 20:03:02 +08:00
boos 5ecea182bc feat: polish console shell and notification settings 2026-06-06 19:56:56 +08:00
boos cd843465b2 feat: show conversation content in usage logs 2026-06-06 19:36:42 +08:00
boos ef0f91e683 perf: speed up app loading 2026-06-06 19:21:59 +08:00
boos 9c1e5ae293 fix: keep video assistant reasoning label stable 2026-06-06 19:02:41 +08:00
boos 00b3365f5a feat: refine website gateway os hero 2026-06-06 18:59:10 +08:00
boos d5babf5f3f fix: support website color schemes 2026-06-06 18:45:12 +08:00
boos 4b757b8250 feat: add light website intro motion 2026-06-06 18:29:10 +08:00
boos def8d7a204 chore: speed up local build workflow 2026-06-06 18:10:16 +08:00
boos 25c6fdbd6c revert website high contrast redesign 2026-06-06 17:58:10 +08:00
boos 47202e6848 redesign website with high contrast motion 2026-06-06 17:54:38 +08:00
boos 1b2880739a polish auth entry surfaces 2026-06-06 17:42:14 +08:00
boos 188e457e7f polish topbar user experience 2026-06-06 17:31:41 +08:00
boos 13756adc9d polish about page experience 2026-06-06 17:25:31 +08:00
boos f026582008 fix: persist runtime proxy token 2026-06-06 17:11:51 +08:00
boos b3eb86d5ea chore: remove update center 2026-06-06 16:55:15 +08:00
boos 301d086cb6 fix: show runtime logs in program logs 2026-06-06 16:40:55 +08:00
boos 023384b14b style: refine website section spacing 2026-06-06 16:18:11 +08:00
boos d315f0ffe2 feat: polish website model radar 2026-06-06 16:14:05 +08:00
boos f0385c9c8c chore: install vercel agent skills 2026-06-06 16:07:34 +08:00
boos 339f91d014 feat: sync upstream analytics schema support 2026-06-06 16:07:13 +08:00
boos 0d231ec467 feat: enhance website model radar motion 2026-06-06 15:46:51 +08:00
boos be1fc1df48 feat: optimize routes and registration UX 2026-06-06 15:37:37 +08:00
boos dd874bae30 feat: refresh BossAI console frontend 2026-06-06 15:25:04 +08:00
boos 45aa741d9c feat: refine BossAI console and website design 2026-06-06 15:09:53 +08:00
boos 49c398db5e feat: polish BossAI website motion 2026-06-06 14:56:33 +08:00
boos 4d748dd938 feat: refine BossAI website product story 2026-06-06 06:27:28 +08:00
boos b40c522749 feat: add gsap motion to BossAI website 2026-06-06 06:18:40 +08:00
boos d467e27fd8 fix: guard embedded frontend assets 2026-06-06 06:11:15 +08:00
boos 5ef017c54e feat: serve website at root path 2026-06-06 05:59:48 +08:00
boos 03046152fd feat: isolate BossAI website with Next.js 2026-06-06 05:30:10 +08:00
boos d75daf72d6 feat: add premium public landing page 2026-06-06 05:06:37 +08:00
boos d39100b56d feat: optimize money printer factory 2026-06-06 04:56:02 +08:00
boos 2e4c5801b6 chore: tighten repository hygiene 2026-06-06 04:40:25 +08:00
boos 870b2a2da3 feat: surface admin settings attention states 2026-06-06 03:57:15 +08:00
boos 4448567368 feat: optimize admin settings page 2026-06-06 03:49:30 +08:00
boos fae0f607aa test: restore project verification suite 2026-06-06 03:35:29 +08:00
boos e4630c4cb6 fix: harden workflow smoke issues 2026-06-06 03:23:39 +08:00
boos e72e323313 feat: refine money printer comfy template mode 2026-06-06 03:09:08 +08:00
boos 2b836b3e1e Add built-in Cloud video template fallback 2026-06-06 02:58:16 +08:00
boos 598b2f48d6 Handle Comfy Cloud video job status 2026-06-05 22:01:03 +08:00
boos 3f22d7b1ab Make short video factory use Comfy templates 2026-06-05 21:35:12 +08:00
boos 5ce5ea1687 Route Comfy workflows through video API 2026-06-03 11:39:14 +08:00
boos f8741b544e Filter MoneyPrinter Comfy video templates 2026-06-03 11:29:55 +08:00
boos c316a78511 Default MoneyPrinter videos to BoosAPI route 2026-06-03 11:03:04 +08:00
boos 68ae9acb27 Clarify bundled Picture Agent template errors 2026-06-03 11:01:47 +08:00
boos 13c7d8aa4e Remove stale Picture Agent remote template state 2026-06-03 10:59:11 +08:00
boos fb5fc9f1ae Use bundled Linux Do picture templates 2026-06-03 10:55:43 +08:00
boos 5765ea48b0 Clarify Picture Agent template refresh state 2026-06-02 17:38:54 +08:00
boos 55bd3f41f8 Follow Picture Agent gallery data URL 2026-06-02 17:30:15 +08:00
boos 938597b766 Fix Picture Agent loading fallback 2026-06-02 17:23:36 +08:00
boos fe0e3faa22 Generate MoneyPrinter Comfy prompts from form 2026-06-02 17:18:23 +08:00
boos d0f26118ff Normalize MoneyPrinter Comfy workflow templates 2026-06-02 17:11:15 +08:00
boos c98d9a2c6d Load Comfy templates for MoneyPrinter 2026-06-02 17:00:46 +08:00
boos 1293ebae68 Use Comfy API for MoneyPrinter video generation 2026-06-02 16:47:48 +08:00
boos b29c7ffa7a Add native MoneyPrinter short video factory 2026-06-02 15:26:42 +08:00
boos 056e9fb651 Improve digital human template UI 2026-06-02 12:17:05 +08:00
boos fa5a2419d8 Add Comfy digital human jobs 2026-06-02 11:07:31 +08:00
boos 7b34153235 Proxy external template requests 2026-06-01 11:21:56 +08:00
boos 04792bd3f7 Improve offline template loading 2026-06-01 11:02:36 +08:00
boos bb31118792 Reference local Workflow UI repo directly 2026-05-28 17:19:32 +08:00
boos 5027767ca2 Use local Workflow UI source for BossAI deploy 2026-05-28 17:16:12 +08:00
boos fd930757f9 Move BossAI deploy worktree out of tmp 2026-05-28 17:03:08 +08:00
boos eb6ed11cff Add BossAI Debian deploy script 2026-05-28 16:59:51 +08:00
boos 931bcf3fb9 Update BossAI workflow and workspace experience 2026-05-28 10:32:20 +08:00
boos e0bd9b4194 Embed workflow UI and isolate workspace assets 2026-05-26 17:00:10 +08:00
boos 0f9fe4a81d chore: prune legacy node backend and mix workflow models 2026-05-26 15:18:33 +08:00
boos 6869504119 chore: add optional bun frontend build 2026-05-25 22:05:03 +08:00
boos 3f9f9f7665 docs: remove readme screenshots 2026-05-25 21:43:46 +08:00
345 changed files with 97018 additions and 10119 deletions
+6 -5
View File
@@ -1,18 +1,19 @@
# Cargo build acceleration.
#
# The biggest single speedup is the `mold` linker (~3-5x faster link
# time on debug builds). It isn't installed by default on this host
# (no apt-get, see build-and-run.sh notes), so the mold block below is
# commented out — uncomment it after running:
# 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:
#
# sudo apt-get install -y mold
#
# (or build it from source: https://github.com/rui314/mold)
#
# Without mold, the [profile.dev] tweaks in Cargo.toml still give a
# Without mold/lld, the [profile.dev] tweaks in Cargo.toml still give a
# meaningful speedup (line-tables-only debuginfo + bumped codegen-units).
# Uncomment once mold is on PATH:
# Optional: uncomment once mold is on PATH if you want direct cargo commands
# to use it without going through build-and-run.sh.
# [target.x86_64-unknown-linux-gnu]
# linker = "clang"
# rustflags = ["-C", "link-arg=-fuse-ld=mold"]
+16
View File
@@ -2,3 +2,19 @@ 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
+11 -1
View File
@@ -1,13 +1,15 @@
/target
**/*.rs.bk
Cargo.lock
.env
.env.*
data/
node_modules/
website/.next/
website/out/
dist/
release/
*.log
*.tsbuildinfo
logs/
tmp/
!tmp/.gitkeep
@@ -19,5 +21,13 @@ docker/.env
.comfyui/
.claude/
.codex/
.agents/
.cache/
openspec/
boosapi
bossai
docker/*.tar
boosapi.db-shm
boosapi.db-wal
.tours/
bun.lock
Generated
+4177
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -70,6 +70,9 @@ 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"
+2
View File
@@ -81,6 +81,8 @@ boosapi
config.toml
```
部署目录只需要运行产物和配置。仓库里的 `.agents/`、`.cache/`、本地二进制、Docker tar 包、运行日志等均为本地缓存或构建产物,不应进入发布包。`Cargo.lock` 应保留在仓库中,用于保证生产构建依赖可复现。
启动前检查配置:
```bash
+119 -21
View File
@@ -1,6 +1,6 @@
<div align="center">
# BossAI
# BoosAPI
**AI 聚合网关 + Workflow Studio — 将分散的中转站整合为一个统一入口**
@@ -36,7 +36,7 @@
现在 AI 生态里出现了越来越多基于 New API / One API 系列的聚合中转站,要管理多个站点的余额、模型列表和 API 密钥,往往既分散又费时。
**BossAI** 作为中转站之上的**元聚合层**,把多个站点统一到 **一个入口**——下游所有工具即可无感接入全部模型。
**BoosAPI** 作为中转站之上的**元聚合层**,把多个站点统一到 **一个入口**——下游所有工具即可无感接入全部模型。
支持的上游范围:
@@ -45,7 +45,7 @@
- **官方预设**:阿里云、智谱、豆包 Coding Plan、DeepSeek、Moonshot、MiniMax、ModelScope
- **OAuth 连接**:Codex、Claude、Gemini CLI、Antigravity
| 痛点 | BossAI 怎么解决 |
| 痛点 | BoosAPI 怎么解决 |
|------|-----------------|
| 🔑 每个站点一个 Key,下游配置一堆 | **统一代理入口 + 多下游 Key 策略**,模型自动聚合 |
| 💸 不知道哪个站点最便宜 | **智能路由** 按成本、余额、使用率选最优通道 |
@@ -53,10 +53,22 @@
| 📊 余额分散各处 | **集中看板** 一目了然,余额不足自动告警 |
| ✅ 每天签到领额度 | **自动签到** 定时执行,奖励自动追踪 |
| 🤷 不知道各站有什么模型 | **自动模型发现**,上游新增模型零配置 |
| 🎨 想编排图像/视频流程但不想跑本地模型 | **Workflow Studio + 对话助手**,直接复用 BossAI 模型路由 |
| 🎨 想编排图像/视频流程但不想跑本地模型 | **Workflow Studio + 对话助手**,直接复用 BoosAPI 模型路由 |
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
| 📋 不同团队需要不同配额 | **套餐系统**,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)
@@ -156,13 +168,13 @@
## 🖥️ 硬件配置建议
BossAI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时不需要 GPU。实际硬件主要取决于你是否把图像/视频生成放在本机 ComfyUI 上执行。
BoosAPI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时不需要 GPU。实际硬件主要取决于你是否把图像/视频生成放在本机 ComfyUI 上执行。
| 使用模式 | CPU | 内存 | 磁盘 | GPU |
|----------|-----|------|------|-----|
| 仅网关 / 管理后台 / `/v1/*` 代理 | 2 核起步,4 核推荐 | 2GB 起步,4GB 推荐 | 10GB+ | 不需要 |
| BossAI + Workflow UI + 远程模型 API / Comfy Cloud / RunningHub | 4 核推荐 | 4GB 起步,8GB 推荐 | 20-50GB | 不需要 |
| 外部独立 ComfyUI 代理 | 4 核推荐 | 8GB 推荐 | 50GB+ | BossAI 机器不需要;ComfyUI 机器按模型需求配置 |
| BoosAPI + Workflow UI + 远程模型 API / Comfy Cloud / RunningHub | 4 核推荐 | 4GB 起步,8GB 推荐 | 20-50GB | 不需要 |
| 外部独立 ComfyUI 代理 | 4 核推荐 | 8GB 推荐 | 50GB+ | BoosAPI 机器不需要;ComfyUI 机器按模型需求配置 |
| 本机 Docker / 本地 ComfyUI 轻量文生图 | 4-8 核 | 16GB+ | 100GB+ | NVIDIA 8GB VRAM 勉强,12GB+ 推荐 |
| 本机视频生成 / 数字人 / 大工作流 | 8 核+ | 32GB 起步,64GB 推荐 | 200GB-1TB | NVIDIA 24GB VRAM 起步,48GB+ 更稳 |
@@ -175,7 +187,7 @@ BossAI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时
无需 GPU
```
如果图像、视频、语音能力走 BossAI 上游路由、Comfy Cloud、RunningHub 或其他远程 API,本机只负责网关、数据库、Workflow UI 代理、日志和 Workspace 归档,上面这档配置通常足够。只有把 ComfyUI 模型和视频生成放在本机运行时,才需要高显存 NVIDIA GPU。
如果图像、视频、语音能力走 BoosAPI 上游路由、Comfy Cloud、RunningHub 或其他远程 API,本机只负责网关、数据库、Workflow UI 代理、日志和 Workspace 归档,上面这档配置通常足够。只有把 ComfyUI 模型和视频生成放在本机运行时,才需要高显存 NVIDIA GPU。
## 🎛️ Workflow API 后端模式
@@ -218,7 +230,38 @@ base_url = "http://127.0.0.1:8188"
backend = "boosapi"
```
> Workflow UI 有 cloud 与 external/runninghub 两套前端资产。开发环境可用 `npm run workflow-ui:install:cloud`、`npm run workflow-ui:install:external` 单独安装;生产构建会把可用资产嵌入 Rust 二进制。
### 将 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 服务并强刷浏览器缓存。
## 🚀 快速开始
@@ -244,14 +287,14 @@ services:
restart: unless-stopped
EOF
# AUTH_TOKEN = 管理后台登录令牌
# AUTH_TOKEN = 管理后台登录令牌(管理员只支持 API 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`,用 `AUTH_TOKEN` 登录即可。
启动后访问 `http://localhost:4000/boos`,用 `AUTH_TOKEN` 登录管理员后台;普通用户从用户登录页使用账号密码进入用户工作台。
> [!IMPORTANT]
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。
@@ -294,15 +337,16 @@ docker compose up -d
│ ├── billing/ # 计费逻辑
│ └── notification/ # 通知推送
├── migrations/ # SQLite 迁移
├── src/web/ # 前端源码(React 19 + Vite)
├── src/web/ # 管理台前端源码(React + Vite)
│ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶)
│ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage)
│ ├── pages/
│ │ ├── auth/ # 登录页拆分:LoginShell + AdminLoginPage + UserLoginPage(OAuthSlot 占位,行为由后续 change 接入)
│ │ ├── auth/ # 登录页拆分:LoginShell + AdminLoginPage(token-only) + UserLoginPage(OAuthSlot 占位)
│ │ ├── ComfyUIAgent.tsx # 视频/工作流助手聊天界面
│ │ ├── Workflows.tsx # Workflow Studio 入口
│ │ └── ...
│ └── components/
├── website/ # 独立官网(Next.js 16 App Router + React 19)
└── dist/web/ # 前端构建产物(由 Rust serve)
```
@@ -360,7 +404,8 @@ BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
| 协议转换 | OpenAI / Claude / Gemini 全兼容 |
| Workflow Studio | 内嵌 ComfyUI 前端 + Cloud / RunningHub / External / BoosAPI 多后端 |
| 视频助手 | SSE 流式 + gpt-image-2 + TTS 自动生成 |
| 前端框架 | React 19 + Vite(仅构建工具) |
| 管理台前端 | React + Vite(仅构建工具) |
| 独立官网 | Next.js 16 App Router + React 19 + TypeScript |
| 样式 | Tailwind CSS v4 |
| 数据可视化 | VChart |
| 容器化 | Docker + Docker Compose + K3s |
@@ -368,6 +413,8 @@ BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
> **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。
> 独立官网位于 `website/`。默认构建会把官网静态导出到 `dist/website/`,Rust 后端会让根路径 `/` 显示官网;管理台仍由 `dist/web/` 提供,管理员入口是 `/boos`,登录后进入 `/dashboard`。
---
## 🛠️ 本地开发
@@ -375,11 +422,20 @@ BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
### 后端(Rust)
```bash
# 编译并运行
cargo run --release
# 最快验证:只检查 Rust 后端,不构建前端、不启动服务
./build-and-run.sh --check
# 仅检查编译
cargo check
# 日常启动:复用前端缓存,后端 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 test
@@ -390,9 +446,15 @@ 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-boos4721
export BOOSAPI_UPSTREAM_KEY=sk-your-upstream-key
```
构建加速说明:
- 脚本会在 Linux 下自动使用 `mold` / `lld` 快速链接器;可用 `BOOSAPI_FAST_LINKER=off` 关闭。
- `BOOSAPI_CARGO_JOBS=8 ./build-and-run.sh --check` 可限制 Cargo 并发,避免把开发机打满。
- 生产性能验证优先用 `--fast-release`,真正发布再用 `--production` 或 `--release`。
### 前端(React SPA)
前端使用 Vite 构建,产物由 Rust 后端静态 serve:
@@ -404,11 +466,22 @@ npm install
# 开发模式(热更新,需要 Node.js)
cd src/web && npx vite --host 0.0.0.0
# 构建生产版本
npm run build
# 类型检查与构建生产版本
npm run typecheck:web
npm run build:web
# 输出到 dist/web/,Rust 后端自动 serve
```
Workflow UI 前端资产单独更新:
```bash
# Comfy Cloud 前端
npm run workflow-ui:install:cloud
# External / RunningHub 前端
npm run workflow-ui:install:external
```
可选使用 Bun 加速主前端构建:
```bash
@@ -425,6 +498,24 @@ 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
@@ -438,6 +529,13 @@ 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
+226 -14
View File
@@ -17,13 +17,17 @@ 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'
@@ -38,6 +42,8 @@ usage() {
--prod 同 --production
--production-dir <dir>
指定生产产物目录(默认:dist/production)
--check 只执行 cargo check,不构建前端、不启动服务(最快验证)
--backend-check 同 --check
--frontend-only 只构建前端
--backend-only 只构建后端
--force-frontend 强制重建前端,忽略缓存
@@ -46,6 +52,8 @@ 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 显示帮助
@@ -53,6 +61,7 @@ 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
@@ -75,6 +84,11 @@ 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"
@@ -105,12 +119,114 @@ 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"
@@ -224,8 +340,12 @@ 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
@@ -267,6 +387,7 @@ 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
@@ -280,11 +401,71 @@ 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
@@ -293,6 +474,7 @@ 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
@@ -303,43 +485,60 @@ 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
# 提示一次:如果链接器走默认 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
configure_fast_linker
build_cargo_args
CARGO_FEATURE_ARGS=()
if [ -n "$CARGO_FEATURES" ]; then
CARGO_FEATURE_ARGS=(--features "$CARGO_FEATURES")
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[@]}"
fi
ok "后端检查完成"
record_timing "后端检查" "$started"
return 0
fi
if [ "$BUILD_PROFILE" = "release" ]; then
log "构建后端 (cargo build --release ${CARGO_FEATURE_ARGS[*]})..."
cargo build --release "${CARGO_FEATURE_ARGS[@]}"
log "构建后端 (cargo build --release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo build "${CARGO_BASE_ARGS[@]}" --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_FEATURE_ARGS[*]})..."
cargo build --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
log "构建后端 (cargo build --profile fast-release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo build "${CARGO_BASE_ARGS[@]}" --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
BINARY="$ROOT_DIR/target/fast-release/boosapi"
ok "后端构建完成 → target/fast-release/boosapi"
else
log "构建后端 (cargo build ${CARGO_FEATURE_ARGS[*]})..."
cargo build "${CARGO_FEATURE_ARGS[@]}"
log "构建后端 (cargo build ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo build "${CARGO_BASE_ARGS[@]}" "${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"
@@ -360,6 +559,7 @@ build_production_bundle() {
log "产物内容:"
(cd "$PRODUCTION_DIR" && ls -lh boosapi config.toml)
log "部署时只需要复制该目录内的 boosapi 和 config.toml;主 Web 前端已嵌入二进制。"
record_timing "生产打包" "$started"
}
cleanup_started_server() {
@@ -374,12 +574,15 @@ 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
@@ -387,16 +590,25 @@ 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 "启动服务..."
+31 -1
View File
@@ -25,6 +25,28 @@ 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
@@ -35,12 +57,20 @@ 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/94d1330ea62d4ae980ffb018ecdf82a2"
# base_url = "https://www.runninghub.cn/proxy-plus/94d1330ea62d4ae980ffb018ecdf82a2"
# For RunningHub, set backend = "runninghub" and use your RunningHub proxy/API URL.
# RunningHub proxy URL already contains the API key. Keep this empty unless
# you switch to a backend that requires Authorization / X-API-Key headers.
base_url = "https://cloud.comfy.org"
api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f"
agent_model = "gpt-5.5"
@@ -0,0 +1,3 @@
ALTER TABLE proxy_logs ADD COLUMN content_summary TEXT;
ALTER TABLE proxy_logs ADD COLUMN request_content TEXT;
ALTER TABLE proxy_logs ADD COLUMN response_content TEXT;
@@ -0,0 +1,51 @@
-- Performance-oriented projections and indexes for logs/workspace views.
CREATE INDEX IF NOT EXISTS proxy_logs_key_created_status_idx
ON proxy_logs (downstream_api_key_id, created_at DESC, status);
CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC);
CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
ON proxy_logs (content_summary);
CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
ON workspace_assets (
user_id,
status,
deleted_at,
source,
kind,
created_at DESC
);
CREATE INDEX IF NOT EXISTS workspace_assets_output_role_created_idx
ON workspace_assets (
json_extract(metadata, '$.workspaceAsset.role'),
created_at DESC
);
CREATE INDEX IF NOT EXISTS workspace_assets_workflow_output_created_idx
ON workspace_assets (
json_extract(metadata, '$.workflowUi.type'),
created_at DESC
);
CREATE TABLE IF NOT EXISTS workspace_asset_daily (
local_day TEXT NOT NULL,
user_id INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL,
kind TEXT NOT NULL,
asset_count INTEGER NOT NULL DEFAULT 0,
local_file_count INTEGER NOT NULL DEFAULT 0,
total_size_bytes INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(local_day, user_id, source, kind)
);
CREATE INDEX IF NOT EXISTS workspace_asset_daily_user_day_idx
ON workspace_asset_daily (user_id, local_day DESC);
CREATE INDEX IF NOT EXISTS workspace_asset_daily_source_day_idx
ON workspace_asset_daily (source, local_day DESC);
@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS user_recharge_records (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
option_id TEXT,
label TEXT,
title TEXT,
amount_usd REAL,
provider TEXT NOT NULL DEFAULT 'comfy_checkout',
checkout_url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
note TEXT,
metadata TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
completed_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS user_recharge_records_public_id_unique
ON user_recharge_records (public_id);
CREATE INDEX IF NOT EXISTS user_recharge_records_user_created_idx
ON user_recharge_records (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS user_recharge_records_user_status_idx
ON user_recharge_records (user_id, status);
@@ -0,0 +1,39 @@
CREATE TABLE IF NOT EXISTS payment_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
recharge_record_id INTEGER,
provider TEXT NOT NULL,
payment_method TEXT NOT NULL DEFAULT 'stripe',
provider_session_id TEXT,
provider_payment_id TEXT,
amount_usd REAL NOT NULL,
amount_minor INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'usd',
status TEXT NOT NULL DEFAULT 'pending',
checkout_url TEXT,
success_url TEXT,
cancel_url TEXT,
note TEXT,
metadata TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
paid_at TEXT,
completed_at TEXT,
expires_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (recharge_record_id) REFERENCES user_recharge_records(id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS payment_orders_public_id_unique
ON payment_orders (public_id);
CREATE UNIQUE INDEX IF NOT EXISTS payment_orders_provider_session_unique
ON payment_orders (provider, provider_session_id)
WHERE provider_session_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS payment_orders_user_created_idx
ON payment_orders (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS payment_orders_status_idx
ON payment_orders (status);
@@ -0,0 +1,3 @@
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS content_summary TEXT;
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS request_content TEXT;
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS response_content TEXT;
@@ -0,0 +1,45 @@
-- Performance-oriented projections and indexes for logs/workspace views.
CREATE INDEX IF NOT EXISTS proxy_logs_key_created_status_idx
ON proxy_logs (downstream_api_key_id, created_at DESC, status);
CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC);
CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
ON proxy_logs (content_summary);
CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
ON workspace_assets (
user_id,
status,
deleted_at,
source,
kind,
created_at DESC
);
CREATE INDEX IF NOT EXISTS workspace_assets_output_role_created_idx
ON workspace_assets (((metadata::jsonb #>> '{workspaceAsset,role}')), created_at DESC);
CREATE INDEX IF NOT EXISTS workspace_assets_workflow_output_created_idx
ON workspace_assets (((metadata::jsonb #>> '{workflowUi,type}')), created_at DESC);
CREATE TABLE IF NOT EXISTS workspace_asset_daily (
local_day TEXT NOT NULL,
user_id BIGINT NOT NULL DEFAULT 0,
source TEXT NOT NULL,
kind TEXT NOT NULL,
asset_count BIGINT NOT NULL DEFAULT 0,
local_file_count BIGINT NOT NULL DEFAULT 0,
total_size_bytes BIGINT NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
UNIQUE(local_day, user_id, source, kind)
);
CREATE INDEX IF NOT EXISTS workspace_asset_daily_user_day_idx
ON workspace_asset_daily (user_id, local_day DESC);
CREATE INDEX IF NOT EXISTS workspace_asset_daily_source_day_idx
ON workspace_asset_daily (source, local_day DESC);
+7 -2
View File
@@ -32,20 +32,25 @@
"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 .",
"test:watch": "vitest --root ."
"test": "vitest run --root src/web",
"test:watch": "vitest --root src/web"
},
"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"
},
+8
View File
@@ -20,6 +20,9 @@ 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
@@ -2210,6 +2213,9 @@ 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==}
@@ -5138,6 +5144,8 @@ snapshots:
graceful-fs@4.2.11: {}
gsap@3.15.0: {}
hachure-fill@0.5.2: {}
has-symbols@1.1.0:
+39 -3
View File
@@ -35,9 +35,18 @@ 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: { ...process.env, ...(options.env || {}) },
env,
stdio: 'inherit',
shell: process.platform === 'win32',
});
@@ -55,6 +64,11 @@ 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;
@@ -77,17 +91,39 @@ 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 });
await run('pnpm', ['exec', 'vite', 'build', '--config', 'vite.config.mts'], {
cwd: dir,
cleanPackageEnv: true,
env: packageEnv,
});
} else {
await run('npm', ['ci'], { cwd: dir });
await run('npm', ['run', 'build'], { cwd: dir });
await run('npm', ['run', 'build'], {
cwd: dir,
cleanPackageEnv: true,
env: packageEnv,
});
}
await fs.rm(dist, { recursive: true, force: true });
await fs.cp(path.join(dir, 'dist'), dist, { recursive: true });
await fs.writeFile(
path.join(dist, 'boosapi-workflow-ui.json'),
`${JSON.stringify({
variant: name,
ref,
packageName: packageInfo.name || null,
packageVersion: packageInfo.version || null,
}, null, 2)}\n`,
);
console.log(`workflow-ui:${name} installed to ${dist}`);
if (name === 'cloud') {
+980 -2
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -13,6 +13,8 @@ pub struct AppConfig {
pub proxy: ProxyConfig,
#[serde(default)]
pub comfyui: ComfyUIConfig,
#[serde(default)]
pub payment: PaymentConfig,
}
#[derive(Debug, Deserialize, Clone)]
@@ -56,6 +58,10 @@ 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")]
@@ -71,6 +77,23 @@ 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 {
@@ -95,6 +118,8 @@ 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,
@@ -110,6 +135,26 @@ 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!(
+18
View File
@@ -8,6 +8,7 @@ use sqlx::{
pub mod models;
pub mod queries;
pub mod schema_compat;
pub mod sqlite_to_postgres;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
@@ -45,6 +46,23 @@ pub async fn create_pool(database_url: &str, max_connections: u32) -> anyhow::Re
}
let pool = SqlitePoolOptions::new()
.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)
+383
View File
@@ -0,0 +1,383 @@
use anyhow::Context;
use sqlx::{Row, SqlitePool};
use std::collections::HashSet;
pub async fn ensure_sqlite_upstream_compat(pool: &SqlitePool) -> anyhow::Result<()> {
ensure_columns(
pool,
"sites",
&[
("post_refresh_probe_enabled", "INTEGER DEFAULT false"),
("post_refresh_probe_model", "TEXT DEFAULT ''"),
("post_refresh_probe_scope", "TEXT DEFAULT 'single'"),
(
"post_refresh_probe_latency_threshold_ms",
"INTEGER DEFAULT 0",
),
],
)
.await?;
ensure_columns(
pool,
"downstream_api_keys",
&[
("excluded_site_ids", "TEXT"),
("excluded_credential_refs", "TEXT"),
],
)
.await?;
ensure_columns(
pool,
"route_channels",
&[("oauth_route_unit_id", "INTEGER")],
)
.await?;
ensure_columns(
pool,
"proxy_logs",
&[
("client_family", "TEXT"),
("client_app_id", "TEXT"),
("client_app_name", "TEXT"),
("client_confidence", "TEXT"),
("is_stream", "INTEGER"),
("first_byte_latency_ms", "INTEGER"),
("billing_details", "TEXT"),
("content_summary", "TEXT"),
("request_content", "TEXT"),
("response_content", "TEXT"),
],
)
.await?;
run_compat_ddl(pool).await?;
Ok(())
}
async fn ensure_columns(
pool: &SqlitePool,
table: &str,
columns: &[(&str, &str)],
) -> anyhow::Result<()> {
let existing = table_columns(pool, table).await?;
if existing.is_empty() {
return Ok(());
}
for (name, definition) in columns {
if existing.contains(*name) {
continue;
}
let sql = format!("ALTER TABLE {table} ADD {name} {definition}");
sqlx::query(&sql)
.execute(pool)
.await
.with_context(|| format!("add column {table}.{name}"))?;
}
Ok(())
}
async fn table_columns(pool: &SqlitePool, table: &str) -> anyhow::Result<HashSet<String>> {
let sql = format!("PRAGMA table_info({table})");
let rows = sqlx::query(&sql).fetch_all(pool).await?;
Ok(rows
.into_iter()
.filter_map(|row| row.try_get::<String, _>("name").ok())
.collect())
}
async fn run_compat_ddl(pool: &SqlitePool) -> anyhow::Result<()> {
for statement in SQLITE_COMPAT_DDL {
sqlx::query(statement)
.execute(pool)
.await
.with_context(|| format!("run SQLite compat DDL: {statement}"))?;
}
Ok(())
}
const SQLITE_COMPAT_DDL: &[&str] = &[
"CREATE INDEX IF NOT EXISTS proxy_logs_client_app_id_created_at_idx
ON proxy_logs (client_app_id, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_logs_client_family_created_at_idx
ON proxy_logs (client_family, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_logs_key_created_status_idx
ON proxy_logs (downstream_api_key_id, created_at DESC, status)",
"CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC)",
"CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
ON proxy_logs (content_summary)",
"CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
ON workspace_assets (user_id, status, deleted_at, source, kind, created_at DESC)",
"CREATE INDEX IF NOT EXISTS workspace_assets_output_role_created_idx
ON workspace_assets (json_extract(metadata, '$.workspaceAsset.role'), created_at DESC)",
"CREATE INDEX IF NOT EXISTS workspace_assets_workflow_output_created_idx
ON workspace_assets (json_extract(metadata, '$.workflowUi.type'), created_at DESC)",
"CREATE TABLE IF NOT EXISTS workspace_asset_daily (
local_day TEXT NOT NULL,
user_id INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL,
kind TEXT NOT NULL,
asset_count INTEGER NOT NULL DEFAULT 0,
local_file_count INTEGER NOT NULL DEFAULT 0,
total_size_bytes INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(local_day, user_id, source, kind)
)",
"CREATE INDEX IF NOT EXISTS workspace_asset_daily_user_day_idx
ON workspace_asset_daily (user_id, local_day DESC)",
"CREATE INDEX IF NOT EXISTS workspace_asset_daily_source_day_idx
ON workspace_asset_daily (source, local_day DESC)",
"CREATE INDEX IF NOT EXISTS route_channels_oauth_route_unit_id_idx
ON route_channels (oauth_route_unit_id)",
"CREATE TABLE IF NOT EXISTS site_api_endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
site_id INTEGER NOT NULL,
url TEXT NOT NULL,
enabled INTEGER DEFAULT true,
sort_order INTEGER DEFAULT 0,
cooldown_until TEXT,
last_selected_at TEXT,
last_failed_at TEXT,
last_failure_reason TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
)",
"CREATE UNIQUE INDEX IF NOT EXISTS site_api_endpoints_site_url_unique
ON site_api_endpoints (site_id, url)",
"CREATE INDEX IF NOT EXISTS site_api_endpoints_site_enabled_sort_idx
ON site_api_endpoints (site_id, enabled, sort_order)",
"CREATE INDEX IF NOT EXISTS site_api_endpoints_site_cooldown_idx
ON site_api_endpoints (site_id, cooldown_until)",
"CREATE TABLE IF NOT EXISTS proxy_debug_traces (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
downstream_path TEXT NOT NULL,
client_kind TEXT,
session_id TEXT,
trace_hint TEXT,
requested_model TEXT,
downstream_api_key_id INTEGER,
request_headers_json TEXT,
request_body_json TEXT,
sticky_session_key TEXT,
sticky_hit_channel_id INTEGER,
selected_channel_id INTEGER,
selected_route_id INTEGER,
selected_account_id INTEGER,
selected_site_id INTEGER,
selected_site_platform TEXT,
endpoint_candidates_json TEXT,
endpoint_runtime_state_json TEXT,
decision_summary_json TEXT,
final_status TEXT,
final_http_status INTEGER,
final_upstream_path TEXT,
final_response_headers_json TEXT,
final_response_body_json TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_created_at_idx
ON proxy_debug_traces (created_at)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_session_created_at_idx
ON proxy_debug_traces (session_id, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_model_created_at_idx
ON proxy_debug_traces (requested_model, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_final_status_created_at_idx
ON proxy_debug_traces (final_status, created_at)",
"CREATE TABLE IF NOT EXISTS proxy_debug_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
trace_id INTEGER NOT NULL,
attempt_index INTEGER NOT NULL,
endpoint TEXT NOT NULL,
request_path TEXT NOT NULL,
target_url TEXT NOT NULL,
runtime_executor TEXT,
request_headers_json TEXT,
request_body_json TEXT,
response_status INTEGER,
response_headers_json TEXT,
response_body_json TEXT,
raw_error_text TEXT,
recover_applied INTEGER DEFAULT false,
downgrade_decision INTEGER DEFAULT false,
downgrade_reason TEXT,
memory_write_json TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (trace_id) REFERENCES proxy_debug_traces(id) ON DELETE CASCADE
)",
"CREATE UNIQUE INDEX IF NOT EXISTS proxy_debug_attempts_trace_attempt_unique
ON proxy_debug_attempts (trace_id, attempt_index)",
"CREATE INDEX IF NOT EXISTS proxy_debug_attempts_trace_created_at_idx
ON proxy_debug_attempts (trace_id, created_at)",
"CREATE TABLE IF NOT EXISTS oauth_route_units (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
site_id INTEGER NOT NULL,
provider TEXT NOT NULL,
name TEXT NOT NULL,
strategy TEXT NOT NULL DEFAULT 'round_robin',
enabled INTEGER DEFAULT true,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
)",
"CREATE INDEX IF NOT EXISTS oauth_route_units_site_provider_idx
ON oauth_route_units (site_id, provider)",
"CREATE INDEX IF NOT EXISTS oauth_route_units_enabled_idx
ON oauth_route_units (enabled)",
"CREATE TABLE IF NOT EXISTS oauth_route_unit_members (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
unit_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
sort_order INTEGER DEFAULT 0,
success_count INTEGER DEFAULT 0,
fail_count INTEGER DEFAULT 0,
total_latency_ms INTEGER DEFAULT 0,
total_cost REAL DEFAULT 0,
last_used_at TEXT,
last_selected_at TEXT,
last_fail_at TEXT,
consecutive_fail_count INTEGER NOT NULL DEFAULT 0,
cooldown_level INTEGER NOT NULL DEFAULT 0,
cooldown_until TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (unit_id) REFERENCES oauth_route_units(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
)",
"CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_unit_account_unique
ON oauth_route_unit_members (unit_id, account_id)",
"CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_account_unique
ON oauth_route_unit_members (account_id)",
"CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_sort_idx
ON oauth_route_unit_members (unit_id, sort_order)",
"CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_cooldown_idx
ON oauth_route_unit_members (unit_id, cooldown_until)",
"CREATE TABLE IF NOT EXISTS admin_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
namespace TEXT NOT NULL,
snapshot_key TEXT NOT NULL,
payload TEXT NOT NULL,
generated_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
stale_until TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
"CREATE UNIQUE INDEX IF NOT EXISTS admin_snapshots_namespace_key_unique
ON admin_snapshots (namespace, snapshot_key)",
"CREATE INDEX IF NOT EXISTS admin_snapshots_expires_at_idx
ON admin_snapshots (expires_at)",
"CREATE INDEX IF NOT EXISTS admin_snapshots_stale_until_idx
ON admin_snapshots (stale_until)",
"CREATE TABLE IF NOT EXISTS analytics_projection_checkpoints (
projector_key TEXT PRIMARY KEY NOT NULL,
time_zone TEXT NOT NULL DEFAULT 'Local',
last_proxy_log_id INTEGER NOT NULL DEFAULT 0,
watermark_created_at TEXT,
recompute_from_id INTEGER,
recompute_requested_at TEXT,
recompute_reason TEXT,
recompute_started_at TEXT,
recompute_completed_at TEXT,
last_projected_at TEXT,
last_successful_at TEXT,
last_error TEXT,
lease_owner TEXT,
lease_token TEXT,
lease_expires_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
"CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_recompute_from_id_idx
ON analytics_projection_checkpoints (recompute_from_id)",
"CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_lease_expires_at_idx
ON analytics_projection_checkpoints (lease_expires_at)",
"CREATE TABLE IF NOT EXISTS model_day_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
local_day TEXT NOT NULL,
site_id INTEGER NOT NULL,
model TEXT NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CHECK (
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
AND total_tokens >= 0 AND total_spend >= 0
AND total_latency_ms >= 0 AND latency_count >= 0
)
)",
"CREATE UNIQUE INDEX IF NOT EXISTS model_day_usage_day_site_model_unique
ON model_day_usage (local_day, site_id, model)",
"CREATE INDEX IF NOT EXISTS model_day_usage_day_idx
ON model_day_usage (local_day)",
"CREATE INDEX IF NOT EXISTS model_day_usage_site_id_idx
ON model_day_usage (site_id)",
"CREATE INDEX IF NOT EXISTS model_day_usage_model_idx
ON model_day_usage (model)",
"CREATE TABLE IF NOT EXISTS site_day_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
local_day TEXT NOT NULL,
site_id INTEGER NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_summary_spend REAL NOT NULL DEFAULT 0,
total_site_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CHECK (
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
AND total_tokens >= 0 AND total_summary_spend >= 0
AND total_site_spend >= 0 AND total_latency_ms >= 0
AND latency_count >= 0
)
)",
"CREATE UNIQUE INDEX IF NOT EXISTS site_day_usage_day_site_unique
ON site_day_usage (local_day, site_id)",
"CREATE INDEX IF NOT EXISTS site_day_usage_day_idx
ON site_day_usage (local_day)",
"CREATE INDEX IF NOT EXISTS site_day_usage_site_id_idx
ON site_day_usage (site_id)",
"CREATE TABLE IF NOT EXISTS site_hour_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
bucket_start_utc TEXT NOT NULL,
site_id INTEGER NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_summary_spend REAL NOT NULL DEFAULT 0,
total_site_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CHECK (
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
AND total_tokens >= 0 AND total_summary_spend >= 0
AND total_site_spend >= 0 AND total_latency_ms >= 0
AND latency_count >= 0
)
)",
"CREATE UNIQUE INDEX IF NOT EXISTS site_hour_usage_hour_site_unique
ON site_hour_usage (bucket_start_utc, site_id)",
"CREATE INDEX IF NOT EXISTS site_hour_usage_hour_idx
ON site_hour_usage (bucket_start_utc)",
"CREATE INDEX IF NOT EXISTS site_hour_usage_site_id_idx
ON site_hour_usage (site_id)",
];
+194
View File
@@ -299,6 +299,200 @@ 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(
+9
View File
@@ -66,6 +66,14 @@ 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!({
@@ -74,6 +82,7 @@ impl IntoResponse for AppError {
"type": status.canonical_reason(),
},
"retry_after_secs": retry_after_secs,
"billing": billing,
})),
)
.into_response()
+22 -2
View File
@@ -11,14 +11,16 @@ pub mod transformers;
pub mod workflow;
use sqlx::SqlitePool;
use std::sync::Arc;
use std::{sync::Arc, time::Duration};
#[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>,
}
@@ -28,6 +30,7 @@ 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()
@@ -39,19 +42,36 @@ impl AppState {
let comfyui = services::comfyui_process::ComfyUIProcess::new(
config.comfyui.clone(),
boosapi_url.clone(),
config.auth.proxy_token.clone(),
runtime_auth.proxy_token(),
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()
}
}
+20 -4
View File
@@ -1,11 +1,16 @@
use anyhow::Context;
use axum::serve;
use boosapi::{config::AppConfig, db, services::builtin_keys, AppState};
use boosapi::{
config::AppConfig,
db,
services::{builtin_keys, runtime_auth},
AppState,
};
use clap::{Parser, Subcommand};
use std::ffi::OsString;
use std::net::SocketAddr;
use tokio::signal;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::{prelude::*, EnvFilter};
#[derive(Parser, Debug)]
#[command(
@@ -104,7 +109,11 @@ async fn main() -> anyhow::Result<()> {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(&config.server.log_level));
tracing_subscriber::fmt().with_env_filter(filter).init();
tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer())
.with(boosapi::services::runtime_logs::RuntimeLogLayer)
.init();
tracing::info!("Loaded configuration from {}", args.config);
@@ -116,6 +125,7 @@ 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 {
@@ -134,9 +144,15 @@ 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);
let state = AppState::new(config.clone(), pool, builtin, runtime_auth);
if let Err(err) = state.comfyui.start().await {
tracing::warn!(%err, "ComfyUI embedded process did not start");
}
+494 -48
View File
@@ -14,9 +14,7 @@ use crate::transformers::{
canonical::CanonicalRequest,
};
use crate::AppState;
use reqwest::Client;
use serde_json::Value;
use std::time::Duration;
use serde_json::{json, Value};
/// Resolved upstream info for a given model request.
#[derive(Debug, Clone)]
@@ -26,15 +24,6 @@ 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,
@@ -174,11 +163,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 = http_client();
let client = state.http.clone();
let upstream_url = upstream_url(&route.base_url, "/chat/completions");
let resp = client
@@ -210,19 +199,29 @@ 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(route, "/chat/completions", body).await
execute_streaming_request(state, route, "/chat/completions", body).await
}
/// Execute a streaming chat completions request and return the live upstream response.
pub async fn execute_chat_completions_stream_response(
state: AppState,
route: UpstreamRoute,
body: Value,
) -> AppResult<reqwest::Response> {
execute_streaming_response(state, route, "/chat/completions", body).await
}
/// Execute an Anthropic Messages API request against an Anthropic-shaped upstream.
pub async fn execute_anthropic_messages(
state: AppState,
route: UpstreamRoute,
request: CanonicalRequest,
) -> AppResult<Value> {
let client = http_client();
let client = state.http.clone();
let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/'));
let compatibility = AnthropicMessagesCompatibility::default();
let body = format_messages_request_with_compat(&request, &compatibility);
@@ -260,10 +259,21 @@ pub async fn execute_anthropic_messages(
/// Execute a streaming Anthropic Messages request and return Anthropic SSE frames.
pub async fn execute_anthropic_messages_stream(
state: AppState,
route: UpstreamRoute,
request: CanonicalRequest,
) -> AppResult<Vec<String>> {
let client = http_client();
let response = execute_anthropic_messages_stream_response(state, route, request).await?;
collect_anthropic_stream_frames(response).await
}
/// Execute a streaming Anthropic Messages request and return the live upstream response.
pub async fn execute_anthropic_messages_stream_response(
state: AppState,
route: UpstreamRoute,
request: CanonicalRequest,
) -> AppResult<reqwest::Response> {
let client = state.http.clone();
let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/'));
let compatibility = AnthropicMessagesCompatibility::default();
let body = format_messages_request_with_compat(&request, &compatibility);
@@ -292,6 +302,10 @@ pub async fn execute_anthropic_messages_stream(
)));
}
Ok(resp)
}
async fn collect_anthropic_stream_frames(resp: reqwest::Response) -> AppResult<Vec<String>> {
let mut frames = Vec::new();
let mut stream = resp.bytes_stream();
use futures::StreamExt;
@@ -406,32 +420,12 @@ fn denormalize_anthropic_stop_reason(reason: &str) -> &str {
/// Execute a streaming request against any upstream path (e.g. /responses).
/// Returns SSE `data: ...` lines verbatim.
pub async fn execute_streaming_request(
state: AppState,
route: UpstreamRoute,
path_suffix: &str,
body: Value,
) -> AppResult<Vec<String>> {
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 resp = execute_streaming_response(state, route, path_suffix, body).await?;
let mut chunks = Vec::new();
let mut stream = resp.bytes_stream();
@@ -455,14 +449,50 @@ 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 = http_client();
let client = state.http.clone();
let upstream_url = upstream_url(&route.base_url, path_suffix);
let resp = client
@@ -492,8 +522,12 @@ pub async fn execute_proxy_request(
}
/// Generic GET proxy request to an upstream endpoint path.
pub async fn execute_proxy_get(route: UpstreamRoute, path_suffix: &str) -> AppResult<Value> {
let client = http_client();
pub async fn execute_proxy_get(
state: AppState,
route: UpstreamRoute,
path_suffix: &str,
) -> AppResult<Value> {
let client = state.http.clone();
let upstream_url = upstream_url(&route.base_url, path_suffix);
let resp = client
@@ -518,6 +552,23 @@ pub async fn execute_proxy_get(route: UpstreamRoute, path_suffix: &str) -> AppRe
parse_upstream_json(&text, &upstream_url, status)
}
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(
@@ -525,6 +576,10 @@ 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;
@@ -533,6 +588,76 @@ 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")
}
@@ -602,13 +727,334 @@ fn scalar_to_string(value: &Value) -> Option<String> {
}
}
fn scalar_string(value: Option<&Value>) -> Option<String> {
scalar_to_string(value?)
}
fn workflow_prompt_from_graph(graph: &Value) -> Option<Value> {
graph.get("prompt")
.cloned()
.or_else(|| graph.get("workflow").cloned())
.or_else(|| graph.get("comfyPrompt").cloned())
.or_else(|| graph_to_comfy_prompt(graph))
.or_else(|| graph.is_object().then(|| graph.clone()))
}
fn comfy_mapping_from_graph(graph: &Value) -> Option<Value> {
graph.pointer("/metadata/moneyPrinter/nodeMapping")
.or_else(|| graph.pointer("/moneyPrinter/nodeMapping"))
.or_else(|| graph.pointer("/metadata/video/nodeMapping"))
.or_else(|| graph.pointer("/video/nodeMapping"))
.or_else(|| graph.pointer("/metadata/digitalHuman/nodeMapping"))
.or_else(|| graph.pointer("/digitalHuman/nodeMapping"))
.cloned()
}
fn graph_to_comfy_prompt(graph: &Value) -> Option<Value> {
let nodes = graph.get("nodes")?.as_array()?;
let mut prompt = serde_json::Map::new();
for node in nodes {
let id = scalar_string(node.get("id"))?;
let class_type = node
.get("type")
.or_else(|| node.get("class_type"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if class_type.trim().is_empty() {
continue;
}
let inputs = node
.get("params")
.or_else(|| node.get("inputs"))
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
prompt.insert(id, json!({ "class_type": class_type, "inputs": inputs }));
}
for edge in graph.get("edges").and_then(Value::as_array).into_iter().flatten() {
let Some(from) = edge.get("from").and_then(Value::as_str) else { continue; };
let Some(to) = edge.get("to").and_then(Value::as_str) else { continue; };
let Some((from_id, _from_port)) = from.split_once('.') else { continue; };
let Some((to_id, to_field)) = to.split_once('.') else { continue; };
if let Some(inputs) = prompt
.get_mut(to_id)
.and_then(Value::as_object_mut)
.and_then(|node| node.get_mut("inputs"))
.and_then(Value::as_object_mut)
{
inputs.insert(to_field.to_string(), json!([from_id, 0]));
}
}
Some(Value::Object(prompt))
}
fn default_comfy_mapping() -> Value {
json!({
"prompt": { "nodeId": "", "field": "prompt" },
"negativePrompt": { "nodeId": "", "field": "text" },
"ratio": { "nodeId": "", "field": "ratio" },
"resolution": { "nodeId": "", "field": "resolution" },
"duration": { "nodeId": "", "field": "duration" }
})
}
fn mapping_value<'a>(mapping: &'a Value, keys: &[&str]) -> Option<&'a Value> {
keys.iter().find_map(|key| mapping.get(*key))
}
fn mapping_node_id(mapping: &Value, keys: &[&str]) -> Option<String> {
mapping_value(mapping, keys).and_then(|value| {
if value.is_object() {
scalar_string(value.get("nodeId").or_else(|| value.get("node_id")).or(Some(value)))
} else {
scalar_string(Some(value))
}
})
}
fn mapping_field(mapping: &Value, keys: &[&str], default_field: &str) -> String {
mapping_value(mapping, keys)
.and_then(|value| {
if value.is_object() {
scalar_string(value.get("field").or_else(|| value.get("fieldName")))
} else {
None
}
})
.unwrap_or_else(|| default_field.to_string())
}
fn mapping_has_node(mapping: &Value, keys: &[&str]) -> bool {
mapping_node_id(mapping, keys).is_some_and(|value| !value.trim().is_empty())
}
fn first_node_by_class(prompt: &serde_json::Map<String, Value>, class_type: &str) -> Option<String> {
prompt
.iter()
.find(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some(class_type))
.map(|(id, _)| id.clone())
}
fn infer_comfy_mapping(prompt: &Value) -> Value {
let Some(object) = prompt.as_object() else {
return default_comfy_mapping();
};
let prompt_node = object
.iter()
.find(|(_, node)| {
let class_type = node.get("class_type").and_then(Value::as_str).unwrap_or_default().to_ascii_lowercase();
let inputs = node
.get("inputs")
.and_then(Value::as_object)
.map(|value| value.keys().cloned().collect::<Vec<_>>().join(" ").to_ascii_lowercase())
.unwrap_or_default();
class_type.contains("text")
|| class_type.contains("prompt")
|| class_type.contains("wan")
|| class_type.contains("ltx")
|| class_type.contains("video")
|| inputs.contains("prompt")
|| inputs.contains("positive")
|| inputs.contains("text")
})
.map(|(id, _)| id.clone())
.unwrap_or_default();
let video_node = first_node_by_class(object, "Wan2ImageToVideoApi")
.or_else(|| first_node_by_class(object, "Wan2TextToVideoApi"))
.or_else(|| first_node_by_class(object, "TextGenerateLTX2Prompt"))
.or_else(|| first_node_by_class(object, "KSampler"))
.unwrap_or_else(|| prompt_node.clone());
json!({
"prompt": { "nodeId": prompt_node, "field": "text" },
"ratio": { "nodeId": video_node, "field": "ratio" },
"resolution": { "nodeId": video_node, "field": "resolution" },
"duration": { "nodeId": video_node, "field": "duration" }
})
}
fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value) -> AppResult<()> {
if node_id.trim().is_empty() {
return Err(AppError::BadRequest("Comfy 视频模型节点映射包含空 nodeId".into()));
}
let Some(node) = prompt.get_mut(node_id) else {
return Err(AppError::BadRequest(format!("Comfy prompt missing node {node_id}")));
};
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
return Err(AppError::BadRequest(format!("Comfy node {node_id} has no inputs")));
};
inputs.insert(field.to_string(), value);
Ok(())
}
fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value) -> AppResult<String> {
let video_prompt = scalar_string(body.get("prompt"))
.ok_or_else(|| AppError::BadRequest("prompt is required for Comfy video models".into()))?;
let prompt_node = mapping_node_id(mapping, &["prompt", "positive", "text", "promptNode"])
.ok_or_else(|| AppError::BadRequest("Comfy 视频模型缺少提示词节点映射".into()))?;
apply_node_input(
prompt,
&prompt_node,
&mapping_field(mapping, &["prompt", "positive", "text", "promptNode"], "prompt"),
json!(video_prompt),
)?;
if let Some(negative_node) = mapping_node_id(mapping, &["negativePrompt", "negative", "negativeNode"]) {
let negative = scalar_string(body.get("negative_prompt").or_else(|| body.get("negative")))
.unwrap_or_else(|| "low quality, blurry, distorted, watermark, text artifacts".to_string());
apply_node_input(
prompt,
&negative_node,
&mapping_field(mapping, &["negativePrompt", "negative", "negativeNode"], "text"),
json!(negative),
)?;
}
for (keys, request_keys, default_field) in [
(&["ratio", "ratioNode"][..], &["ratio", "aspectRatio", "aspect_ratio"][..], "ratio"),
(&["resolution", "resolutionNode"][..], &["resolution", "size"][..], "resolution"),
(&["duration", "durationNode"][..], &["duration"][..], "duration"),
] {
if let Some(node_id) = mapping_node_id(mapping, keys) {
if let Some(value) = request_keys.iter().find_map(|key| body.get(*key).cloned()) {
apply_node_input(prompt, &node_id, &mapping_field(mapping, keys, default_field), value)?;
}
}
}
Ok(video_prompt)
}
fn prompt_contains_class_type(prompt: &Value, class_type: &str) -> bool {
match prompt {
Value::Object(object) => {
object
.get("class_type")
.and_then(Value::as_str)
.is_some_and(|value| value == class_type)
|| object.values().any(|value| prompt_contains_class_type(value, class_type))
}
Value::Array(items) => items.iter().any(|value| prompt_contains_class_type(value, class_type)),
_ => false,
}
}
fn comfy_native_path(state: &AppState, path: &str) -> String {
if state.config.comfyui.uses_cloud_backend() {
format!(
"/api{}",
path.strip_prefix('/')
.map(|value| format!("/{value}"))
.unwrap_or_else(|| path.to_string())
)
} else {
path.to_string()
}
}
fn comfy_native_url(state: &AppState, path: &str) -> String {
format!(
"{}{}",
state.config.comfyui.target_base_url(),
comfy_native_path(state, path)
)
}
async fn comfy_json(
state: &AppState,
method: reqwest::Method,
path: &str,
body: Option<Value>,
) -> AppResult<Value> {
let client = state.http.clone();
let mut request = client.request(method, comfy_native_url(state, path));
if let Some(api_key) = state
.config
.comfyui
.api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
request = request
.header("X-API-Key", api_key)
.header("Authorization", format!("Bearer {api_key}"));
}
if let Some(body) = body {
request = request.json(&body);
}
let response = request
.send()
.await
.map_err(|err| AppError::UpstreamError(format!("Comfy backend unreachable: {err}")))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
let value = serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({ "body": text }));
if !status.is_success() {
return Err(AppError::UpstreamError(value.to_string()));
}
Ok(value)
}
async fn comfy_history(state: &AppState, prompt_id: &str) -> AppResult<Value> {
let path = if state.config.comfyui.uses_cloud_backend() {
format!("/jobs/{prompt_id}")
} else {
format!("/history/{prompt_id}")
};
let history = comfy_json(state, reqwest::Method::GET, &path, None).await?;
let failed = comfy_failure_message(&history);
Ok(json!({
"id": prompt_id,
"object": "video.generation.task",
"backend": "comfy",
"status": if failed.is_some() {
"failed"
} else if comfy_output_objects(&history) {
"succeeded"
} else {
"running"
},
"error": failed,
"history": history,
}))
}
fn comfy_failure_message(value: &Value) -> Option<String> {
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
.unwrap_or_default()
.to_ascii_lowercase();
if matches!(status.as_str(), "failed" | "error" | "cancelled" | "canceled") {
return Some(
scalar_string(value.pointer("/execution_error/exception_message"))
.or_else(|| scalar_string(value.get("error")))
.or_else(|| scalar_string(value.get("message")))
.unwrap_or_else(|| format!("Comfy job {status}")),
);
}
scalar_string(value.pointer("/execution_error/exception_message"))
}
fn comfy_output_objects(value: &Value) -> bool {
match value {
Value::Object(object) => {
["videos", "gifs", "images"]
.iter()
.any(|key| object.get(*key).and_then(Value::as_array).is_some_and(|items| !items.is_empty()))
|| object.values().any(comfy_output_objects)
}
Value::Array(items) => items.iter().any(comfy_output_objects),
_ => false,
}
}
/// Execute an image generation request.
pub async fn execute_image_generation(
_state: AppState,
state: AppState,
route: UpstreamRoute,
body: Value,
) -> AppResult<Value> {
let client = http_client();
let client = state.http.clone();
let upstream_url = upstream_url(&route.base_url, "/images/generations");
let resp = client
@@ -661,13 +1107,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 = http_client();
let client = state.http.clone();
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
+255 -11
View File
@@ -13,6 +13,8 @@ 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>,
@@ -151,10 +153,23 @@ 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>)> =
let latest_health_rows: Vec<(
i64,
String,
Option<i64>,
Option<String>,
Option<i64>,
Option<String>,
Option<String>,
Option<String>,
Option<i64>,
Option<i64>,
i64,
)> =
sqlx::query_as(
r#"
SELECT l.account_id, l.status, l.http_status, l.error_message, l.latency_ms, l.created_at
SELECT l.account_id, l.status, l.http_status, l.error_message, l.latency_ms, l.created_at,
l.model_requested, l.model_actual, l.route_id, l.channel_id, l.id
FROM proxy_logs l
INNER JOIN (
SELECT account_id, MAX(id) AS id
@@ -176,14 +191,42 @@ 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)| {
|(
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),
(
status,
http_status,
error_message,
latency_ms,
created_at,
model_requested,
model_actual,
route_id,
channel_id,
log_id,
),
)
},
)
@@ -214,6 +257,11 @@ fn account_response_value(
Option<String>,
Option<i64>,
Option<String>,
Option<String>,
Option<String>,
Option<i64>,
Option<i64>,
i64,
)>,
) -> Value {
let credential_mode = resolve_account_credential_mode(account);
@@ -246,16 +294,46 @@ fn account_response_value(
"state": "unhealthy",
"reason": "连接凭证已过期,请更新凭证",
})
} else if let Some((status, http_status, error_message, latency_ms, created_at)) =
} else if let Some((
status,
http_status,
error_message,
latency_ms,
created_at,
model_requested,
model_actual,
route_id,
channel_id,
log_id,
)) =
runtime_health
{
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 { "healthy" } else { "unhealthy" },
"state": if !ok {
"unhealthy"
} else if is_degraded_latency {
"degraded"
} else {
"healthy"
},
"reason": if ok {
match latency_ms {
Some(ms) => format!("最近代理请求成功,用时 {}ms", ms),
None => "最近代理请求成功".to_string(),
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
@@ -265,11 +343,17 @@ 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": "degraded",
"reason": "尚无代理请求记录,等待首次调用后更新",
"state": "unknown",
"reason": "尚无代理请求记录,首次调用后自动更新",
})
} else {
json!({
@@ -1266,3 +1350,163 @@ pub async fn refresh_account_models(
}
})))
}
#[cfg(test)]
mod tests {
use super::*;
fn account_fixture(status: &str, credential_mode: &str) -> Account {
Account {
id: 7,
site_id: 3,
username: Some("tester".to_string()),
access_token: "sk-test".to_string(),
api_token: None,
balance: Some(0.0),
balance_used: Some(0.0),
quota: None,
unit_cost: None,
value_score: None,
status: status.to_string(),
checkin_enabled: Some(false),
last_checkin_at: None,
last_balance_refresh: None,
extra_config: Some(json!({ "credentialMode": credential_mode }).to_string()),
is_pinned: Some(false),
sort_order: Some(0),
oauth_provider: None,
oauth_account_key: None,
oauth_project_id: None,
created_at: None,
updated_at: None,
}
}
fn site_fixture(status: &str) -> Site {
Site {
id: 3,
name: "Demo".to_string(),
platform: "openai".to_string(),
url: "https://example.com".to_string(),
status: status.to_string(),
api_key: None,
proxy_url: None,
use_system_proxy: None,
custom_headers: None,
external_checkin_url: None,
global_weight: None,
post_refresh_probe_enabled: None,
post_refresh_probe_model: None,
post_refresh_probe_scope: None,
post_refresh_probe_latency_threshold_ms: None,
is_pinned: Some(false),
sort_order: Some(0),
created_at: None,
updated_at: None,
}
}
#[test]
fn active_apikey_without_proxy_logs_is_pending_detection_not_degraded() {
let account = account_fixture("active", "apikey");
let site = site_fixture("active");
let value = account_response_value(&account, Some(&site), None);
let runtime_health = value
.get("runtimeHealth")
.expect("runtimeHealth should be present");
assert_eq!(
runtime_health.get("state").and_then(Value::as_str),
Some("unknown")
);
assert_eq!(
runtime_health.get("reason").and_then(Value::as_str),
Some("尚无代理请求记录,首次调用后自动更新")
);
}
#[test]
fn runtime_health_includes_recent_proxy_context_for_diagnostics() {
let account = account_fixture("active", "apikey");
let site = site_fixture("active");
let latest = (
"failed".to_string(),
Some(404),
Some("Not found".to_string()),
Some(1820),
Some("2026-06-08 10:00:00".to_string()),
Some("gpt-4o-mini".to_string()),
Some("gpt-4o-mini-2026".to_string()),
Some(77),
Some(88),
101,
);
let value = account_response_value(&account, Some(&site), Some(&latest));
let runtime_health = value
.get("runtimeHealth")
.expect("runtimeHealth should be present");
assert_eq!(
runtime_health.get("state").and_then(Value::as_str),
Some("unhealthy")
);
assert_eq!(
runtime_health.get("recentModel").and_then(Value::as_str),
Some("gpt-4o-mini-2026")
);
assert_eq!(
runtime_health.get("modelRequested").and_then(Value::as_str),
Some("gpt-4o-mini")
);
assert_eq!(
runtime_health.get("routeId").and_then(Value::as_i64),
Some(77)
);
assert_eq!(
runtime_health.get("channelId").and_then(Value::as_i64),
Some(88)
);
assert_eq!(
runtime_health.get("logId").and_then(Value::as_i64),
Some(101)
);
}
#[test]
fn successful_but_slow_proxy_log_marks_runtime_health_degraded() {
let account = account_fixture("active", "apikey");
let site = site_fixture("active");
let latest = (
"success".to_string(),
Some(200),
None,
Some(7928),
Some("2026-06-08 10:00:00".to_string()),
Some("gpt-4o-mini".to_string()),
Some("gpt-4o-mini".to_string()),
Some(77),
Some(88),
101,
);
let value = account_response_value(&account, Some(&site), Some(&latest));
let runtime_health = value
.get("runtimeHealth")
.expect("runtimeHealth should be present");
assert_eq!(
runtime_health.get("state").and_then(Value::as_str),
Some("degraded")
);
assert_eq!(
runtime_health.get("reason").and_then(Value::as_str),
Some("最近代理请求成功但延迟偏高,用时 7928ms")
);
assert_eq!(
runtime_health.get("latencyMs").and_then(Value::as_i64),
Some(7928)
);
}
}
+1982 -17
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -417,6 +417,7 @@ mod tests {
BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-abcdef0123456789".into()),
},
crate::services::runtime_auth::RuntimeAuthSettings::from_token_for_test("proxy"),
)
}
+77 -17
View File
@@ -24,7 +24,7 @@ mod sites;
mod tokens;
mod users;
/// Middleware to require admin authentication via config token or admin JWT.
/// Middleware to require admin authentication via the configured admin API token.
async fn require_admin_auth(
State(state): State<AppState>,
headers: HeaderMap,
@@ -37,20 +37,8 @@ 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 {
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()));
}
if auth_header != state.config.auth.auth_token {
return Err(AppError::Unauthorized("Invalid admin token".into()));
}
Ok(next.run(request).await)
@@ -73,6 +61,15 @@ 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()
@@ -217,6 +214,7 @@ 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.
@@ -288,9 +286,15 @@ 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()
@@ -313,6 +317,12 @@ 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 {
@@ -349,11 +359,12 @@ 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 any
/// `builtin_key.*` rows.
/// 5.2 — `GET /api/settings` returns regular settings but never protected
/// secret rows.
#[tokio::test]
async fn list_settings_filters_builtin_key_prefix() {
let state = settings_state().await;
@@ -364,6 +375,10 @@ 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.*`
@@ -402,4 +417,49 @@ 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);
}
}
+894 -2
View File
@@ -1,16 +1,27 @@
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, State},
extract::{Path, Query, State},
http::StatusCode,
response::sse::{Event, Sse},
Json,
};
use futures::Stream;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tracing::info;
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};
#[derive(Debug, Deserialize)]
pub struct DetectSiteRequest {
@@ -398,12 +409,89 @@ 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>,
@@ -477,6 +565,727 @@ 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() {
@@ -645,3 +1454,86 @@ 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")
);
}
}
+150 -1
View File
@@ -324,7 +324,10 @@ pub async fn get_user_usage(
// Recent logs
let recent_logs: Vec<Value> = sqlx::query(
r#"
SELECT l.*
SELECT
l.*,
k.id AS downstream_api_key_id,
k.name AS downstream_key_name
FROM usage_logs l
JOIN downstream_api_keys k ON l.downstream_key_id = k.id
WHERE k.user_id = ?
@@ -341,6 +344,11 @@ 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),
@@ -699,3 +707,144 @@ 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");
}
}
+867 -13
View File
@@ -10,14 +10,14 @@ use crate::{
};
use axum::{
extract::{ConnectInfo, Path, State},
http::HeaderMap,
http::{header, HeaderMap},
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::net::SocketAddr;
use tracing::info;
use std::net::{IpAddr, SocketAddr};
use tracing::{info, warn};
// ── Request / Response types ─────────────────────────────────────────
@@ -70,7 +70,6 @@ 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))
@@ -229,24 +228,82 @@ 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 = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
.bind(&payload.email)
let user: User = if let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
.bind(&email)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::Unauthorized("Invalid email or password".into()))?;
{
user
} else {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::Password,
success: false,
user: None,
identifier: Some(&email),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Invalid email or password"),
})
.await;
return Err(AppError::Unauthorized("Invalid email or password".into()));
};
if user.status != "active" {
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()));
}
@@ -258,6 +315,18 @@ 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": {
@@ -288,10 +357,25 @@ 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()));
}
@@ -302,20 +386,91 @@ async fn login_api_key(
.fetch_optional(&*state.db)
.await?;
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 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 user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
let user: User = if let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(user_id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::Unauthorized("User not found".into()))?;
{
user
} else {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: None,
identifier: Some(&api_key.name),
api_key: Some(&api_key),
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("User not found"),
})
.await;
return Err(AppError::Unauthorized("User not found".into()));
};
if user.status != "active" {
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);
@@ -327,6 +482,18 @@ 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": {
@@ -503,6 +670,225 @@ async fn get_user(
})))
}
// ── User login audit helpers ──────────────────────────────────────────
#[derive(Debug, Clone, Copy)]
enum LoginMethod {
Password,
ApiKey,
}
impl LoginMethod {
fn label(self) -> &'static str {
match self {
LoginMethod::Password => "password",
LoginMethod::ApiKey => "api_key",
}
}
fn title_label(self) -> &'static str {
match self {
LoginMethod::Password => "密码登录",
LoginMethod::ApiKey => "API Key 登录",
}
}
}
struct LoginAuditEvent<'a> {
method: LoginMethod,
success: bool,
user: Option<&'a User>,
identifier: Option<&'a str>,
api_key: Option<&'a DownstreamApiKey>,
client_ip: &'a str,
user_agent: Option<&'a str>,
reason: Option<&'a str>,
}
async fn audit_user_login(state: &AppState, audit: LoginAuditEvent<'_>) {
let event_type = "user_login";
let level = if audit.success { "info" } else { "warning" };
let account_label = display_user_for_audit(audit.user, audit.identifier);
let title = if audit.success {
format!("用户登录成功:{account_label}")
} else {
format!("用户登录失败:{account_label}")
};
let mut message_parts = Vec::new();
if let Some(user) = audit.user {
message_parts.push(format!("账号={}", user_account_label_for_audit(user)));
if !user.email.trim().is_empty() {
message_parts.push(format!("邮箱={}", user.email.trim()));
}
message_parts.push(format!("用户ID={}", user.id));
} else if let Some(identifier) = audit.identifier {
message_parts.push(format!("标识={}", identifier));
}
message_parts.push(format!("方式={}", audit.method.label()));
message_parts.push(format!("IP={}", audit.client_ip));
if let Some(api_key) = audit.api_key {
message_parts.push(format!("密钥={}", api_key.name));
message_parts.push(format!("密钥ID={}", api_key.id));
}
if let Some(reason) = audit.reason.filter(|value| !value.trim().is_empty()) {
message_parts.push(format!("原因={reason}"));
}
if let Some(user_agent) = audit.user_agent.filter(|value| !value.trim().is_empty()) {
message_parts.push(format!("UA={}", truncate_for_audit(user_agent, 180)));
}
if let Err(err) = sqlx::query(
"INSERT INTO events (type, title, message, level, read, related_id, related_type)
VALUES (?, ?, ?, ?, 0, ?, ?)",
)
.bind(event_type)
.bind(title)
.bind(message_parts.join(" · "))
.bind(level)
.bind(audit.user.map(|user| user.id))
.bind(if audit.user.is_some() { Some("user") } else { None::<&str> })
.execute(&*state.db)
.await
{
warn!(
%err,
method = audit.method.label(),
success = audit.success,
client_ip = audit.client_ip,
"failed to write user login audit event",
);
} else {
info!(
method = audit.method.label(),
method_title = audit.method.title_label(),
success = audit.success,
client_ip = audit.client_ip,
"user login audit recorded",
);
}
}
fn display_user_for_audit(user: Option<&User>, identifier: Option<&str>) -> String {
if let Some(user) = user {
return user_account_label_for_audit(user);
}
identifier
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("未知用户")
.to_string()
}
fn user_account_label_for_audit(user: &User) -> String {
let username = user.username.trim();
let email = user.email.trim();
match (username.is_empty(), email.is_empty(), username == email) {
(false, false, false) => format!("{username} <{email}>"),
(false, _, _) => username.to_string(),
(_, false, _) => email.to_string(),
_ => format!("用户 #{}", user.id),
}
}
fn user_agent_from_headers(headers: &HeaderMap) -> Option<String> {
headers
.get(header::USER_AGENT)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| truncate_for_audit(value, 240))
}
fn client_ip_from_headers(headers: &HeaderMap, fallback: SocketAddr) -> String {
let mut candidates = Vec::new();
for name in [
"cf-connecting-ip",
"x-real-ip",
"x-forwarded-for",
"forwarded",
] {
candidates.extend(header_ip_values(headers, name));
}
if let Some(ipv4) = candidates.iter().find(|value| is_ipv4_address(value)) {
return ipv4.clone();
}
if let Some(first) = candidates.into_iter().next() {
return first;
}
fallback.ip().to_string()
}
fn is_ipv4_address(value: &str) -> bool {
matches!(value.parse::<IpAddr>(), Ok(IpAddr::V4(_)))
}
fn header_ip_values(headers: &HeaderMap, name: &str) -> Vec<String> {
let Some(raw) = headers.get(name).and_then(|value| value.to_str().ok()) else {
return Vec::new();
};
let raw = raw.trim();
if raw.is_empty() {
return Vec::new();
}
if name.eq_ignore_ascii_case("forwarded") {
return forwarded_header_ips(raw);
}
raw.split(',').filter_map(normalize_ip_token).collect()
}
fn forwarded_header_ips(raw: &str) -> Vec<String> {
raw.split(',')
.flat_map(|entry| entry.split(';'))
.filter_map(|part| {
let (key, value) = part.split_once('=')?;
if !key.trim().eq_ignore_ascii_case("for") {
return None;
}
normalize_ip_token(value)
})
.collect()
}
fn normalize_ip_token(raw: &str) -> Option<String> {
let mut token = raw.trim().trim_matches('"').trim_matches('\'').trim();
if token.is_empty() || token.eq_ignore_ascii_case("unknown") {
return None;
}
if token.starts_with('[') {
if let Some(end) = token.find(']') {
token = &token[1..end];
}
} else if let Some((host, port)) = token.rsplit_once(':') {
if host.matches(':').count() == 0 && port.parse::<u16>().is_ok() {
token = host;
}
}
token
.parse::<IpAddr>()
.map(|ip| ip.to_string())
.ok()
}
fn truncate_for_audit(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
return value.to_string();
}
let mut truncated = value.chars().take(max_chars.saturating_sub(1)).collect::<String>();
truncated.push('…');
truncated
}
fn mask_api_key(key: &str) -> String {
let trimmed = key.trim();
let tail = trimmed.chars().rev().take(4).collect::<Vec<_>>();
if tail.is_empty() {
return "sk-****".to_string();
}
let suffix = tail.into_iter().rev().collect::<String>();
format!("••••{suffix}")
}
// ── Inline JWT extract helper for this module ──────────────────────────
fn extract_jwt(state: &AppState, headers: &HeaderMap) -> AppResult<(i64, String)> {
@@ -522,3 +908,471 @@ fn extract_jwt(state: &AppState, headers: &HeaderMap) -> AppResult<(i64, String)
Ok((uid, claims.role))
}
#[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}");
}
}
+111 -1
View File
@@ -6,6 +6,7 @@
use crate::{
auth::api_key::{authorize_downstream_token, DownstreamAuthResult},
auth::jwt::validate_jwt,
error::{AppError, AppResult},
AppState,
};
@@ -47,10 +48,20 @@ 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(&state.config.auth.proxy_token))
authorize_downstream_token(auth, &state.db, Some(&proxy_token))
.await
.map_err(AppError::Unauthorized)?;
@@ -68,6 +79,105 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
auth::jwt::generate_jwt,
config::{AppConfig, AuthConfig, ComfyUIConfig, DbConfig, ProxyConfig, ServerConfig},
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
};
use axum::http::HeaderValue;
use sqlx::sqlite::SqlitePoolOptions;
async fn test_state() -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool");
AppState::new(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 4000,
log_level: "info".to_string(),
cors_origins: Vec::new(),
},
db: DbConfig {
url: "sqlite::memory:".to_string(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "test-jwt-secret".to_string(),
jwt_expiry_hours: 24,
auth_token: "test-admin-token".to_string(),
proxy_token: "sk-test-proxy".to_string(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 1,
proxy_log_retention_days: 7,
template_proxy_url: None,
},
comfyui: ComfyUIConfig::default(),
},
pool,
BuiltinKeys {
comfyui_agent: (0, "sk-comfyui-test".to_string()),
},
RuntimeAuthSettings::from_token_for_test("sk-test-proxy"),
)
}
fn bearer_headers(token: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
"Authorization",
HeaderValue::from_str(&format!("Bearer {token}")).expect("valid bearer header"),
);
headers
}
#[tokio::test]
async fn comfyui_auth_accepts_admin_jwt() {
let state = test_state().await;
let token = generate_jwt(
&state.config.auth.jwt_secret,
"42",
"admin",
state.config.auth.jwt_expiry_hours,
)
.expect("admin jwt");
check_auth(&state, &bearer_headers(&token))
.await
.expect("admin jwt can access comfyui proxy routes");
}
#[tokio::test]
async fn comfyui_auth_rejects_user_jwt() {
let state = test_state().await;
let token = generate_jwt(
&state.config.auth.jwt_secret,
"43",
"user",
state.config.auth.jwt_expiry_hours,
)
.expect("user jwt");
let result = check_auth(&state, &bearer_headers(&token)).await;
assert!(matches!(result, Err(AppError::Forbidden(_))));
}
}
/// Get ComfyUI base URL from embedded/external runtime configuration.
fn comfyui_base(state: &AppState) -> String {
state.comfyui.internal_base_url()
+130 -19
View File
@@ -243,11 +243,12 @@ async fn resolve_agent_auth(
)
})?;
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
let proxy_token = state.runtime_proxy_token();
if token == state.config.auth.auth_token || token == proxy_token {
return Ok(AgentAuthContext {
user_id: None,
role: "admin".to_string(),
outbound_key: state.config.auth.proxy_token.clone(),
outbound_key: proxy_token,
key_name: "global-proxy".to_string(),
});
}
@@ -282,7 +283,7 @@ async fn resolve_agent_auth(
Err(_) if claims.role == "admin" => Ok(AgentAuthContext {
user_id: Some(user_id),
role: claims.role,
outbound_key: state.config.auth.proxy_token.clone(),
outbound_key: state.runtime_proxy_token(),
key_name: "global-proxy".to_string(),
}),
Err(message) => Err((
@@ -368,6 +369,8 @@ 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,
})))
@@ -680,6 +683,77 @@ 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
@@ -936,22 +1010,7 @@ async fn chat_handler(
continue;
};
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 let Some(reasoning) = extract_reasoning_delta(&parsed) {
if !send_sse(
&tx,
json!({
@@ -963,6 +1022,14 @@ async fn chat_handler(
}
}
let delta = parsed
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(|choice| choice.get("delta"))
.cloned()
.unwrap_or(Value::Null);
let content = delta
.get("content")
.and_then(Value::as_str)
@@ -1243,6 +1310,7 @@ mod tests {
BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-test".into()),
},
crate::services::runtime_auth::RuntimeAuthSettings::from_token_for_test("proxy"),
)
}
@@ -1262,6 +1330,49 @@ 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`.
+617 -14
View File
@@ -2,6 +2,7 @@ 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,
@@ -9,7 +10,7 @@ use crate::{
use axum::{
body::Bytes,
extract::{Multipart, Path, Query, State},
http::{header, HeaderMap, Method},
http::{header, HeaderMap, Method, StatusCode},
routing::{get, post},
Json, Router,
};
@@ -22,11 +23,61 @@ 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)]
@@ -104,11 +155,14 @@ 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()))?;
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
let proxy_token = state.runtime_proxy_token();
if token == state.config.auth.auth_token || token == proxy_token {
return Ok(DigitalHumanAuth {
user_id: None,
role: "admin".to_string(),
outbound_key: state.config.auth.proxy_token.clone(),
outbound_key: proxy_token,
key_id: None,
key_name: "global-proxy".to_string(),
});
}
@@ -127,21 +181,26 @@ 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.config.auth.proxy_token.clone(),
outbound_key: state.runtime_proxy_token(),
key_id: None,
key_name: "global-proxy".to_string(),
}),
Err(message) => Err(AppError::Forbidden(message)),
}
}
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let _ = resolve_auth(&headers, &state).await?;
let auth = 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"
@@ -485,6 +544,7 @@ 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" },
@@ -519,11 +579,106 @@ 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())
}
@@ -582,6 +737,16 @@ 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,
@@ -676,6 +841,13 @@ 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,
@@ -702,6 +874,35 @@ 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()),
@@ -719,18 +920,50 @@ async fn submit_prompt(state: &AppState, prompt: Value) -> AppResult<(String, Va
Ok((prompt_id, value))
}
async fn prompt_history(state: &AppState, prompt_id: &str) -> AppResult<Value> {
native_json(state, Method::GET, &format!("/history/{prompt_id}"), None).await
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"))
}
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) {
out.extend(items.iter().filter(|item| item.is_object()).cloned());
for item in items {
walk(item, out);
}
}
}
for value in object.values() {
@@ -742,6 +975,11 @@ 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);
}
}
_ => {}
}
}
@@ -749,10 +987,112 @@ 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 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") {
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")
{
"video"
} else if lower.ends_with(".mp3") || lower.ends_with(".wav") || lower.ends_with(".m4a") {
"audio"
@@ -763,7 +1103,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) = scalar_string(item.get("filename")) {
if let Some(filename) = output_filename(item) {
params.insert("filename".to_string(), filename);
}
if let Some(file_type) = scalar_string(item.get("type")) {
@@ -808,6 +1148,56 @@ 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();
@@ -835,6 +1225,7 @@ async fn archive_output(
model: Some("comfy-native".to_string()),
remote_url: None,
metadata: json!({
"workspaceAsset": { "role": "output" },
"digitalHuman": {
"promptId": prompt_id,
"filename": filename,
@@ -941,6 +1332,9 @@ 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(
@@ -963,8 +1357,11 @@ 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,
@@ -986,6 +1383,14 @@ 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?;
@@ -993,8 +1398,11 @@ 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,
@@ -1014,7 +1422,15 @@ async fn create_job(
}
update_progress(&state_for_task, &task_id_for_spawn, 3, "Comfy 正在生成视频")
.await?;
let history = prompt_history(&state_for_task, &prompt_id).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 outputs = output_objects(&history);
if !outputs.is_empty() {
update_progress(
@@ -1038,17 +1454,62 @@ 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
@@ -1068,6 +1529,27 @@ 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')
@@ -1230,7 +1712,10 @@ async fn upload_asset(
prompt: None,
model: None,
remote_url: None,
metadata: json!({ "digitalHumanUpload": { "purpose": purpose, "fileName": file_name } }),
metadata: json!({
"workspaceAsset": { "role": "input" },
"digitalHumanUpload": { "purpose": purpose, "fileName": file_name },
}),
};
let asset = workspace::record_bytes_asset(&state, input, &bytes, &mime_type).await?;
let file_url = format!(
@@ -1250,3 +1735,121 @@ 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")
);
}
}
+671 -13
View File
@@ -1,14 +1,23 @@
// Events routes — system event notifications
use crate::{db::models::event::Event, error::AppResult, AppState};
use crate::{
db::models::event::Event,
error::{AppError, AppResult},
services::runtime_logs,
AppState,
};
use axum::{
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::info;
use tracing::{debug, info};
#[derive(Debug, Deserialize)]
pub struct ListEventsQuery {
@@ -19,12 +28,35 @@ pub struct ListEventsQuery {
read: Option<String>, // "true" | "false"
}
pub fn routes() -> Router<AppState> {
pub fn routes(state: AppState) -> 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
@@ -32,20 +64,29 @@ async fn list_events(
State(state): State<AppState>,
Query(q): Query<ListEventsQuery>,
) -> AppResult<Json<Value>> {
info!("Listing events");
debug!("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 * FROM events WHERE 1=1".to_string();
let mut bind_idx = 0;
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 params: Vec<String> = vec![];
if let Some(ref t) = q.filter_type {
bind_idx += 1;
sql.push_str(&format!(" AND type = ?{}", bind_idx));
if let Some(ref t) = filter_type {
sql.push_str(" AND type = ?");
params.push(t.clone());
}
if let Some(ref r) = q.read {
if let Some(r) = read_filter {
if r == "true" {
sql.push_str(" AND read = 1");
} else if r == "false" {
@@ -53,16 +94,304 @@ async fn list_events(
}
}
sql.push_str(" ORDER BY created_at DESC");
sql.push_str(&format!(" LIMIT {} OFFSET {}", limit, offset));
sql.push_str(" ORDER BY created_at DESC LIMIT 1000");
let mut query = sqlx::query_as::<_, Event>(&sql);
for p in &params {
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();
Ok(Json(json!({ "data": events })))
if should_include_source(filter_type.as_deref(), "runtime") {
rows.extend(
runtime_logs::list()
.into_iter()
.filter(|entry| read_filter != Some("false") || !entry.is_read)
.map(EventListItem::from),
);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "checkin") {
rows.extend(load_checkin_log_events(&state, source_limit).await?);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "proxy") {
rows.extend(load_proxy_log_events(&state, source_limit).await?);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "balance") {
rows.extend(load_balance_log_events(&state, source_limit).await?);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "token") {
rows.extend(load_token_log_events(&state, source_limit).await?);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "site_notice") {
rows.extend(load_site_notice_events(&state, source_limit).await?);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "status") {
rows.extend(load_status_log_events(&state, source_limit).await?);
}
rows.sort_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then_with(|| a.id.cmp(&b.id))
});
let start = offset as usize;
let end = start.saturating_add(limit as usize).min(rows.len());
let page = if start < rows.len() {
rows[start..end].to_vec()
} else {
Vec::new()
};
Ok(Json(json!({ "data": page })))
}
fn should_include_source(filter_type: Option<&str>, source: &str) -> bool {
filter_type.is_none_or(|value| value == source)
}
fn should_include_read_only_source(
filter_type: Option<&str>,
read_filter: Option<&str>,
source: &str,
) -> bool {
should_include_source(filter_type, source) && read_filter != Some("false")
}
async fn load_checkin_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-10000000000 - c.id) AS id,
'checkin' AS event_type,
CASE
WHEN lower(COALESCE(c.status, '')) IN ('success', 'ok', 'completed') THEN '签到成功'
WHEN lower(COALESCE(c.status, '')) IN ('skipped', 'skip') THEN '签到跳过'
ELSE '签到失败'
END || ':' || COALESCE(NULLIF(s.name, ''), '账号 #' || c.account_id) AS title,
COALESCE(NULLIF(a.username, ''), '账号 #' || c.account_id)
|| ' · 状态=' || COALESCE(NULLIF(c.status, ''), '-')
|| CASE WHEN c.reward IS NOT NULL AND trim(c.reward) != '' THEN ' · 奖励=' || c.reward ELSE '' END
|| CASE WHEN c.message IS NOT NULL AND trim(c.message) != '' THEN ' · ' || c.message ELSE '' END AS message,
CASE
WHEN lower(COALESCE(c.status, '')) IN ('success', 'ok', 'completed') THEN 'info'
WHEN lower(COALESCE(c.status, '')) IN ('skipped', 'skip') THEN 'warning'
ELSE 'error'
END AS level,
1 AS is_read,
c.id AS related_id,
'checkin_log' AS related_type,
c.created_at AS created_at
FROM checkin_logs c
LEFT JOIN accounts a ON a.id = c.account_id
LEFT JOIN sites s ON s.id = a.site_id
ORDER BY c.created_at DESC
LIMIT ?
"#,
)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(rows)
}
async fn load_proxy_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-20000000000 - l.id) AS id,
'proxy' AS event_type,
CASE
WHEN lower(COALESCE(l.status, '')) = 'success' THEN '代理请求成功'
ELSE '代理请求失败'
END || ':' || COALESCE(NULLIF(l.model_requested, ''), NULLIF(l.model_actual, ''), '未知模型') AS title,
'站点=' || COALESCE(NULLIF(s.name, ''), '-')
|| ' · 账号=' || COALESCE(NULLIF(a.username, ''), CASE WHEN l.account_id IS NOT NULL THEN '账号 #' || l.account_id ELSE '-' END)
|| ' · 状态=' || COALESCE(NULLIF(l.status, ''), '-')
|| CASE WHEN l.http_status IS NOT NULL THEN ' · HTTP ' || l.http_status ELSE '' END
|| CASE WHEN l.latency_ms IS NOT NULL THEN ' · ' || l.latency_ms || 'ms' ELSE '' END
|| CASE WHEN l.total_tokens IS NOT NULL THEN ' · Tokens=' || l.total_tokens ELSE '' END
|| CASE WHEN l.estimated_cost IS NOT NULL THEN ' · 成本=' || printf('$%.6f', l.estimated_cost) ELSE '' END
|| CASE WHEN l.error_message IS NOT NULL AND trim(l.error_message) != '' THEN ' · ' || l.error_message ELSE '' END AS message,
CASE WHEN lower(COALESCE(l.status, '')) = 'success' THEN 'info' ELSE 'error' END AS level,
1 AS is_read,
l.id AS related_id,
'proxy_log' AS related_type,
l.created_at AS created_at
FROM proxy_logs l
LEFT JOIN accounts a ON a.id = l.account_id
LEFT JOIN sites s ON s.id = a.site_id
ORDER BY l.created_at DESC
LIMIT ?
"#,
)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(rows)
}
async fn load_balance_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-30000000000 - a.id) AS id,
'balance' AS event_type,
'余额已刷新:' || COALESCE(NULLIF(s.name, ''), '账号 #' || a.id) AS title,
COALESCE(NULLIF(a.username, ''), '账号 #' || a.id)
|| ' · 当前余额=' || printf('%.6f', COALESCE(a.balance, 0))
|| ' · 已用=' || printf('%.6f', COALESCE(a.balance_used, 0))
|| ' · 额度=' || printf('%.6f', COALESCE(a.quota, 0)) AS message,
'info' AS level,
1 AS is_read,
a.id AS related_id,
'account' AS related_type,
a.last_balance_refresh AS created_at
FROM accounts a
LEFT JOIN sites s ON s.id = a.site_id
WHERE a.last_balance_refresh IS NOT NULL
AND trim(a.last_balance_refresh) != ''
ORDER BY a.last_balance_refresh DESC
LIMIT ?
"#,
)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(rows)
}
async fn load_token_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-40000000000 - t.id) AS id,
'token' AS event_type,
CASE
WHEN COALESCE(t.updated_at, t.created_at, '') != COALESCE(t.created_at, '') THEN '令牌已更新'
ELSE '令牌已创建'
END || ':' || COALESCE(NULLIF(t.name, ''), 'Token #' || t.id) AS title,
COALESCE(NULLIF(s.name, ''), '-')
|| ' · ' || COALESCE(NULLIF(a.username, ''), '账号 #' || t.account_id)
|| ' · 状态=' || COALESCE(NULLIF(t.value_status, ''), '-')
|| CASE WHEN COALESCE(t.enabled, 1) = 1 THEN ' · 已启用' ELSE ' · 已禁用' END
|| CASE WHEN t.token_group IS NOT NULL AND trim(t.token_group) != '' THEN ' · 分组=' || t.token_group ELSE '' END
|| CASE WHEN t.source IS NOT NULL AND trim(t.source) != '' THEN ' · 来源=' || t.source ELSE '' END AS message,
CASE
WHEN COALESCE(t.enabled, 1) = 1
AND lower(COALESCE(t.value_status, 'ready')) IN ('ready', 'valid') THEN 'info'
ELSE 'warning'
END AS level,
1 AS is_read,
t.id AS related_id,
'account_token' AS related_type,
COALESCE(t.updated_at, t.created_at) AS created_at
FROM account_tokens t
LEFT JOIN accounts a ON a.id = t.account_id
LEFT JOIN sites s ON s.id = a.site_id
WHERE COALESCE(t.updated_at, t.created_at) IS NOT NULL
AND trim(COALESCE(t.updated_at, t.created_at)) != ''
ORDER BY COALESCE(t.updated_at, t.created_at) DESC
LIMIT ?
"#,
)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(rows)
}
async fn load_site_notice_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-50000000000 - n.id) AS id,
'site_notice' AS event_type,
'站点公告:' || COALESCE(NULLIF(n.title, ''), '未命名公告') AS title,
COALESCE(NULLIF(s.name, ''), '站点 #' || n.site_id)
|| CASE WHEN n.content IS NOT NULL AND trim(n.content) != '' THEN ' · ' || substr(n.content, 1, 420) ELSE '' END AS message,
COALESCE(NULLIF(n.level, ''), 'info') AS level,
1 AS is_read,
n.id AS related_id,
'site_announcement' AS related_type,
COALESCE(n.upstream_created_at, n.first_seen_at) AS created_at
FROM site_announcements n
LEFT JOIN sites s ON s.id = n.site_id
ORDER BY COALESCE(n.upstream_created_at, n.first_seen_at) DESC
LIMIT ?
"#,
)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(rows)
}
async fn load_status_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let per_source_limit = limit.clamp(50, 1000);
let mut rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-60000000000 - s.id) AS id,
'status' AS event_type,
'站点状态:' || COALESCE(NULLIF(s.name, ''), '站点 #' || s.id) AS title,
'平台=' || COALESCE(NULLIF(s.platform, ''), '-')
|| ' · 状态=' || COALESCE(NULLIF(s.status, ''), '-')
|| CASE WHEN s.url IS NOT NULL AND trim(s.url) != '' THEN ' · ' || s.url ELSE '' END AS message,
CASE
WHEN lower(COALESCE(s.status, 'active')) IN ('active', 'enabled', 'healthy') THEN 'info'
WHEN lower(COALESCE(s.status, '')) IN ('disabled', 'inactive') THEN 'warning'
ELSE 'error'
END AS level,
1 AS is_read,
s.id AS related_id,
'site' AS related_type,
COALESCE(s.updated_at, s.created_at) AS created_at
FROM sites s
WHERE COALESCE(s.updated_at, s.created_at) IS NOT NULL
AND trim(COALESCE(s.updated_at, s.created_at)) != ''
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
LIMIT ?
"#,
)
.bind(per_source_limit)
.fetch_all(&*state.db)
.await?;
rows.extend(
sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-70000000000 - a.id) AS id,
'status' AS event_type,
'账号状态:' || COALESCE(NULLIF(s.name, ''), '站点 #' || a.site_id) AS title,
COALESCE(NULLIF(a.username, ''), '账号 #' || a.id)
|| ' · 状态=' || COALESCE(NULLIF(a.status, ''), '-')
|| CASE WHEN a.oauth_provider IS NOT NULL AND trim(a.oauth_provider) != '' THEN ' · OAuth=' || a.oauth_provider ELSE '' END AS message,
CASE
WHEN lower(COALESCE(a.status, 'active')) IN ('active', 'enabled', 'healthy') THEN 'info'
WHEN lower(COALESCE(a.status, '')) IN ('disabled', 'inactive') THEN 'warning'
ELSE 'error'
END AS level,
1 AS is_read,
a.id AS related_id,
'account' AS related_type,
COALESCE(a.updated_at, a.created_at) AS created_at
FROM accounts a
LEFT JOIN sites s ON s.id = a.site_id
WHERE COALESCE(a.updated_at, a.created_at) IS NOT NULL
AND trim(COALESCE(a.updated_at, a.created_at)) != ''
ORDER BY COALESCE(a.updated_at, a.created_at) DESC
LIMIT ?
"#,
)
.bind(per_source_limit)
.fetch_all(&*state.db)
.await?,
);
Ok(rows)
}
/// GET /api/events/count — unread count
@@ -98,5 +427,334 @@ 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")));
}
}
+131 -32
View File
@@ -10,9 +10,14 @@ 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;
@@ -22,7 +27,6 @@ mod comfyui_agent;
mod digital_human;
mod events;
mod health;
mod monitor;
mod money_printer;
mod oauth;
mod organizations;
@@ -32,7 +36,6 @@ 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"]
@@ -58,9 +61,8 @@ pub fn build_router(state: AppState) -> Router {
let api_routes = api_routes
.merge(auth::routes())
.merge(events::routes())
.merge(events::routes(state.clone()))
.merge(checkin::routes())
.merge(monitor::routes())
.merge(oauth::routes())
.merge(organizations::routes())
.merge(search::routes())
@@ -68,7 +70,6 @@ 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())
@@ -76,40 +77,42 @@ pub fn build_router(state: AppState) -> Router {
.with_state(state);
let router = Router::new().merge(api_routes);
match std::env::var("BOOSAPI_WEB_DIR")
let router = match std::env::var("BOOSAPI_WEB_DIR")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
Some(web_dir) => {
let web_dir_owned = web_dir.clone();
let website_dir = std::env::var("BOOSAPI_WEBSITE_DIR")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "dist/website".to_string());
let static_service = ServeDir::new(&web_dir)
.append_index_html_on_directories(true)
.fallback(service_fn(move |req: Request<Body>| {
let web_dir = web_dir_owned.clone();
let web_dir = web_dir.clone();
let website_dir = website_dir.clone();
async move {
let path = req.uri().path();
if is_api_path(path) {
return Ok::<_, Infallible>(api_not_found_response(path));
}
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()),
),
}
Ok::<_, Infallible>(filesystem_static_response(
path,
&web_dir,
&website_dir,
)
.await)
}
}));
router.fallback_service(static_service)
}
None => router.fallback(embedded_static_handler),
}
};
router.layer(CompressionLayer::new())
}
fn api_not_found_response(path: &str) -> Response {
@@ -122,6 +125,49 @@ 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) {
@@ -129,27 +175,64 @@ async fn embedded_static_handler(req: Request<Body>) -> Response {
}
let asset_path = embedded_asset_path(path);
if let Some(file) = EMBEDDED_WEB.get_file(&asset_path) {
let static_dir = if should_serve_website(path) {
&EMBEDDED_WEBSITE
} else {
&EMBEDDED_WEB
};
if let Some(file) = static_dir.get_file(&asset_path) {
return embedded_file_response(&asset_path, file.contents());
}
if let Some(file) = EMBEDDED_WEB.get_file("index.html") {
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 dist/web/index.html not found"))
.body(Body::from(
"embedded frontend index.html not found; rebuild with ./build-and-run.sh --production or set BOOSAPI_WEB_DIR/BOOSAPI_WEBSITE_DIR",
))
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
}
fn is_api_path(path: &str) -> bool {
path.starts_with("/api/") || path.starts_with("/v1/")
path.starts_with("/api/")
|| path.starts_with("/v1/")
|| path == "/health"
|| path == "/ready"
|| path == "/metrics"
}
fn should_serve_website(path: &str) -> bool {
path == "/"
|| path == "/new"
|| path.starts_with("/new/")
|| path.starts_with("/_next/")
|| path == "/favicon.ico"
|| path == "/favicon.png"
|| path == "/favicon-64.png"
|| path == "/logo.png"
}
fn embedded_asset_path(path: &str) -> String {
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 {
@@ -157,14 +240,6 @@ fn embedded_asset_path(path: &str) -> String {
}
}
fn html_response(html: String) -> Response {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(html))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
fn embedded_file_response(path: &str, bytes: &[u8]) -> Response {
let content_type = mime_guess::from_path(path)
.first_or_octet_stream()
@@ -197,3 +272,27 @@ async fn removed_comfy_workbench() -> (StatusCode, Json<Value>) {
})),
)
}
#[cfg(test)]
mod tests {
use super::{embedded_asset_path, should_serve_website};
#[test]
fn experimental_website_route_is_served_from_website_export() {
assert!(should_serve_website("/new"));
assert!(should_serve_website("/new/"));
assert!(should_serve_website("/new/index.html"));
assert_eq!(embedded_asset_path("/new"), "new.html");
assert_eq!(embedded_asset_path("/new/"), "new.html");
assert_eq!(embedded_asset_path("/new/index.html"), "new.html");
}
#[test]
fn application_routes_still_use_web_spa() {
assert!(!should_serve_website("/boos"));
assert!(!should_serve_website("/dashboard"));
assert!(!should_serve_website("/user/recharge"));
assert!(!should_serve_website("/logs"));
assert!(!should_serve_website("/events"));
}
}
File diff suppressed because it is too large Load Diff
-297
View File
@@ -1,297 +0,0 @@
// Monitor routes — LDOH incident dashboard proxy with cookie auth
use crate::{
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, State},
http::{header, HeaderMap},
response::Response,
routing::{get, post},
Json, Router,
};
use serde_json::{json, Value};
const MONITOR_AUTH_COOKIE: &str = "meta_monitor_auth";
const LDOH_BASE_URL: &str = "https://ldoh.105117.xyz";
const LDOH_COOKIE_SETTING_KEY: &str = "monitor_ldoh_cookie";
pub fn routes() -> Router<AppState> {
Router::new()
.route(
"/api/monitor/config",
get(get_monitor_config).put(update_monitor_config),
)
.route("/api/monitor/session", post(create_session))
.route("/monitor-proxy/ldoh", get(ldoh_proxy_handler))
.route("/monitor-proxy/ldoh/", get(ldoh_proxy_handler))
.route("/monitor-proxy/ldoh/{*path}", get(ldoh_proxy_handler))
}
/// GET /api/monitor/config — returns whether LDOH cookie is configured
async fn get_monitor_config(State(state): State<AppState>) -> AppResult<Json<Value>> {
let cookie: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
.bind(LDOH_COOKIE_SETTING_KEY)
.fetch_optional(&*state.db)
.await?;
let raw = cookie
.and_then(|(v,)| {
serde_json::from_str::<serde_json::Value>(&v)
.ok()
.and_then(|parsed| parsed.as_str().map(String::from))
})
.unwrap_or_default();
let configured = !raw.is_empty();
let masked = if configured {
mask_cookie_value(&raw)
} else {
String::new()
};
Ok(Json(json!({
"ldohCookieConfigured": configured,
"ldohCookieMasked": masked,
})))
}
/// PUT /api/monitor/config — save LDOH cookie
async fn update_monitor_config(
State(state): State<AppState>,
Json(body): Json<serde_json::Value>,
) -> AppResult<Json<Value>> {
let cookie = body
.get("ldohCookie")
.and_then(|v| v.as_str())
.unwrap_or("");
if cookie.is_empty() {
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, '')")
.bind(LDOH_COOKIE_SETTING_KEY)
.execute(&*state.db)
.await?;
return Ok(Json(json!({
"success": true,
"message": "LDOH Cookie 已清空",
"ldohCookieConfigured": false,
})));
}
let normalized = normalize_ldoh_cookie(cookie);
if !normalized.starts_with("ld_auth_session=") || normalized.len() < 24 {
return Err(AppError::BadRequest("Cookie 格式无效".into()));
}
let escaped = serde_json::to_string(&normalized).unwrap_or_default();
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.bind(LDOH_COOKIE_SETTING_KEY)
.bind(&escaped)
.execute(&*state.db)
.await?;
Ok(Json(json!({
"success": true,
"message": "LDOH Cookie 已保存",
"ldohCookieConfigured": true,
"ldohCookieMasked": mask_cookie_value(&normalized),
})))
}
/// POST /api/monitor/session — returns cookie value for client to set
async fn create_session(State(state): State<AppState>) -> Json<Value> {
let token = &state.config.auth.auth_token;
Json(json!({
"success": true,
"_cookie": format!("{}={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=7200", MONITOR_AUTH_COOKIE, token),
}))
}
/// GET /monitor-proxy/ldoh/{*path} — reverse proxy to ldoh.105117.xyz
async fn ldoh_proxy_handler(
State(state): State<AppState>,
headers: HeaderMap,
Path(path): Path<String>,
) -> Result<Response, AppError> {
// Auth check via cookie
let cookies = parse_cookies(
headers
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or(""),
);
if cookies.get(MONITOR_AUTH_COOKIE).map(|s| s.as_str()) != Some(&state.config.auth.auth_token) {
return Err(AppError::Unauthorized(
"Missing or invalid monitor session".into(),
));
}
// Get stored LDOH cookie
let stored: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
.bind(LDOH_COOKIE_SETTING_KEY)
.fetch_optional(&*state.db)
.await?;
let ldoh_cookie = stored
.and_then(|(v,)| {
serde_json::from_str::<serde_json::Value>(&v)
.ok()
.and_then(|parsed| parsed.as_str().map(String::from))
})
.unwrap_or_default();
if ldoh_cookie.is_empty() {
return Err(AppError::BadRequest("LDOH cookie not configured".into()));
}
// Build upstream URL
let clean_path = if path.is_empty() || path == "/" {
String::new()
} else {
format!("/{}", path)
};
let target_url = format!("{}{}", LDOH_BASE_URL, clean_path);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to build client: {e}")))?;
let mut req = client
.get(&target_url)
.header("cookie", &ldoh_cookie)
.header(
"accept",
headers
.get("accept")
.and_then(|v| v.to_str().ok())
.unwrap_or("*/*"),
)
.header(
"accept-language",
headers
.get("accept-language")
.and_then(|v| v.to_str().ok())
.unwrap_or("zh-CN,zh;q=0.9,en;q=0.8"),
)
.header(
"user-agent",
headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.unwrap_or("boosapiMonitorProxy/1.0"),
);
if let Some(referer) = headers.get("referer").and_then(|v| v.to_str().ok()) {
req = req.header("referer", referer.replace("/monitor-proxy/ldoh", ""));
}
let resp = req
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("LDOH unreachable: {e}")))?;
let status = resp.status();
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let mut builder = axum::response::Response::builder().status(status);
if let Some(ctl) = resp
.headers()
.get("cache-control")
.and_then(|v| v.to_str().ok())
{
builder = builder.header("cache-control", ctl);
}
// Rewrite text content
if content_type.contains("text/html")
|| content_type.contains("javascript")
|| content_type.contains("text/css")
|| content_type.contains("application/json")
{
let text = resp
.text()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
return builder
.header(header::CONTENT_TYPE, &content_type)
.body(axum::body::Body::from(rewrite_proxy_text(&text)))
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")));
}
let bytes = resp
.bytes()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
builder
.header(header::CONTENT_TYPE, &content_type)
.body(axum::body::Body::from(bytes))
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))
}
// ── Helpers ────────────────────────────────────────────────────────────
fn parse_cookies(raw: &str) -> std::collections::HashMap<String, String> {
let mut map = std::collections::HashMap::new();
for part in raw.split(';') {
let part = part.trim();
if let Some(idx) = part.find('=') {
let key = part[..idx].trim().to_string();
let val = part[idx + 1..].trim().to_string();
if !key.is_empty() {
map.insert(key, val);
}
}
}
map
}
fn mask_cookie_value(cookie_text: &str) -> String {
let raw = cookie_text.trim();
if raw.is_empty() {
return String::new();
}
let value = raw.split('=').nth(1).unwrap_or(raw);
if value.len() <= 10 {
format!("{}****", &value[..2.min(value.len())])
} else {
format!("{}****{}", &value[..6], &value[value.len() - 4..])
}
}
fn normalize_ldoh_cookie(raw: &str) -> String {
let trimmed = raw.trim().to_string();
if trimmed.contains("ld_auth_session=") {
if let Some(first) = trimmed.split(';').next() {
if first.trim().starts_with("ld_auth_session=") {
return first.trim().to_string();
}
}
}
format!("ld_auth_session={}", trimmed)
}
fn rewrite_proxy_text(text: &str) -> String {
text.replace("https://ldoh.105117.xyz/", "/monitor-proxy/ldoh/")
.replace(
"https:\\/\\/ldoh.105117.xyz\\/",
"\\/monitor-proxy\\/ldoh\\/",
)
.replace("src=\"/", "src=\"/monitor-proxy/ldoh/")
.replace("src='/", "src='/monitor-proxy/ldoh/")
.replace("href=\"/", "href=\"/monitor-proxy/ldoh/")
.replace("href='/", "href='/monitor-proxy/ldoh/")
.replace("action=\"/", "action=\"/monitor-proxy/ldoh/")
.replace("action='/", "action='/monitor-proxy/ldoh/")
.replace("\"/_next/", "\"/monitor-proxy/ldoh/_next/")
.replace("'/_next/", "'/monitor-proxy/ldoh/_next/")
.replace("\"\\\\/api/", "\"\\\\/monitor-proxy\\\\/ldoh\\\\/api/")
.replace("'/api/", "'/monitor-proxy/ldoh/api/")
.replace("\"/api/", "\"/monitor-proxy/ldoh/api/")
}
+4206 -117
View File
File diff suppressed because it is too large Load Diff
+155
View File
@@ -0,0 +1,155 @@
use crate::{
auth::jwt::validate_jwt,
error::{AppError, AppResult},
services::payment,
AppState,
};
use axum::{
body::Bytes,
extract::{Path, State},
http::{HeaderMap, StatusCode},
routing::{get, post},
Json, Router,
};
use serde_json::{json, Value};
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/payment/config", get(get_payment_config))
.route("/api/user/payment/orders/{public_id}", get(get_payment_order))
.route(
"/api/v1/payment/webhook/stripe",
post(stripe_webhook),
)
}
fn current_user_id(headers: &HeaderMap, state: &AppState) -> AppResult<i64> {
let auth = headers
.get("Authorization")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
claims
.sub
.parse()
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))
}
async fn get_payment_config(State(state): State<AppState>) -> AppResult<Json<Value>> {
Ok(Json(json!({
"success": true,
"payment": payment::public_config(&state.config.payment),
"webhooks": {
"stripe": "/api/v1/payment/webhook/stripe",
},
})))
}
async fn get_payment_order(
State(state): State<AppState>,
headers: HeaderMap,
Path(public_id): Path<String>,
) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let order = payment::fetch_payment_order_by_public_id(&state.db, user_id, public_id.trim()).await?;
Ok(Json(json!({
"success": true,
"order": order,
})))
}
async fn stripe_webhook(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> AppResult<(StatusCode, Json<Value>)> {
let webhook_secret = state
.config
.payment
.stripe_webhook_secret
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| AppError::ServiceUnavailable("Stripe webhook secret is not configured".into()))?;
let signature = headers
.get("stripe-signature")
.or_else(|| headers.get("Stripe-Signature"))
.and_then(|value| value.to_str().ok())
.ok_or_else(|| AppError::Unauthorized("Missing Stripe signature".into()))?;
if !payment::verify_stripe_signature(&body, signature, webhook_secret, 300) {
return Err(AppError::Unauthorized("Invalid Stripe signature".into()));
}
let event: Value = serde_json::from_slice(&body)
.map_err(|_| AppError::BadRequest("Invalid Stripe webhook JSON".into()))?;
let Some((event_type, session_id, payment_intent)) = payment::stripe_event_checkout_session(&event) else {
return Ok((StatusCode::OK, Json(json!({ "received": true, "ignored": true }))));
};
match event_type.as_str() {
"checkout.session.completed" => {
let completion = payment::complete_stripe_order_by_session(
&state.db,
&session_id,
payment_intent.as_deref(),
)
.await?;
Ok((
StatusCode::OK,
Json(json!({
"received": true,
"completed": true,
"order": completion.order,
"rechargeRecord": completion.recharge_record,
"creditedKeyId": completion.credited_key_id,
})),
))
}
"checkout.session.expired" => {
let order = payment::mark_stripe_order_failed_by_session(&state.db, &session_id).await?;
Ok((
StatusCode::OK,
Json(json!({
"received": true,
"failed": order.is_some(),
"order": order,
})),
))
}
_ => Ok((StatusCode::OK, Json(json!({ "received": true, "ignored": true })))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn stripe_signature_verification_matches_route_header_shape() {
let payload = br#"{"type":"checkout.session.completed"}"#;
let secret = "whsec_route_test";
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(format!("{timestamp}.{}", String::from_utf8_lossy(payload)).as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
let header = HeaderValue::from_str(&format!("t={timestamp},v1={signature}")).unwrap();
assert!(payment::verify_stripe_signature(
payload,
header.to_str().unwrap(),
secret,
300,
));
}
}
+21 -173
View File
@@ -14,17 +14,15 @@ use axum::{
routing::{get, post},
};
use reqwest::Client;
use reqwest::Url;
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 TEMPLATE_GALLERY_FALLBACK_DATA_URL: &str =
"https://weiyyin.github.io/linux-do-gpt-image/linux_do_2036927_prompts_images.with_tags.json";
const LINUX_DO_TEMPLATE_JSON: &str =
include_str!("../../assets/picture-agent/linux_do_2036927_prompts_images.with_tags.json");
const TASK_TYPE_PICTURE_GENERATION: &str = "picture-agent-generation";
const 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;
@@ -34,6 +32,7 @@ struct PictureAgentAuth {
user_id: Option<i64>,
role: String,
outbound_key: String,
key_id: Option<i64>,
key_name: String,
}
@@ -125,11 +124,13 @@ async fn resolve_picture_agent_auth(
let token = extract_bearer_token(headers)
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
let proxy_token = state.runtime_proxy_token();
if token == state.config.auth.auth_token || token == proxy_token {
return Ok(PictureAgentAuth {
user_id: None,
role: "admin".to_string(),
outbound_key: state.config.auth.proxy_token.clone(),
outbound_key: proxy_token,
key_id: None,
key_name: "global-proxy".to_string(),
});
}
@@ -150,12 +151,14 @@ 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.config.auth.proxy_token.clone(),
outbound_key: state.runtime_proxy_token(),
key_id: None,
key_name: "global-proxy".to_string(),
}),
Err(message) => Err(AppError::Forbidden(message)),
@@ -289,22 +292,6 @@ mod tests {
assert!(!is_picture_model_pattern("qwen-plus"));
}
#[test]
fn extracts_gallery_data_url_from_page_script() {
let html = r#"
<script>
const DATA_URL = "./linux_do_2036927_prompts_images.with_tags.json";
</script>
"#;
assert_eq!(
extract_gallery_data_url(html).as_deref(),
Some("./linux_do_2036927_prompts_images.with_tags.json")
);
assert_eq!(
absolute_gallery_url("./linux_do_2036927_prompts_images.with_tags.json").unwrap(),
"https://weiyyin.github.io/linux-do-gpt-image/linux_do_2036927_prompts_images.with_tags.json"
);
}
}
fn prompt_title(prompt: &str, post_number: Option<i64>) -> String {
@@ -364,52 +351,28 @@ 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 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 })
}
let (templates, local_status) = match parse_gallery_templates(LINUX_DO_TEMPLATE_JSON, limit) {
Ok(templates) => (templates, json!({ "ok": true, "source": "local-json" })),
Err(err) => {
tracing::warn!(%err, "Picture Agent template gallery unavailable; using built-in templates");
json!({ "ok": false, "message": err })
tracing::warn!(%err, "Picture Agent local Linux Do template gallery unavailable");
(Vec::new(), json!({ "ok": false, "message": err }))
}
};
templates.truncate(limit);
Ok(Json(json!({
"success": true,
"sourceUrl": TEMPLATE_GALLERY_PAGE_URL,
"fallback": remote_status.get("ok").and_then(Value::as_bool) != Some(true),
"remote": remote_status,
"source": local_status,
"templates": templates,
})))
}
async fn fetch_remote_gallery_templates(
config: &crate::config::AppConfig,
limit: usize,
) -> Result<Vec<Value>, String> {
fn parse_gallery_templates(raw: &str, limit: usize) -> Result<Vec<Value>, String> {
if limit == 0 {
return Ok(Vec::new());
}
let client = crate::template_http::client(config, TEMPLATE_GALLERY_TIMEOUT_SECS);
let data_url = resolve_gallery_data_url(&client).await?;
let items = client
.get(&data_url)
.send()
.await
.map_err(|err| format!("failed to fetch templates from {data_url}: {err}"))?
.error_for_status()
.map_err(|err| format!("template gallery data returned {err}"))?
.json::<Vec<GalleryItem>>()
.await
.map_err(|err| format!("failed to parse templates: {err}"))?;
let items = serde_json::from_str::<Vec<GalleryItem>>(raw)
.map_err(|err| format!("failed to parse local Linux Do templates: {err}"))?;
Ok(items
.into_iter()
@@ -439,7 +402,7 @@ async fn fetch_remote_gallery_templates(
"prompt": prompt,
"source": {
"name": "Linux Do GPT Image Gallery",
"url": "https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html",
"url": TEMPLATE_GALLERY_PAGE_URL,
"postNumber": post_number,
"username": item.username,
},
@@ -449,123 +412,6 @@ async fn fetch_remote_gallery_templates(
.collect())
}
async fn resolve_gallery_data_url(client: &Client) -> Result<String, String> {
let html = client
.get(TEMPLATE_GALLERY_PAGE_URL)
.send()
.await
.map_err(|err| format!("failed to fetch template gallery page: {err}"))?
.error_for_status()
.map_err(|err| format!("template gallery page returned {err}"))?
.text()
.await
.map_err(|err| format!("failed to read template gallery page: {err}"))?;
let data_url = extract_gallery_data_url(&html)
.ok_or_else(|| "template gallery page did not expose DATA_URL".to_string())?;
absolute_gallery_url(&data_url)
}
fn extract_gallery_data_url(html: &str) -> Option<String> {
let regex = regex::Regex::new(r#"(?m)\bDATA_URL\s*=\s*["']([^"']+)["']"#).ok()?;
regex
.captures(html)
.and_then(|captures| captures.get(1))
.map(|match_| match_.as_str().trim().to_string())
.filter(|value| !value.is_empty())
}
fn absolute_gallery_url(value: &str) -> Result<String, String> {
if value.starts_with("http://") || value.starts_with("https://") {
return Ok(value.to_string());
}
let base = Url::parse(TEMPLATE_GALLERY_PAGE_URL)
.map_err(|err| format!("invalid template gallery page URL: {err}"))?;
base.join(value)
.map(|url| url.to_string())
.or_else(|_| Url::parse(TEMPLATE_GALLERY_FALLBACK_DATA_URL).map(|url| url.to_string()))
.map_err(|err| format!("invalid template gallery data URL: {err}"))
}
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)
}
@@ -814,6 +660,7 @@ 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,
@@ -895,6 +742,7 @@ 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,
+792 -362
View File
File diff suppressed because it is too large Load Diff
+720 -76
View File
@@ -8,8 +8,8 @@ use axum::{
};
use serde::Deserialize;
use serde_json::{json, Value};
use sqlx::Row;
use std::collections::{BTreeMap, HashMap};
use sqlx::{QueryBuilder, Row, Sqlite};
use std::collections::{BTreeMap, HashMap, HashSet};
use tracing::info;
#[derive(Debug, Deserialize)]
@@ -25,6 +25,333 @@ 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))
@@ -45,6 +372,45 @@ 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?;
@@ -125,10 +491,8 @@ async fn dashboard(
)
.fetch_one(&*state.db)
.await?;
let site_availability = build_site_availability(&state).await?;
let model_analysis = build_model_analysis(&state, 7).await?;
let payload = json!({
Ok(json!({
"generatedAt": chrono::Utc::now().to_rfc3339(),
"totalBalance": total_balance,
"totalUsed": total_used,
@@ -150,8 +514,6 @@ async fn dashboard(
"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,
@@ -169,31 +531,7 @@ async fn dashboard(
"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> {
@@ -397,47 +735,77 @@ 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> = sqlx::query_as::<_, ProxyLogRow>(
let items: Vec<Value> = if meta_only {
Vec::new()
} else {
let mut list_query = QueryBuilder::<Sqlite>::new(
r#"
SELECT
l.id, l.route_id, l.channel_id, l.account_id, l.downstream_api_key_id,
l.model_requested, l.model_actual, l.status, l.http_status,
l.latency_ms, l.first_byte_latency_ms,
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,
s.name AS site_name,
s.url AS site_url,
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#"
ORDER BY l.created_at DESC
"#,
);
list_query.push(" LIMIT ");
list_query.push_bind(limit);
list_query.push(" OFFSET ");
list_query.push_bind(offset);
list_query
.build_query_as::<ProxyLogRow>()
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|row| serde_json::to_value(row).unwrap_or_else(|_| json!({})))
.collect()
};
let mut summary_query = QueryBuilder::<Sqlite>::new(
r#"
SELECT
l.id, l.route_id, l.channel_id, l.account_id, l.downstream_api_key_id,
l.model_requested, l.model_actual, l.status, l.http_status,
l.latency_ms, l.first_byte_latency_ms,
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.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,
s.name AS site_name,
s.url AS site_url,
dk.name AS downstream_key_name,
dk.group_name AS downstream_key_group_name,
dk.tags AS downstream_key_tags
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 ?
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
"#,
)
.bind(limit)
.bind(offset)
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|row| serde_json::to_value(row).unwrap_or_else(|_| json!({})))
.collect();
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?;
);
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())
};
Ok(Json(json!({
"items": items,
@@ -448,10 +816,12 @@ async fn proxy_logs(
"summary": {
"total": total,
"success": success,
"failed": total - success,
"failed": failed,
"cost": cost,
"totalTokens": total_tokens,
},
"clientOptions": [],
"sites": [],
"clientOptions": client_options,
"sites": sites,
})))
}
@@ -486,6 +856,10 @@ 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>,
}
@@ -502,6 +876,7 @@ 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,
@@ -523,14 +898,215 @@ async fn proxy_log_detail(
Ok(Json(json!({ "item": item, "data": item })))
}
async fn proxy_debug_traces() -> Json<Value> {
Json(json!({ "items": [], "data": [], "total": 0 }))
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_trace_detail(
State(state): State<AppState>,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Json<Value> {
Json(json!({ "id": id, "events": [], "data": null }))
) -> 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>,
}
/// GET /api/stats/model-by-site?siteId=...&days=...
@@ -763,3 +1339,71 @@ 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(&params);
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(&params);
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(&params);
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(&params);
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(&params);
assert!(filters.status.is_none());
assert!(filters.downstream_key_id.is_none());
assert!(filters.site_id.is_none());
assert!(filters.search_like.is_none());
}
}
-121
View File
@@ -1,18 +1,11 @@
// 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;
@@ -26,10 +19,6 @@ 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
@@ -67,113 +56,3 @@ async fn get_task(State(state): State<AppState>, Path(id): Path<String>) -> AppR
)),
}
}
/// GET /api/update-center/tasks/{id}/stream — SSE stream of task progress
async fn stream_task_logs(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, axum::response::Response> {
info!("Streaming task logs for: {}", id);
// Check task exists
let task: Option<BackgroundTask> =
sqlx::query_as::<_, BackgroundTask>("SELECT * FROM background_tasks WHERE id = ?")
.bind(&id)
.fetch_optional(&*state.db)
.await
.map_err(|e| {
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!("DB error: {e}"),
)
.into_response()
})?;
let task = match task {
Some(t) => t,
None => {
return Err((
axum::http::StatusCode::NOT_FOUND,
json!({ "success": false, "message": "task not found" }).to_string(),
)
.into_response());
}
};
// Stream task state with polling
let initial_status = task.status.clone();
let is_done = initial_status != "pending" && initial_status != "running";
let state_clone = state.clone();
let stream = async_stream::stream! {
// Yield current task status
yield Ok::<_, Infallible>(Event::default()
.event("status")
.data(json!({ "status": initial_status }).to_string()));
yield Ok::<_, Infallible>(Event::default()
.event("log")
.data(json!({
"seq": 1,
"message": format!("Task {}: {}", initial_status, task.message.as_deref().unwrap_or("")),
"createdAt": task.created_at.as_deref().unwrap_or(""),
}).to_string()));
if is_done {
yield Ok::<_, Infallible>(Event::default()
.event("done")
.data(json!({ "status": initial_status }).to_string()));
return;
}
// Poll for updates
let poll_id = id.clone();
for i in 0..120 { // Poll up to 120 times (2 minutes)
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
let current: Option<BackgroundTask> = sqlx::query_as(
"SELECT * FROM background_tasks WHERE id = ?"
)
.bind(&poll_id)
.fetch_optional(&*state_clone.db)
.await
.ok()
.flatten();
match current {
Some(t) => {
let status = t.status;
let updated_at = t.updated_at.unwrap_or_default();
yield Ok::<_, Infallible>(Event::default()
.event("log")
.data(json!({
"seq": i + 2,
"message": format!("Status: {} — {}", status, t.message.as_deref().unwrap_or("")),
"createdAt": updated_at,
}).to_string()));
if status != "pending" && status != "running" {
yield Ok::<_, Infallible>(Event::default()
.event("done")
.data(json!({ "status": status }).to_string()));
return;
}
}
None => {
yield Ok::<_, Infallible>(Event::default()
.event("done")
.data(json!({ "status": "unknown", "message": "task removed" }).to_string()));
return;
}
}
}
// Timeout
yield Ok::<_, Infallible>(Event::default()
.event("done")
.data(json!({ "status": "timeout" }).to_string()));
};
Ok(Sse::new(stream))
}
-435
View File
@@ -1,435 +0,0 @@
// Update Center routes — deploy/rollback orchestration via helper API
#![allow(clippy::type_complexity)]
use crate::{
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::State,
routing::{get, post, put},
Json, Router,
};
use serde_json::{json, Value};
const UC_CONFIG_KEY: &str = "update_center_config";
const TASK_TYPE_DEPLOY: &str = "update-center-deploy";
const TASK_TYPE_ROLLBACK: &str = "update-center-rollback";
const DEDUPE_KEY_DEPLOY: &str = "update-center:deploy";
const DEDUPE_KEY_ROLLBACK: &str = "update-center:rollback";
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/update-center/status", get(get_status))
.route("/api/update-center/check", post(check_status))
.route("/api/update-center/config", put(update_config))
.route("/api/update-center/deploy", post(deploy_handler))
.route("/api/update-center/rollback", post(rollback_handler))
}
/// GET /api/update-center/status
async fn get_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
let config = load_uc_config(&state).await?;
let helper_token = get_helper_token();
let last_deploy = load_last_task(&state, TASK_TYPE_DEPLOY).await?;
let last_rollback = load_last_task(&state, TASK_TYPE_ROLLBACK).await?;
Ok(Json(json!({
"enabled": config.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false),
"helperBaseUrl": config.get("helperBaseUrl"),
"namespace": config.get("namespace"),
"releaseName": config.get("releaseName"),
"helperTokenConfigured": !helper_token.is_empty(),
"lastDeploy": last_deploy,
"lastRollback": last_rollback,
})))
}
/// POST /api/update-center/check — refresh status cache
async fn check_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
// Same as get_status for now
get_status(State(state)).await
}
/// PUT /api/update-center/config
async fn update_config(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let serialized = serde_json::to_string(&body)
.map_err(|e| AppError::BadRequest(format!("Invalid config JSON: {e}")))?;
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.bind(UC_CONFIG_KEY)
.bind(&serialized)
.execute(&*state.db)
.await?;
Ok(Json(json!({
"success": true,
"config": body,
})))
}
/// POST /api/update-center/deploy
async fn deploy_handler(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let config = load_uc_config(&state).await?;
let helper_token = get_helper_token();
let helper_base = config
.get("helperBaseUrl")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let namespace = config
.get("namespace")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let chart_ref = config
.get("chartRef")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let image_repo = config
.get("imageRepository")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if helper_base.is_empty()
|| namespace.is_empty()
|| chart_ref.is_empty()
|| helper_token.is_empty()
{
return Err(AppError::BadRequest(
"Update center not fully configured".into(),
));
}
let target_tag = body
.get("targetTag")
.or_else(|| body.get("targetVersion"))
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or("");
if target_tag.is_empty() {
return Err(AppError::BadRequest("targetTag is required".into()));
}
// Extract owned values for the spawn
let helper_base_owned = helper_base.trim_end_matches('/').to_string();
let namespace_owned = namespace.to_string();
let chart_ref_owned = chart_ref.to_string();
let image_repo_owned = image_repo.to_string();
let release_name = config
.get("releaseName")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned);
let target_tag_owned = target_tag.to_string();
let helper_token_owned = helper_token;
let title = format!("部署 {}", target_tag);
let task_id = uuid::Uuid::new_v4().to_string();
// Create background task
sqlx::query(
"INSERT INTO background_tasks (id, task_type, title, status, message, dedupe_key)
VALUES (?, ?, ?, 'running', ?, ?)",
)
.bind(&task_id)
.bind(TASK_TYPE_DEPLOY)
.bind(&title)
.bind(format!(
"Starting deploy of {} to {}/{}",
target_tag, namespace, chart_ref
))
.bind(DEDUPE_KEY_DEPLOY)
.execute(&*state.db)
.await?;
// Call deploy helper in background (fire-and-forget via tokio)
let client = reqwest::Client::new();
let deploy_url = format!("{}/api/deploy", helper_base_owned);
let task_id_clone = task_id.clone();
let db_pool = state.db.clone();
tokio::spawn(async move {
let payload = json!({
"namespace": namespace_owned,
"releaseName": release_name,
"chartRef": chart_ref_owned,
"imageRepository": image_repo_owned,
"targetTag": target_tag_owned,
});
match client
.post(&deploy_url)
.header("Authorization", format!("Bearer {}", helper_token_owned))
.json(&payload)
.send()
.await
{
Ok(resp) => {
let status = resp.status();
let status_code = status.as_u16();
let body_text = resp.text().await.unwrap_or_default();
if status.is_success() {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'succeeded', message = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!("Deploy completed (HTTP {})", status_code))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
} else {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', error = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!(
"Helper returned HTTP {}: {}",
status_code, body_text
))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
}
}
Err(e) => {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', error = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!("Helper unreachable: {}", e))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
}
}
});
Ok(Json(json!({
"success": true,
"reused": false,
"task": {
"id": task_id,
"type": TASK_TYPE_DEPLOY,
"title": title,
"status": "running",
},
})))
}
/// POST /api/update-center/rollback
async fn rollback_handler(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let config = load_uc_config(&state).await?;
let helper_token = get_helper_token();
let helper_base = config
.get("helperBaseUrl")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let namespace = config
.get("namespace")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if helper_base.is_empty() || namespace.is_empty() || helper_token.is_empty() {
return Err(AppError::BadRequest(
"Update center not fully configured".into(),
));
}
let target_revision = body
.get("targetRevision")
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or("");
if target_revision.is_empty() {
return Err(AppError::BadRequest("targetRevision is required".into()));
}
let helper_base_owned = helper_base.trim_end_matches('/').to_string();
let namespace_owned = namespace.to_string();
let release_name = config
.get("releaseName")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned);
let target_revision_owned = target_revision.to_string();
let helper_token_owned = helper_token;
let title = format!("回退 {}", target_revision);
let task_id = uuid::Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO background_tasks (id, task_type, title, status, message, dedupe_key)
VALUES (?, ?, ?, 'running', ?, ?)",
)
.bind(&task_id)
.bind(TASK_TYPE_ROLLBACK)
.bind(&title)
.bind(format!(
"Starting rollback to {} in {}",
target_revision, namespace
))
.bind(DEDUPE_KEY_ROLLBACK)
.execute(&*state.db)
.await?;
let client = reqwest::Client::new();
let rollback_url = format!("{}/api/rollback", helper_base_owned);
let task_id_clone = task_id.clone();
let db_pool = state.db.clone();
tokio::spawn(async move {
let payload = json!({
"namespace": namespace_owned,
"releaseName": release_name,
"targetRevision": target_revision_owned,
});
match client
.post(&rollback_url)
.header("Authorization", format!("Bearer {}", helper_token_owned))
.json(&payload)
.send()
.await
{
Ok(resp) => {
let status = resp.status();
let status_code = status.as_u16();
let body_text = resp.text().await.unwrap_or_default();
if status.is_success() {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'succeeded', message = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!("Rollback completed (HTTP {})", status_code))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
} else {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', error = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!(
"Helper returned HTTP {}: {}",
status_code, body_text
))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
}
}
Err(e) => {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', error = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!("Helper unreachable: {}", e))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
}
}
});
Ok(Json(json!({
"success": true,
"reused": false,
"task": {
"id": task_id,
"type": TASK_TYPE_ROLLBACK,
"title": title,
"status": "running",
},
})))
}
// ── Helpers ────────────────────────────────────────────────────────────
fn get_helper_token() -> String {
std::env::var("UPDATE_CENTER_HELPER_TOKEN")
.or_else(|_| std::env::var("DEPLOY_HELPER_TOKEN"))
.unwrap_or_default()
.trim()
.to_string()
}
async fn load_uc_config(state: &AppState) -> AppResult<Value> {
let row: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
.bind(UC_CONFIG_KEY)
.fetch_optional(&*state.db)
.await?;
match row {
Some((v,)) => serde_json::from_str(&v)
.map_err(|e| AppError::BadRequest(format!("Invalid update center config JSON: {e}"))),
None => Ok(json!({})),
}
}
async fn load_last_task(state: &AppState, task_type: &str) -> AppResult<Option<Value>> {
let row: Option<(
String,
String,
Option<String>,
Option<String>,
String,
Option<String>,
Option<String>,
)> = sqlx::query_as(
"SELECT id, status, message, error, created_at, started_at, finished_at
FROM background_tasks
WHERE task_type = ?
ORDER BY created_at DESC
LIMIT 1",
)
.bind(task_type)
.fetch_optional(&*state.db)
.await?;
Ok(row.map(
|(id, status, message, error, created_at, started_at, finished_at)| {
json!({
"id": id,
"type": task_type,
"status": status,
"message": message,
"error": error,
"createdAt": created_at,
"startedAt": started_at,
"finishedAt": finished_at,
})
},
))
}
+1679 -98
View File
File diff suppressed because it is too large Load Diff
+121 -9
View File
@@ -15,6 +15,7 @@ use std::convert::Infallible;
use tracing::info;
use crate::{
auth::jwt::validate_jwt,
error::{AppError, AppResult},
AppState,
};
@@ -200,15 +201,28 @@ async fn external_comfyui_status_value(state: &AppState) -> Value {
}
fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
let expected = format!("Bearer {}", state.config.auth.auth_token);
let actual = headers
let token = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
.and_then(|value| {
value
.strip_prefix("Bearer ")
.or_else(|| value.strip_prefix("bearer "))
})
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".to_string()))?;
if actual != expected {
return Err(AppError::BadRequest("unauthorized".to_string()));
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(),
));
}
Ok(())
@@ -273,7 +287,7 @@ async fn call_llm(state: &AppState, prompt: &str, system: &str) -> AppResult<Str
let resp = client
.post(url)
.bearer_auth(&state.config.auth.proxy_token)
.bearer_auth(state.runtime_proxy_token())
.json(&json!({
"model": &state.config.comfyui.agent_model,
"messages": [
@@ -319,7 +333,7 @@ async fn call_image_gen(state: &AppState, prompt: &str) -> AppResult<String> {
let resp = client
.post(url)
.bearer_auth(&state.config.auth.proxy_token)
.bearer_auth(state.runtime_proxy_token())
.json(&json!({
"model": "gpt-image-2",
"prompt": prompt,
@@ -362,7 +376,7 @@ async fn call_tts(state: &AppState, text: &str, voice: &str) -> AppResult<Vec<u8
let resp = client
.post(url)
.bearer_auth(&state.config.auth.proxy_token)
.bearer_auth(state.runtime_proxy_token())
.json(&json!({
"model": "tts-1",
"input": text,
@@ -633,3 +647,101 @@ async fn full_workflow(
}
})))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
auth::jwt::generate_jwt,
config::{AppConfig, AuthConfig, ComfyUIConfig, DbConfig, ProxyConfig, ServerConfig},
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
};
use axum::http::HeaderValue;
use sqlx::sqlite::SqlitePoolOptions;
async fn test_state() -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool");
AppState::new(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 4000,
log_level: "info".to_string(),
cors_origins: Vec::new(),
},
db: DbConfig {
url: "sqlite::memory:".to_string(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "test-jwt-secret".to_string(),
jwt_expiry_hours: 24,
auth_token: "test-admin-token".to_string(),
proxy_token: "sk-test-proxy".to_string(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 1,
proxy_log_retention_days: 7,
template_proxy_url: None,
},
comfyui: ComfyUIConfig::default(),
},
pool,
BuiltinKeys {
comfyui_agent: (0, "sk-comfyui-test".to_string()),
},
RuntimeAuthSettings::from_token_for_test("sk-test-proxy"),
)
}
fn bearer_headers(token: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {token}")).expect("valid bearer header"),
);
headers
}
#[tokio::test]
async fn legacy_video_agent_auth_accepts_admin_jwt() {
let state = test_state().await;
let token = generate_jwt(
&state.config.auth.jwt_secret,
"42",
"admin",
state.config.auth.jwt_expiry_hours,
)
.expect("admin jwt");
check_auth(&bearer_headers(&token), &state)
.expect("admin jwt can access legacy video agent routes");
}
#[tokio::test]
async fn legacy_video_agent_auth_rejects_user_jwt() {
let state = test_state().await;
let token = generate_jwt(
&state.config.auth.jwt_secret,
"43",
"user",
state.config.auth.jwt_expiry_hours,
)
.expect("user jwt");
let result = check_auth(&bearer_headers(&token), &state);
assert!(matches!(result, Err(AppError::Forbidden(_))));
}
}
+204 -96
View File
@@ -30,7 +30,7 @@ use serde_json::{json, Map, Value};
use sqlx::Row;
use std::{
collections::{HashMap, HashSet},
path::PathBuf,
path::{Path as StdPath, PathBuf},
sync::{mpsc, Arc},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
@@ -46,6 +46,7 @@ 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");
@@ -210,9 +211,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 PathBuf::from(path);
return resolve_workflow_ui_dir(PathBuf::from(path));
}
let preferred = if config.uses_cloud_backend() {
let preferred = resolve_workflow_ui_dir(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"))
@@ -222,11 +223,23 @@ 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 {
PathBuf::from("dist/workflow-ui")
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
}
}
@@ -272,14 +285,24 @@ 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": workflow_ui_root_for_config(&state.config.comfyui),
"frontendDir": frontend_dir,
"frontendBuild": workflow_ui_build_metadata(&frontend_dir),
"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
@@ -307,7 +330,8 @@ fn caller_from_headers(state: &AppState, headers: &HeaderMap) -> AppResult<Workf
.filter(|value| !value.is_empty())
.ok_or_else(|| AppError::Unauthorized("Missing authorization token".into()))?;
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
let proxy_token = state.runtime_proxy_token();
if token == state.config.auth.auth_token || token == proxy_token {
return Ok(WorkflowCaller::Admin);
}
@@ -543,28 +567,28 @@ fn core_workflow_template_index() -> Value {
json!([
{
"moduleName": "default",
"title": "BossAI Examples",
"title": "BoosAPI Examples",
"category": "basics",
"type": "core",
"isEssential": true,
"templates": [
{
"name": "boosapi-text-to-image",
"title": "BossAI 文生图",
"description": "使用 BossAI 模型路由生成图片并预览输出。",
"title": "BoosAPI 文生图",
"description": "使用 BoosAPI 模型路由生成图片并预览输出。",
"mediaType": "image",
"mediaSubtype": "svg",
"tags": ["boosapi", "image"],
"models": ["BossAI"]
"models": ["BoosAPI"]
},
{
"name": "boosapi-text-to-video",
"title": "BossAI 文生视频",
"description": "使用 BossAI 视频节点生成视频并保存输出。",
"title": "BoosAPI 文生视频",
"description": "使用 BoosAPI 视频节点生成视频并保存输出。",
"mediaType": "image",
"mediaSubtype": "svg",
"tags": ["boosapi", "video"],
"models": ["BossAI"]
"models": ["BoosAPI"]
}
]
}
@@ -674,7 +698,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": ["seedance-1-0-lite-t2v", "16:9", 5, "720p", 24, true, 5, 180]
"widgets_values": [DEFAULT_TEXT_TO_VIDEO_MODEL, "16:9", 5, "720p", 24, true, 5, 180]
},
{
"id": 3,
@@ -690,7 +714,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": ["seedance-1-0-lite-t2v"]
"widgets_values": [DEFAULT_TEXT_TO_VIDEO_MODEL]
},
{
"id": 4,
@@ -1072,11 +1096,10 @@ fn workflow_folder_models(folder: &str, models: &[String]) -> Vec<String> {
.cloned()
.collect(),
"clip" => models.to_vec(),
"video_models" => models
.iter()
.filter(|model| is_video_workflow_model(model))
.cloned()
.collect(),
// 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(),
"audio_tts_models" => models
.iter()
.filter(|model| is_audio_workflow_model(model))
@@ -1101,6 +1124,32 @@ 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,
@@ -1230,7 +1279,7 @@ fn inject_model_choices(defs: &mut Map<String, Value>, models: &[String]) {
{
optional.insert(
"model".to_string(),
comfy_combo_input(&video_models, "seedance-1-0-lite-t2v"),
comfy_combo_input(&video_models, DEFAULT_TEXT_TO_VIDEO_MODEL),
);
}
}
@@ -1497,7 +1546,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>BossAI Workflow Studio</title>
<title>BoosAPI 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; }
@@ -1664,9 +1713,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") {
"BossAI Video"
"BoosAPI Video"
} else {
"BossAI Image"
"BoosAPI Image"
};
Some(
(
@@ -1787,7 +1836,7 @@ async fn serve_file(path: PathBuf) -> AppResult<Response> {
}
}
fn content_type_for_path(path: &PathBuf) -> &'static str {
fn content_type_for_path(path: &StdPath) -> &'static str {
match path
.extension()
.and_then(|ext| ext.to_str())
@@ -1808,15 +1857,15 @@ fn content_type_for_path(path: &PathBuf) -> &'static str {
}
}
fn cache_control_for_path(path: &PathBuf) -> &'static str {
fn cache_control_for_path(path: &StdPath) -> &'static str {
let ext = path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("")
.to_ascii_lowercase();
match ext.as_str() {
"html" => "no-cache",
"js" | "mjs" | "css" | "woff2" | "png" | "jpg" | "jpeg" | "webp" | "svg" => {
"html" | "js" | "mjs" | "css" => "no-cache",
"woff2" | "png" | "jpg" | "jpeg" | "webp" | "svg" => {
"public, max-age=604800"
}
_ => "public, max-age=3600",
@@ -3424,7 +3473,7 @@ async fn queue_boosapi_prompt(
if graph.nodes.is_empty() {
return Ok(prompt_error_response(
"画布里还没有可执行节点",
"请先从左侧节点库添加 BossAI 节点并连接后再执行。",
"请先从左侧节点库添加 BoosAPI 节点并连接后再执行。",
));
}
let name = payload
@@ -4077,6 +4126,7 @@ async fn archive_native_workflow_asset(
model: None,
remote_url: None,
metadata: json!({
"workspaceAsset": { "role": "output" },
"workflowUi": {
"filename": filename,
"type": file_type,
@@ -4308,7 +4358,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": "BossAIWorkflowError",
"exception_type": "BoosAPIWorkflowError",
"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!({})),
@@ -4430,7 +4480,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("BossAI workflow");
.unwrap_or("BoosAPI workflow");
let workflow = json!({ "name": workflow_name, "nodes": [], "links": [] });
let prompt = run
.node_runs
@@ -4634,7 +4684,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": "BossAIWorkflowError",
"exception_type": "BoosAPIWorkflowError",
"traceback": [],
})
},
@@ -4712,7 +4762,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": "BossAIWorkflowError",
"exception_type": "BoosAPIWorkflowError",
"traceback": [],
})
} else {
@@ -4790,15 +4840,28 @@ async fn experiment_models_in_folder(
) -> AppResult<Json<Value>> {
if state.config.comfyui.uses_cloud_backend() {
let _ = auth_caller_optional(&state, &headers);
let normalized_folder = folder.trim().to_ascii_lowercase();
if matches!(
normalized_folder.as_str(),
"boosapi" | "boosapi_models" | "api_models"
) {
if workflow_folder_has_route_models(&folder) {
let route_models = workflow_route_models(&state).await.unwrap_or_default();
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 api_model_items = workflow_folder_route_model_items(&folder, &route_models);
if workflow_folder_is_api_only(&folder) {
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(
@@ -4811,7 +4874,7 @@ async fn experiment_models_in_folder(
.await;
}
let route_models = workflow_route_models(&state).await.unwrap_or_default();
let api_model_items = model_payload_items_from_names(workflow_folder_models(&folder, &route_models), 0);
let api_model_items = workflow_folder_route_model_items(&folder, &route_models);
if state.config.comfyui.uses_native_backend() {
let _ = auth_caller_optional(&state, &headers);
@@ -5183,55 +5246,30 @@ 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, cloud_api_key);
return inject_external_bootstrap(html);
}
inject_enhanced_bootstrap(html, cloud_api_key)
inject_enhanced_bootstrap(html)
}
fn workflow_api_key_bootstrap(api_key: Option<&str>) -> String {
let Some(api_key) = api_key else {
return String::new();
};
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);
}})();
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) {}
};
clearLeakedComfyApiKey();
window.addEventListener('DOMContentLoaded', clearLeakedComfyApiKey);
})();
</script>"#
)
}
fn inject_external_bootstrap(html: String, cloud_api_key: Option<&str>) -> String {
let api_key_bootstrap = workflow_api_key_bootstrap(cloud_api_key);
fn inject_external_bootstrap(html: String) -> String {
let secret_cleanup_bootstrap = workflow_secret_cleanup_bootstrap();
let shell = r#"<script>
(function () {
const renameComfyUiBrand = () => {
@@ -5310,12 +5348,12 @@ fn inject_external_bootstrap(html: String, cloud_api_key: Option<&str>) -> Strin
window.setTimeout(installWorkflowValidationButton, 1800);
})();
</script>"#;
let bootstrap = format!("{api_key_bootstrap}{shell}");
let bootstrap = format!("{secret_cleanup_bootstrap}{shell}");
html.replace("</head>", &format!("{bootstrap}\n</head>"))
}
fn inject_enhanced_bootstrap(html: String, cloud_api_key: Option<&str>) -> String {
let api_key_bootstrap = workflow_api_key_bootstrap(cloud_api_key);
fn inject_enhanced_bootstrap(html: String) -> String {
let secret_cleanup_bootstrap = workflow_secret_cleanup_bootstrap();
let shell = r#"<style id="boosapi-workflow-shell-style">
</style>
<script>
@@ -5380,16 +5418,16 @@ fn inject_enhanced_bootstrap(html: String, cloud_api_key: Option<&str>) -> Strin
'CLIP': 'CLIP',
'VAE': 'VAE',
'CONDITIONING': '条件',
'BossAI': 'BossAI',
'boosapi.image_to_video': 'BossAI 图生视频',
'boosapi.text_to_video': 'BossAI 文生视频',
'BoosAPI': 'BoosAPI',
'boosapi.image_to_video': 'BoosAPI 图生视频',
'boosapi.text_to_video': 'BoosAPI 文生视频',
'boosapi.video_status': '查询视频任务',
'output.preview_video': '输出:预览视频',
'output.preview_image': '输出:预览图像',
'input.text': '输入:文本',
'boosapi.chat_completion': 'BossAI 对话',
'boosapi.prompt_rewrite': 'BossAI 提示词改写',
'boosapi.text_to_image': 'BossAI 文生图',
'boosapi.chat_completion': 'BoosAPI 对话',
'boosapi.prompt_rewrite': 'BoosAPI 提示词改写',
'boosapi.text_to_image': 'BoosAPI 文生图',
'output.save_file': '输出:保存文件'
}));
const translateWorkflowText = (text) => {
@@ -5577,7 +5615,7 @@ fn inject_enhanced_bootstrap(html: String, cloud_api_key: Option<&str>) -> Strin
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', 'BossAI 文生视频');
const node = window.LiteGraph.createNode('boosapi.text_to_video', 'BoosAPI 文生视频');
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];
@@ -5713,7 +5751,7 @@ fn inject_enhanced_bootstrap(html: String, cloud_api_key: Option<&str>) -> Strin
window.setTimeout(patchWorkflowShell, 1200);
})();
</script>"#;
let bootstrap = format!("{api_key_bootstrap}{shell}");
let bootstrap = format!("{secret_cleanup_bootstrap}{shell}");
html.replace("</head>", &format!("{bootstrap}\n</head>"))
}
@@ -5724,3 +5762,73 @@ 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"
);
}
}
+34 -17
View File
@@ -91,7 +91,8 @@ fn resolve_caller(
let token = bearer(headers)
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
let proxy_token = state.runtime_proxy_token();
if token == state.config.auth.auth_token || token == proxy_token {
return Ok(WorkspaceCaller::Admin);
}
@@ -172,6 +173,20 @@ fn row_to_json(row: sqlx::sqlite::SqliteRow) -> Value {
})
}
const WORKSPACE_OUTPUT_FILTER_SQL: &str = r#"
AND status = 'completed'
AND (
json_extract(metadata, '$.workspaceAsset.role') = 'output'
OR json_extract(metadata, '$.workflowUi.type') = 'output'
OR (
source IN ('picture-agent', 'workflow-studio')
AND json_type(metadata, '$.responseItem') IS NOT NULL
)
OR json_type(metadata, '$.moneyPrinter') IS NOT NULL
OR json_type(metadata, '$.digitalHuman') IS NOT NULL
)
"#;
async fn list_assets(
state: AppState,
caller: WorkspaceCaller,
@@ -184,15 +199,12 @@ async fn list_assets(
};
let is_admin = matches!(caller, WorkspaceCaller::Admin);
let items = sqlx::query(
let list_sql = format!(
"SELECT *
FROM workspace_assets
WHERE deleted_at IS NULL
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
AND (
source != 'workflow-ui'
OR json_extract(metadata, '$.workflowUi.type') = 'output'
)
{WORKSPACE_OUTPUT_FILTER_SQL}
AND (? IS NULL OR source = ?)
AND (? IS NULL OR kind = ?)
AND (
@@ -203,8 +215,9 @@ async fn list_assets(
OR source LIKE ?
)
ORDER BY datetime(created_at) DESC, id DESC
LIMIT ? OFFSET ?",
)
LIMIT ? OFFSET ?"
);
let items = sqlx::query(&list_sql)
.bind(is_admin)
.bind(scoped_user_id)
.bind(scoped_user_id)
@@ -225,15 +238,12 @@ async fn list_assets(
.fetch_all(&*state.db)
.await?;
let total: i64 = sqlx::query_scalar(
let count_sql = format!(
"SELECT COUNT(*)
FROM workspace_assets
WHERE deleted_at IS NULL
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
AND (
source != 'workflow-ui'
OR json_extract(metadata, '$.workflowUi.type') = 'output'
)
{WORKSPACE_OUTPUT_FILTER_SQL}
AND (? IS NULL OR source = ?)
AND (? IS NULL OR kind = ?)
AND (
@@ -242,8 +252,9 @@ 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)
@@ -288,7 +299,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)
@@ -328,7 +339,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)
@@ -339,6 +350,9 @@ 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)
}
@@ -384,6 +398,9 @@ 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);
+826 -1
View File
@@ -2,8 +2,10 @@ use crate::{
error::{AppError, AppResult},
services::shared::{enforce_key_request_limit, increment_key_usage},
};
use chrono::Timelike;
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool};
use serde_json::{json, Value};
use sqlx::{FromRow, Row, SqlitePool};
use std::sync::Arc;
#[derive(Clone)]
@@ -21,6 +23,23 @@ 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 }
@@ -118,6 +137,112 @@ 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
}
@@ -152,6 +277,215 @@ 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 (
@@ -190,8 +524,283 @@ 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)]
@@ -235,6 +844,56 @@ 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));
@@ -282,4 +941,170 @@ 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");
}
}
+5
View File
@@ -79,6 +79,10 @@ 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')
@@ -123,6 +127,7 @@ impl CronScheduler {
)
.execute(&*db)
.await?;
crate::proxy::token_router::router::invalidate_route_cache();
Ok(())
}
}
+3 -1
View File
@@ -5,9 +5,11 @@ 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;
+72 -9
View File
@@ -1,4 +1,4 @@
use crate::error::AppResult;
use crate::error::{AppError, AppResult};
use chrono::{FixedOffset, SecondsFormat, TimeZone, Utc};
use lettre::{
message::Mailbox, transport::smtp::authentication::Credentials, Message, SmtpTransport,
@@ -36,13 +36,19 @@ 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,
@@ -50,8 +56,10 @@ 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>,
@@ -100,6 +108,33 @@ 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, &notif, &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_%'")
@@ -112,18 +147,26 @@ 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"),
@@ -188,25 +231,28 @@ impl NotificationService {
fn enabled_channels(&self, config: &NotificationConfig) -> Vec<String> {
let mut channels = Vec::new();
if config.telegram_bot_token.is_some() && config.telegram_chat_id.is_some() {
if config.telegram_enabled
&& config.telegram_bot_token.is_some()
&& config.telegram_chat_id.is_some()
{
channels.push("telegram".to_string());
}
if config.wecom_webhook.is_some() {
if config.wecom_enabled && config.wecom_webhook.is_some() {
channels.push("wecom".to_string());
}
if config.feishu_webhook.is_some() {
if config.feishu_enabled && config.feishu_webhook.is_some() {
channels.push("feishu".to_string());
}
if config.webhook_url.is_some() {
if config.webhook_enabled && config.webhook_url.is_some() {
channels.push("webhook".to_string());
}
if config.bark_url.is_some() {
if config.bark_enabled && config.bark_url.is_some() {
channels.push("bark".to_string());
}
if config.serverchan_key.is_some() {
if config.serverchan_enabled && config.serverchan_key.is_some() {
channels.push("serverchan".to_string());
}
if config.smtp.is_configured() {
if config.smtp.enabled && config.smtp.is_configured() {
channels.push("smtp".to_string());
}
channels
@@ -385,6 +431,7 @@ 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")?;
@@ -400,7 +447,12 @@ impl SmtpConfig {
))
.body(format_notification_text(notif))?;
let mailer = SmtpTransport::relay(host)?
let builder = if secure {
SmtpTransport::relay(host)?
} else {
SmtpTransport::builder_dangerous(host)
};
let mailer = builder
.port(port)
.credentials(Credentials::new(user.to_string(), pass.to_string()))
.build();
@@ -413,6 +465,17 @@ 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"))
}
+523 -50
View File
@@ -1,13 +1,16 @@
use crate::error::{AppError, AppResult};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_json::{json, Map, 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>,
client: reqwest::Client,
#[cfg(test)]
client_override: Option<reqwest::Client>,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
@@ -26,6 +29,7 @@ pub struct OAuthRouteUnit {
pub struct RefreshedToken {
pub account_id: i64,
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: Option<String>,
}
@@ -33,7 +37,16 @@ impl OAuthService {
pub fn new(db: Arc<SqlitePool>) -> Self {
Self {
db,
client: reqwest::Client::new(),
#[cfg(test)]
client_override: None,
}
}
#[cfg(test)]
fn with_client(db: Arc<SqlitePool>, client: reqwest::Client) -> Self {
Self {
db,
client_override: Some(client),
}
}
@@ -55,6 +68,7 @@ impl OAuthService {
return Ok(RefreshedToken {
account_id,
access_token: current_access_token,
refresh_token,
expires_at: token_expiry(extra_config.as_deref()),
});
}
@@ -63,21 +77,24 @@ impl OAuthService {
.filter(|token| !token.trim().is_empty())
.ok_or_else(|| AppError::BadRequest("OAuth account has no refresh token".into()))?;
let mut refreshed = self.perform_refresh(site_id, &refresh_token).await?;
let (proxy_url, use_system_proxy) = oauth_proxy_settings(extra_config.as_deref());
let client = self
.configured_http_client(proxy_url.as_deref(), use_system_proxy)
.await?;
let mut refreshed = self
.perform_refresh(&client, site_id, &refresh_token)
.await?;
refreshed.account_id = account_id;
let next_extra_config =
merge_refreshed_extra_config(extra_config.as_deref(), &refreshed, &refresh_token)?;
sqlx::query(
"UPDATE accounts
SET access_token = ?, extra_config = ?, updated_at = datetime('now')
SET access_token = ?, api_token = ?, extra_config = ?, updated_at = datetime('now')
WHERE id = ? AND site_id = ?",
)
.bind(&refreshed.access_token)
.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(refreshed.refresh_token.as_deref().unwrap_or(&refresh_token))
.bind(next_extra_config)
.bind(account_id)
.bind(site_id)
.execute(&*self.db)
@@ -90,6 +107,19 @@ 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")
@@ -132,29 +162,37 @@ impl OAuthService {
async fn perform_refresh(
&self,
client: &reqwest::Client,
site_id: i64,
refresh_token: &str,
) -> AppResult<RefreshedToken> {
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 raw_custom_headers: Option<String> =
sqlx::query_scalar("SELECT custom_headers FROM sites WHERE id = ?")
.bind(site_id)
.fetch_optional(&*self.db)
.await?;
let Some(token_endpoint) = token_endpoint.filter(|url| !url.trim().is_empty()) else {
let token_config = oauth_token_config(raw_custom_headers.as_deref())?;
let Some(token_endpoint) = token_config.token_url.filter(|url| !url.trim().is_empty())
else {
return Err(AppError::BadRequest(
"OAuth token endpoint is not configured for this site".into(),
));
};
let mut form = vec![
("grant_type".to_string(), "refresh_token".to_string()),
("refresh_token".to_string(), refresh_token.to_string()),
];
if let Some(client_id) = token_config.client_id {
form.push(("client_id".to_string(), client_id));
}
if let Some(client_secret) = token_config.client_secret {
form.push(("client_secret".to_string(), client_secret));
}
let response: Value = self
.client
let response: Value = client
.post(token_endpoint)
.form(&[
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
])
.form(&form)
.send()
.await
.map_err(|err| AppError::UpstreamError(format!("OAuth refresh failed: {err}")))?
@@ -162,46 +200,481 @@ impl OAuthService {
.await
.map_err(|err| AppError::UpstreamError(format!("Invalid OAuth response: {err}")))?;
let access_token = response
.get("access_token")
.and_then(Value::as_str)
let access_token = string_field(&response, &["access_token", "accessToken"])
.ok_or_else(|| AppError::UpstreamError("OAuth response missing access_token".into()))?
.to_string();
let expires_at = response
.get("expires_at")
.and_then(Value::as_str)
.map(str::to_string);
let refresh_token =
string_field(&response, &["refresh_token", "refreshToken"]).map(str::to_string);
let expires_at = token_expiry_from_response(&response);
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 {
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)
token_expiry_datetime(extra_config)
.map(|expires_at| expires_at <= chrono::Utc::now() + chrono::Duration::minutes(5))
.unwrap_or(true)
}
fn token_expiry(extra_config: Option<&str>) -> Option<String> {
extra_config
.and_then(|config| serde_json::from_str::<Value>(config).ok())
.and_then(|value| {
value
.get("expires_at")
.and_then(Value::as_str)
.map(str::to_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
.get("expires_at")
.or_else(|| value.get("expiresAt"))
.and_then(Value::as_str)
{
return chrono::DateTime::parse_from_rfc3339(expires_at)
.map(|value| value.with_timezone(&chrono::Utc))
.ok();
}
let millis = value
.get("tokenExpiresAt")
.or_else(|| value.get("token_expires_at"))
.and_then(expiry_datetime_from_value)?;
Some(millis)
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn test_pool() -> SqlitePool {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool");
sqlx::query(
"CREATE TABLE settings (
key TEXT PRIMARY KEY NOT NULL,
value TEXT
)",
)
.execute(&pool)
.await
.expect("create settings");
sqlx::query(
"CREATE TABLE sites (
id INTEGER PRIMARY KEY,
custom_headers TEXT
)",
)
.execute(&pool)
.await
.expect("create sites");
sqlx::query(
"CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
site_id INTEGER NOT NULL,
access_token TEXT NOT NULL,
api_token TEXT,
extra_config TEXT,
oauth_provider TEXT,
updated_at TEXT
)",
)
.execute(&pool)
.await
.expect("create accounts");
pool
}
#[tokio::test]
async fn refresh_token_preserves_extra_config_and_updates_refresh_token() {
let pool = test_pool().await;
let mock = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.and(body_string_contains("grant_type=refresh_token"))
.and(body_string_contains("refresh_token=refresh-old"))
.and(body_string_contains("client_id=client-a"))
.and(body_string_contains("client_secret=secret-a"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"access_token": "access-new",
"refresh_token": "refresh-new",
"expires_at": 1893456000_i64
})))
.mount(&mock)
.await;
sqlx::query("INSERT INTO sites (id, custom_headers) VALUES (1, ?)")
.bind(
json!({
"oauth_token_url": format!("{}/oauth/token", mock.uri()),
"oauth_client_id": "client-a",
"oauth_client_secret": "secret-a"
})
.to_string(),
)
.execute(&pool)
.await
.expect("insert site");
sqlx::query(
"INSERT INTO accounts (id, site_id, access_token, api_token, extra_config, oauth_provider)
VALUES (7, 1, 'access-old', 'refresh-old', ?, 'codex')",
)
.bind(
json!({
"credentialMode": "oauth",
"proxyUrl": "socks5://127.0.0.1:1080",
"expires_at": "2000-01-01T00:00:00Z"
})
.to_string(),
)
.execute(&pool)
.await
.expect("insert account");
let service = OAuthService::with_client(Arc::new(pool.clone()), reqwest::Client::new());
let refreshed = service.refresh_token(7, 1).await.expect("refresh token");
assert_eq!(refreshed.access_token, "access-new");
assert_eq!(refreshed.refresh_token.as_deref(), Some("refresh-new"));
assert!(refreshed.expires_at.is_some());
let row: (String, Option<String>, Option<String>) = sqlx::query_as(
"SELECT access_token, api_token, extra_config FROM accounts WHERE id = 7",
)
.fetch_one(&pool)
.await
.expect("account row");
assert_eq!(row.0, "access-new");
assert_eq!(row.1.as_deref(), Some("refresh-new"));
let extra: Value =
serde_json::from_str(row.2.as_deref().expect("extra config")).expect("extra json");
assert_eq!(
extra.get("credentialMode").and_then(Value::as_str),
Some("oauth")
);
assert_eq!(
extra.get("proxyUrl").and_then(Value::as_str),
Some("socks5://127.0.0.1:1080")
);
assert_eq!(
extra.get("refreshToken").and_then(Value::as_str),
Some("refresh-new")
);
assert_eq!(
extra.get("expires_at").and_then(Value::as_str),
Some("2030-01-01T00:00:00+00:00")
);
assert_eq!(
extra.get("tokenExpiresAt").and_then(Value::as_i64),
Some(1893456000000_i64)
);
}
#[test]
fn token_expiry_from_response_accepts_string_token_expires_at() {
assert_eq!(
token_expiry_from_response(&json!({ "tokenExpiresAt": "1893456000" })).as_deref(),
Some("2030-01-01T00:00:00+00:00")
);
assert_eq!(
token_expiry_from_response(&json!({ "token_expires_at": 1893456000000_i64 }))
.as_deref(),
Some("2030-01-01T00:00:00+00:00")
);
}
#[tokio::test]
async fn refresh_token_keeps_current_token_when_not_expired() {
let pool = test_pool().await;
sqlx::query("INSERT INTO sites (id, custom_headers) VALUES (1, '{}')")
.execute(&pool)
.await
.expect("insert site");
let future = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
sqlx::query(
"INSERT INTO accounts (id, site_id, access_token, api_token, extra_config, oauth_provider)
VALUES (7, 1, 'access-current', 'refresh-current', ?, 'codex')",
)
.bind(json!({ "expires_at": future }).to_string())
.execute(&pool)
.await
.expect("insert account");
let service = OAuthService::with_client(Arc::new(pool), reqwest::Client::new());
let refreshed = service.refresh_token(7, 1).await.expect("current token");
assert_eq!(refreshed.access_token, "access-current");
assert_eq!(refreshed.refresh_token.as_deref(), Some("refresh-current"));
}
#[tokio::test]
async fn refresh_token_keeps_current_token_when_token_expires_at_is_future() {
let pool = test_pool().await;
sqlx::query("INSERT INTO sites (id, custom_headers) VALUES (1, '{}')")
.execute(&pool)
.await
.expect("insert site");
let future_millis = (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp_millis();
sqlx::query(
"INSERT INTO accounts (id, site_id, access_token, api_token, extra_config, oauth_provider)
VALUES (7, 1, 'access-current', 'refresh-current', ?, 'codex')",
)
.bind(json!({ "tokenExpiresAt": future_millis }).to_string())
.execute(&pool)
.await
.expect("insert account");
let service = OAuthService::with_client(Arc::new(pool), reqwest::Client::new());
let refreshed = service.refresh_token(7, 1).await.expect("current token");
assert_eq!(refreshed.access_token, "access-current");
assert_eq!(refreshed.refresh_token.as_deref(), Some("refresh-current"));
assert!(refreshed
.expires_at
.as_deref()
.is_some_and(|value| value.contains('T')));
}
#[tokio::test]
async fn system_proxy_url_reads_trimmed_runtime_setting() {
let pool = test_pool().await;
assert_eq!(system_proxy_url(&pool).await.unwrap(), None);
sqlx::query("INSERT INTO settings (key, value) VALUES (?, ?)")
.bind(SYSTEM_PROXY_URL_SETTING_KEY)
.bind(" http://127.0.0.1:7890 ")
.execute(&pool)
.await
.expect("insert system proxy");
assert_eq!(
system_proxy_url(&pool).await.unwrap().as_deref(),
Some("http://127.0.0.1:7890")
);
}
}
File diff suppressed because it is too large Load Diff
+114 -6
View File
@@ -51,13 +51,16 @@ 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> {
rebuild_routes_inner(&state.db).await
let summary = rebuild_routes_inner(&state.db).await?;
crate::proxy::token_router::router::invalidate_route_cache();
Ok(summary)
}
/// Pool-only entry point. Identical to `rebuild_routes`, just decoupled from
@@ -73,10 +76,11 @@ 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)> = sqlx::query_as(
"SELECT ma.account_id, ma.model_name
let rows: Vec<(i64, String, Option<i64>)> = sqlx::query_as(
"SELECT ma.account_id, ma.model_name, rum.unit_id
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 (
@@ -91,9 +95,10 @@ pub(crate) async fn rebuild_routes_inner(
let avail: Vec<AvailRow> = rows
.into_iter()
.map(|(account_id, model_name)| AvailRow {
.map(|(account_id, model_name, oauth_route_unit_id)| AvailRow {
account_id,
model_name,
oauth_route_unit_id,
})
.collect();
@@ -110,11 +115,16 @@ 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
@@ -209,21 +219,41 @@ 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,
priority, weight, enabled, manual_override)
VALUES (?, ?, ?, ?, 0, 10, 1, 0)",
oauth_route_unit_id, 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 {
@@ -377,6 +407,7 @@ 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,
@@ -389,6 +420,20 @@ 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)
@@ -522,6 +567,69 @@ 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
+93
View File
@@ -0,0 +1,93 @@
use sqlx::SqlitePool;
use parking_lot::RwLock;
pub const PROXY_TOKEN_SETTING_KEY: &str = "runtime.auth.proxy_token";
#[derive(Debug)]
pub struct RuntimeAuthSettings {
default_proxy_token: String,
proxy_token: RwLock<String>,
}
impl RuntimeAuthSettings {
#[cfg(test)]
pub fn from_token_for_test(proxy_token: impl Into<String>) -> Self {
let proxy_token = proxy_token.into();
Self {
default_proxy_token: proxy_token.clone(),
proxy_token: RwLock::new(proxy_token),
}
}
pub async fn load(pool: &SqlitePool, default_proxy_token: String) -> anyhow::Result<Self> {
let stored: Option<String> =
sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
.bind(PROXY_TOKEN_SETTING_KEY)
.fetch_optional(pool)
.await?;
let proxy_token = stored
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(default_proxy_token.trim())
.to_string();
Ok(Self {
default_proxy_token,
proxy_token: RwLock::new(proxy_token),
})
}
pub fn proxy_token(&self) -> String {
self.proxy_token.read().clone()
}
pub async fn set_proxy_token(
&self,
pool: &SqlitePool,
proxy_token: String,
) -> anyhow::Result<String> {
let normalized = normalize_proxy_token(&proxy_token)?;
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.bind(PROXY_TOKEN_SETTING_KEY)
.bind(&normalized)
.execute(pool)
.await?;
*self.proxy_token.write() = normalized.clone();
Ok(normalized)
}
pub fn proxy_token_masked(&self) -> String {
mask_token(&self.proxy_token())
}
pub fn using_default_proxy_token(&self) -> bool {
self.proxy_token() == self.default_proxy_token
}
}
pub fn normalize_proxy_token(value: &str) -> anyhow::Result<String> {
let token = value.trim();
if !token.starts_with("sk-") {
anyhow::bail!("下游访问令牌必须以 sk- 开头");
}
if token.chars().count() < 6 {
anyhow::bail!("下游访问令牌至少 6 位(含 sk-)");
}
Ok(token.to_string())
}
pub fn mask_token(value: &str) -> String {
let token = value.trim();
if token.is_empty() {
return String::new();
}
let suffix: String = token
.chars()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("••••{}", suffix)
}
+174
View File
@@ -0,0 +1,174 @@
use chrono::{SecondsFormat, Utc};
use once_cell::sync::Lazy;
use parking_lot::RwLock;
use serde::Serialize;
use std::collections::VecDeque;
use std::fmt;
use std::sync::atomic::{AtomicI64, Ordering};
use tracing::{
field::{Field, Visit},
Level, Subscriber,
};
use tracing_subscriber::{layer::Context, Layer};
const MAX_RUNTIME_LOGS: usize = 500;
static NEXT_ID: AtomicI64 = AtomicI64::new(1);
static RUNTIME_LOGS: Lazy<RwLock<VecDeque<RuntimeLogEntry>>> =
Lazy::new(|| RwLock::new(VecDeque::with_capacity(MAX_RUNTIME_LOGS)));
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeLogEntry {
pub id: i64,
#[serde(rename = "type")]
pub event_type: String,
pub title: String,
pub message: Option<String>,
pub level: String,
#[serde(rename = "read")]
pub is_read: bool,
pub related_id: Option<i64>,
pub related_type: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Default)]
pub struct RuntimeLogLayer;
impl<S> Layer<S> for RuntimeLogLayer
where
S: Subscriber,
{
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
let metadata = event.metadata();
let level = metadata.level();
if !matches!(*level, Level::ERROR | Level::WARN | Level::INFO) {
return;
}
let mut visitor = LogFieldVisitor::default();
event.record(&mut visitor);
let title = visitor
.message
.unwrap_or_else(|| metadata.name().to_string());
let mut details = Vec::with_capacity(visitor.fields.len() + 1);
details.push(metadata.target().to_string());
details.extend(visitor.fields);
push(RuntimeLogEntry {
id: -NEXT_ID.fetch_add(1, Ordering::Relaxed),
event_type: "runtime".to_string(),
title,
message: Some(details.join(" · ")).filter(|value| !value.trim().is_empty()),
level: level_name(level).to_string(),
is_read: true,
related_id: None,
related_type: None,
created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
});
}
}
pub fn list() -> Vec<RuntimeLogEntry> {
RUNTIME_LOGS.read().iter().cloned().collect()
}
pub fn clear() {
RUNTIME_LOGS.write().clear();
}
fn push(entry: RuntimeLogEntry) {
let mut logs = RUNTIME_LOGS.write();
if logs.len() >= MAX_RUNTIME_LOGS {
logs.pop_front();
}
logs.push_back(entry);
}
fn level_name(level: &Level) -> &'static str {
match *level {
Level::ERROR => "error",
Level::WARN => "warning",
_ => "info",
}
}
#[derive(Debug, Default)]
struct LogFieldVisitor {
message: Option<String>,
fields: Vec<String>,
}
impl LogFieldVisitor {
fn record_value(&mut self, field: &Field, value: String) {
if field.name() == "message" {
self.message = Some(value);
} else {
self.fields.push(format!("{}={}", field.name(), value));
}
}
}
impl Visit for LogFieldVisitor {
fn record_str(&mut self, field: &Field, value: &str) {
self.record_value(field, value.to_string());
}
fn record_bool(&mut self, field: &Field, value: bool) {
self.record_value(field, value.to_string());
}
fn record_i64(&mut self, field: &Field, value: i64) {
self.record_value(field, value.to_string());
}
fn record_u64(&mut self, field: &Field, value: u64) {
self.record_value(field, value.to_string());
}
fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) {
self.record_value(field, value.to_string());
}
fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
self.record_value(field, normalize_debug_value(format!("{value:?}")));
}
}
fn normalize_debug_value(value: String) -> String {
if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') {
value[1..value.len() - 1].replace("\\\"", "\"")
} else {
value
}
}
#[cfg(test)]
mod tests {
use super::*;
use tracing_subscriber::prelude::*;
#[test]
fn runtime_log_layer_captures_info_events() {
clear();
let subscriber = tracing_subscriber::registry().with(RuntimeLogLayer);
tracing::subscriber::with_default(subscriber, || {
tracing::info!(key_id = 42, "Built-in key ready");
});
let logs = list();
let entry = logs.last().expect("runtime log entry");
assert_eq!(entry.event_type, "runtime");
assert_eq!(entry.title, "Built-in key ready");
assert_eq!(entry.level, "info");
assert_eq!(entry.is_read, true);
assert!(entry
.message
.as_deref()
.unwrap_or_default()
.contains("key_id=42"));
}
}
-115
View File
@@ -1,115 +0,0 @@
use crate::error::{AppError, AppResult};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::SqlitePool;
use std::sync::Arc;
const UPDATE_CONFIG_KEY: &str = "update_center_config";
#[derive(Clone)]
pub struct UpdateCenter {
db: Arc<SqlitePool>,
client: reqwest::Client,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateInfo {
pub current_version: String,
pub latest_version: Option<String>,
pub update_available: bool,
pub release_notes: Option<String>,
pub download_url: Option<String>,
}
impl UpdateCenter {
pub fn new(db: Arc<SqlitePool>) -> Self {
Self {
db,
client: reqwest::Client::new(),
}
}
pub async fn check_for_updates(&self) -> AppResult<UpdateInfo> {
let config = self.load_config().await?;
let current_version = env!("CARGO_PKG_VERSION").to_string();
let Some(endpoint) = config
.get("check_url")
.and_then(Value::as_str)
.filter(|url| !url.trim().is_empty())
else {
return Ok(UpdateInfo {
current_version,
latest_version: None,
update_available: false,
release_notes: None,
download_url: None,
});
};
let payload: Value = self
.client
.get(endpoint)
.send()
.await
.map_err(|err| AppError::UpstreamError(format!("Update check failed: {err}")))?
.json()
.await
.map_err(|err| AppError::UpstreamError(format!("Invalid update response: {err}")))?;
let latest_version = payload
.get("version")
.or_else(|| payload.get("latestVersion"))
.and_then(Value::as_str)
.map(str::to_string);
let update_available = latest_version
.as_deref()
.map(|latest| latest != current_version)
.unwrap_or(false);
Ok(UpdateInfo {
current_version,
latest_version,
update_available,
release_notes: payload
.get("release_notes")
.or_else(|| payload.get("releaseNotes"))
.and_then(Value::as_str)
.map(str::to_string),
download_url: payload
.get("download_url")
.or_else(|| payload.get("downloadUrl"))
.and_then(Value::as_str)
.map(str::to_string),
})
}
pub async fn apply_update(&self, version: &str) -> AppResult<String> {
let task_id = format!("update-{}", uuid::Uuid::new_v4());
sqlx::query(
"INSERT INTO background_tasks
(id, task_type, title, status, message, dedupe_key, created_at, updated_at)
VALUES (?, 'update-center-deploy', ?, 'pending', ?, ?, datetime('now'), datetime('now'))",
)
.bind(&task_id)
.bind(format!("Apply BoosAPI {version}"))
.bind("Update request accepted; an external supervisor should perform download and restart")
.bind(format!("update-center:deploy:{version}"))
.execute(&*self.db)
.await?;
Ok(task_id)
}
async fn load_config(&self) -> AppResult<Value> {
let raw: Option<String> = sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
.bind(UPDATE_CONFIG_KEY)
.fetch_optional(&*self.db)
.await?;
raw.as_deref()
.map(serde_json::from_str)
.transpose()
.map_err(|err| AppError::BadRequest(format!("Invalid update center config: {err}")))?
.map(Ok)
.unwrap_or_else(|| Ok(Value::Object(Default::default())))
}
}
+110 -7
View File
@@ -160,6 +160,23 @@ 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,
@@ -169,6 +186,51 @@ async fn insert_asset(
})
}
fn should_project_workspace_asset(status: &str, metadata: &Value) -> bool {
status == "completed"
&& (metadata.pointer("/workspaceAsset/role").and_then(Value::as_str) == Some("output")
|| metadata.pointer("/workflowUi/type").and_then(Value::as_str) == Some("output")
|| metadata.get("responseItem").is_some()
|| metadata.get("moneyPrinter").is_some()
|| metadata.get("digitalHuman").is_some())
}
async fn upsert_workspace_asset_daily(
state: &AppState,
local_day: Option<&str>,
user_id: Option<i64>,
source: &str,
kind: &str,
size_bytes: i64,
has_local_file: bool,
delta: i64,
) -> Result<(), AppError> {
let asset_count_delta = delta;
let local_file_delta = if has_local_file { delta } else { 0 };
let size_delta = size_bytes.max(0) * delta;
sqlx::query(
"INSERT INTO workspace_asset_daily
(local_day, user_id, source, kind, asset_count, local_file_count, total_size_bytes, updated_at)
VALUES (COALESCE(?, date('now', 'localtime')), ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(local_day, user_id, source, kind) DO UPDATE SET
asset_count = MAX(0, asset_count + excluded.asset_count),
local_file_count = MAX(0, local_file_count + excluded.local_file_count),
total_size_bytes = MAX(0, total_size_bytes + excluded.total_size_bytes),
updated_at = datetime('now')",
)
.bind(local_day)
.bind(user_id.unwrap_or(0))
.bind(source)
.bind(kind)
.bind(asset_count_delta)
.bind(local_file_delta)
.bind(size_delta)
.execute(&*state.db)
.await?;
Ok(())
}
pub async fn record_remote_asset(
state: &AppState,
input: WorkspaceAssetInput,
@@ -188,11 +250,7 @@ pub async fn record_remote_asset(
.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 {
match state.http.get(url).send().await {
Ok(response) if response.status().is_success() => {
let mime_type = response
.headers()
@@ -307,6 +365,45 @@ 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>,
@@ -338,7 +435,10 @@ pub async fn record_image_assets_from_response(
prompt: prompt.clone(),
model: model.clone(),
remote_url: Some(url.trim().to_string()),
metadata: json!({ "responseItem": item }),
metadata: json!({
"workspaceAsset": { "role": "output" },
"responseItem": item,
}),
};
match record_remote_asset(state, input).await {
Ok(asset) => stored_assets.push(asset),
@@ -357,7 +457,10 @@ pub async fn record_image_assets_from_response(
prompt: prompt.clone(),
model: model.clone(),
remote_url: None,
metadata: json!({ "responseItem": item }),
metadata: json!({
"workspaceAsset": { "role": "output" },
"responseItem": item,
}),
};
match record_base64_asset(state, input, b64).await {
Ok(asset) => stored_assets.push(asset),
-161
View File
@@ -1,161 +0,0 @@
export type UpdateVersionCandidateLike = {
normalizedVersion?: string | null;
displayVersion?: string | null;
tagName?: string | null;
digest?: string | null;
};
export type UpdateHelperRuntimeLike = {
imageTag?: string | null;
imageDigest?: string | null;
};
export type UpdateReminderCandidate = {
source: 'github-release' | 'docker-hub-tag';
kind: 'new-version' | 'new-digest';
candidateKey: string;
displayVersion: string;
tagName: string;
digest: string | null;
};
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
function normalizeString(value?: string | null): string {
return String(value || '').trim();
}
function normalizeDigest(value?: string | null): string {
const digest = normalizeString(value);
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : '';
}
function normalizeStableTag(value?: string | null): string {
return normalizeStableVersion(value);
}
function hasSameImageTag(left?: string | null, right?: string | null): boolean {
const rawLeft = normalizeString(left);
const rawRight = normalizeString(right);
if (!rawLeft || !rawRight) return false;
const stableLeft = normalizeStableTag(left);
const stableRight = normalizeStableTag(right);
if (stableLeft && stableRight) {
return stableLeft === stableRight;
}
return rawLeft === rawRight;
}
export function normalizeStableVersion(value?: string | null): string {
const raw = normalizeString(value);
if (!raw) return '';
const match = raw.match(STABLE_SEMVER_PATTERN);
if (!match) return '';
return [
Number.parseInt(match[1], 10),
Number.parseInt(match[2], 10),
Number.parseInt(match[3], 10),
].join('.');
}
export function compareStableVersions(left?: string | null, right?: string | null): number | null {
const normalizedLeft = normalizeStableVersion(left);
const normalizedRight = normalizeStableVersion(right);
if (!normalizedLeft || !normalizedRight) return null;
const leftParts = normalizedLeft.split('.').map((item) => Number.parseInt(item, 10));
const rightParts = normalizedRight.split('.').map((item) => Number.parseInt(item, 10));
for (let index = 0; index < 3; index += 1) {
if (leftParts[index] === rightParts[index]) continue;
return leftParts[index] < rightParts[index] ? -1 : 1;
}
return 0;
}
export function isSameImageTarget(
current: UpdateHelperRuntimeLike | null | undefined,
target: { tag?: string | null; digest?: string | null },
): boolean {
const currentDigest = normalizeDigest(current?.imageDigest);
const targetDigest = normalizeDigest(target.digest);
if (currentDigest && targetDigest) {
return currentDigest === targetDigest;
}
return hasSameImageTag(current?.imageTag, target.tag);
}
export function buildUpdateReminderCandidateKey(
source: 'github-release' | 'docker-hub-tag',
candidate: { tagName?: string | null; digest?: string | null },
): string {
const tagName = normalizeString(candidate.tagName);
const digest = normalizeDigest(candidate.digest);
if (!tagName) return '';
if (source === 'docker-hub-tag' && digest) {
return `${source}:${tagName}@${digest}`;
}
return `${source}:${tagName}`;
}
export function resolveUpdateReminderCandidate(input: {
currentVersion?: string | null;
helper: UpdateHelperRuntimeLike | null | undefined;
githubRelease: UpdateVersionCandidateLike | null | undefined;
dockerHubTag: UpdateVersionCandidateLike | null | undefined;
}): UpdateReminderCandidate | null {
const githubCandidateVersion = normalizeString(input.githubRelease?.normalizedVersion);
const githubTag = normalizeString(input.githubRelease?.tagName || githubCandidateVersion);
if (githubTag) {
const githubTargetVersion = githubCandidateVersion || githubTag;
const versionCompare = compareStableVersions(input.currentVersion, githubTargetVersion);
const helperVersionCompare = compareStableVersions(input.helper?.imageTag, githubTargetVersion);
if (versionCompare === -1 && (helperVersionCompare == null || helperVersionCompare === -1)) {
return {
source: 'github-release',
kind: 'new-version',
candidateKey: buildUpdateReminderCandidateKey('github-release', { tagName: githubTag, digest: null }),
displayVersion: normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion || githubTag),
tagName: githubTag,
digest: null,
};
}
}
const dockerCandidateVersion = normalizeString(input.dockerHubTag?.normalizedVersion);
const dockerTag = normalizeString(input.dockerHubTag?.tagName || dockerCandidateVersion);
const dockerDigest = normalizeDigest(input.dockerHubTag?.digest);
if (!dockerTag) return null;
if (isSameImageTarget(input.helper, { tag: dockerTag, digest: dockerDigest })) {
return null;
}
const dockerVersionCompare = compareStableVersions(input.currentVersion, dockerCandidateVersion || dockerTag);
if (dockerVersionCompare === -1) {
return {
source: 'docker-hub-tag',
kind: 'new-version',
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest || null }),
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
tagName: dockerTag,
digest: dockerDigest || null,
};
}
const helperDigest = normalizeDigest(input.helper?.imageDigest);
if (dockerDigest && helperDigest && hasSameImageTag(input.helper?.imageTag, dockerTag) && helperDigest !== dockerDigest) {
return {
source: 'docker-hub-tag',
kind: 'new-digest',
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest }),
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
tagName: dockerTag,
digest: dockerDigest,
};
}
return null;
}
+452 -9
View File
@@ -1,7 +1,18 @@
import { describe, expect, it, vi } from 'vitest';
import { create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
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 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) => {
@@ -10,6 +21,77 @@ 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(
@@ -37,12 +119,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('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API');
expect(pageText).toContain('统一代理网关');
expect(pageText).toContain('兼容 OpenAI / Claude Messages / Comfy Cloud / RunningHub / 火山方舟等多类上游');
expect(pageText).toContain('统一 API 网关');
expect(pageText).toContain('智能路由引擎');
expect(pageText).toContain('自动模型发现');
expect(pageText).toContain('工作流生产台');
expect(lightBrandPanel).toBeTruthy();
expect(authStage).toBeTruthy();
expect(brandMarkCanvas).toBeTruthy();
@@ -50,15 +132,376 @@ 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(tokenInput.props.type).toBe('password');
expect(loginModeTabs.map((node) => collectText(node))).toEqual(['Auth Token', '账号密码']);
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');
} 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);');
});
});
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('App route transition feedback', () => {
it('tracks pathname and query changes without remounting page content for search-only navigation', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8').replace(/\r\n/g, '\n');
expect(source).toContain('const routeTransitionKey = `${location.pathname}${location.search}`;');
expect(source).toContain('const previousRouteTransitionKeyRef = useRef(routeTransitionKey);');
expect(source).toContain('const sidebarRef = useRef<HTMLElement>(null);');
expect(source).toContain('const mainRef = useRef<HTMLElement>(null);');
expect(source).toContain("import { shouldFocusMainOnRouteChange } from './pages/helpers/routeFocus.js';");
expect(source).toContain('previousRouteTransitionKeyRef.current === routeTransitionKey');
expect(source).toContain('className="skip-link"');
expect(source).toContain('href="#main-content"');
expect(source).toContain('mainRef.current?.focus({ preventScroll: true });');
expect(source).toContain("{t('跳到主内容')}");
expect(source).toContain('ref={mainRef}');
expect(source).toContain('id="main-content"');
expect(source).toContain('tabIndex={-1}');
expect(source).toContain("aria-label={t('主内容')}");
expect(source).toContain('const pageMotionKey = `${location.pathname}${location.search}`;');
expect(source).toContain('key={location.pathname} motionKey={pageMotionKey}');
expect(source).toContain('<Suspense fallback={null}>');
expect(source).not.toContain('function RouteLoadingFallback');
expect(source).not.toContain('fallback={<RouteLoadingFallback />}');
expect(source).not.toContain('routeTransitioning');
expect(source).not.toContain('is-route-transitioning');
expect(source).toContain('className="sr-only"');
expect(source).toContain('aria-live="polite"');
expect(source).toContain('aria-atomic="true"');
});
it('moves focus to the main region on shell-driven route changes without stealing field focus', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8').replace(/\r\n/g, '\n');
expect(source).toContain('const shouldFocusMain = shouldFocusMainOnRouteChange(document.activeElement, {');
expect(source).toContain('body: document.body,');
expect(source).toContain('topbar: topbarRef.current,');
expect(source).toContain('sidebar: sidebarRef.current,');
expect(source).not.toContain('const shouldFocusMain = !activeElement');
expect(source).toContain('let focusFrameId: number | null = null;');
expect(source).toContain("const schedule = typeof window.requestAnimationFrame === 'function'");
expect(source).toContain('focusFrameId = schedule(() => {');
expect(source).toContain('mainRef.current?.focus({ preventScroll: true });');
expect(source).toContain("typeof window.cancelAnimationFrame === 'function'");
expect(source).toContain('window.cancelAnimationFrame(focusFrameId);');
});
it('does not render a blocking shell loading frame while lazy routes resolve', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8').replace(/\r\n/g, '\n');
expect(css).toContain('.skip-link');
expect(css).toContain('.skip-link:focus,\n.skip-link:focus-visible');
expect(css).toContain('.sr-only');
expect(css).toContain('.skip-link,\n .skip-link:focus,\n .skip-link:focus-visible');
expect(source).not.toContain('RouteLoadingFallback');
expect(source).not.toContain('animate-fade-in');
expect(source).not.toContain('aria-busy={routeTransitioning}');
expect(css).not.toContain('.route-progress');
expect(css).not.toContain('@keyframes route-progress-sweep');
expect(css).not.toContain('.main-content.is-route-transitioning');
});
});
+175 -4
View File
@@ -11,17 +11,166 @@ 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 consoleGroupIndex = source.indexOf("label: '控制台'");
const accessGroupIndex = source.indexOf("label: '接入管理'");
const downstreamIndex = source.indexOf("{ to: '/downstream-keys', label: '下游密钥'");
const systemGroupIndex = source.indexOf("label: '系统'");
expect(consoleGroupIndex).toBeGreaterThanOrEqual(0);
expect(downstreamIndex).toBeGreaterThan(consoleGroupIndex);
expect(accessGroupIndex).toBeGreaterThanOrEqual(0);
expect(downstreamIndex).toBeGreaterThan(accessGroupIndex);
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');
@@ -29,4 +178,26 @@ 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 />} />');
});
});
@@ -0,0 +1,117 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
function readAppSource(): string {
return readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8').replace(/\r\n/g, '\n');
}
describe('App topbar menu accessibility', () => {
it('exposes theme and account dropdowns as controlled menus', () => {
const source = readAppSource();
expect(source).toContain('const themeMenuId = useId();');
expect(source).toContain('const userMenuId = useId();');
expect(source).toContain('const userTriggerRef = useRef<HTMLButtonElement>(null);');
expect(source).toContain('const themeTriggerRef = useRef<HTMLButtonElement>(null);');
expect(source).toContain('const searchDialogId = useId();');
expect(source).toContain('const notificationPanelId = useId();');
expect(source).toContain('function openSearch() {');
expect(source).toContain('aria-haspopup="menu"');
expect(source).toContain('aria-haspopup="dialog"');
expect(source).toContain('aria-expanded={showSearch}');
expect(source).toContain('aria-controls={showSearch ? searchDialogId : undefined}');
expect(source).toContain('aria-controls={themeMenuPresence.shouldRender ? themeMenuId : undefined}');
expect(source).toContain('aria-controls={userMenuPresence.shouldRender ? userMenuId : undefined}');
expect(source).toContain('aria-controls={showNotifications ? notificationPanelId : undefined}');
expect(source).toContain('role="menu"');
expect(source).toContain('aria-label={t(\'主题菜单\')}');
expect(source).toContain('aria-label={t(\'账户菜单\')}');
expect(source).toContain('role="menuitemradio"');
expect(source).toContain('role="menuitem"');
expect(source).toContain('dialogId={searchDialogId}');
expect(source).toContain("type ThemePreset = 'clearops' | 'terminal' | 'studio';");
expect(source).toContain('const THEME_PRESETS: Array<{ value: ThemePreset; label: string; description: string }> = [');
expect(source).toContain("const [themePreset, setThemePreset] = useState<ThemePreset>(() => resolveStoredThemePreset());");
expect(source).toContain("document.documentElement.setAttribute('data-theme-preset', themePreset);");
expect(source).toContain("document.documentElement.setAttribute('data-theme-tone', resolvedThemeTone);");
expect(source).toContain('className={`theme-preset-swatch theme-preset-swatch-${preset.value}`}');
});
it('keeps closing dropdown actions out of the tab order and closes menus on Escape', () => {
const source = readAppSource();
expect(source).toContain('tabIndex={themeMenuPresence.isVisible ? 0 : -1}');
expect(source).toContain('tabIndex={userMenuPresence.isVisible ? 0 : -1}');
expect(source).toContain('aria-hidden={!themeMenuPresence.isVisible}');
expect(source).toContain('aria-hidden={!userMenuPresence.isVisible}');
expect(source).toContain('if (!showThemeMenu && !showUserMenu) return undefined;');
expect(source).toContain("if (e.key !== 'Escape') return;");
expect(source).toContain('setShowThemeMenu(false);');
expect(source).toContain('setShowUserMenu(false);');
expect(source).toContain('setShowNotifications(false);');
expect(source).toContain('setShowSearch(false);');
expect(source).toContain('triggerRef.current?.focus({ preventScroll: true });');
expect(source).toContain('themeTriggerRef.current?.focus({ preventScroll: true });');
expect(source).toContain('if (e.defaultPrevented) return;');
expect(source).toContain("e.key.toLowerCase() === 'k'");
expect(source).toContain("document.addEventListener('keydown', handler);");
expect(source).toContain("return () => document.removeEventListener('keydown', handler);");
});
it('supports keyboard opening and roving focus inside topbar menus', () => {
const source = readAppSource();
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(source).toContain("type MenuFocusEdge = 'first' | 'last';");
expect(source).toContain("type PendingMenuFocus = { menu: 'theme' | 'user'; edge: MenuFocusEdge } | null;");
expect(source).toContain('const [pendingMenuFocus, setPendingMenuFocus] = useState<PendingMenuFocus>(null);');
expect(source).toContain('const focusMenuItem = (menuRef: React.RefObject<HTMLDivElement>, edge: MenuFocusEdge = \'first\') => {');
expect(source).toContain('target?.focus({ preventScroll: true });');
expect(source).toContain('if (!pendingMenuFocus) return;');
expect(source).toContain('const menuRef = pendingMenuFocus.menu === \'theme\' ? themeMenuRef : userMenuRef;');
expect(source).toContain('setPendingMenuFocus(null);');
expect(source).toContain('const handleTopbarMenuTriggerKeyDown = (');
expect(source).toContain("if (!['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) return;");
expect(source).toContain("const focusEdge: MenuFocusEdge = e.key === 'ArrowUp' ? 'last' : 'first';");
expect(source).toContain("setPendingMenuFocus({ menu: 'theme', edge: focusEdge });");
expect(source).toContain("setPendingMenuFocus({ menu: 'user', edge: focusEdge });");
expect(source).toContain('const handleTopbarMenuKeyDown = (');
expect(source).toContain("if (e.key === 'Escape') {\n e.preventDefault();\n setShowThemeMenu(false);");
expect(source).toContain("if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {");
expect(source).toContain('items[nextIndex]?.focus({ preventScroll: true });');
expect(source).toContain("if (e.key === 'Home') {");
expect(source).toContain('items[0]?.focus({ preventScroll: true });');
expect(source).toContain("if (e.key === 'End') {");
expect(source).toContain('items[items.length - 1]?.focus({ preventScroll: true });');
expect(source).toContain('target?.focus({ preventScroll: true });');
expect(source).toContain("onKeyDown={(e) => handleTopbarMenuTriggerKeyDown(e, 'theme')}");
expect(source).toContain("onKeyDown={(e) => handleTopbarMenuTriggerKeyDown(e, 'user')}");
expect(source).toContain('onKeyDown={handleNotificationTriggerKeyDown}');
expect(source).toContain("onKeyDown={(e) => handleTopbarMenuKeyDown(e, 'theme')}");
expect(source).toContain("onKeyDown={(e) => handleTopbarMenuKeyDown(e, 'user')}");
expect(css).toContain('.user-dropdown-item:focus-visible');
expect(css).toContain('[data-theme="dark"] .user-dropdown-item:focus-visible');
});
it('keeps the desktop topbar grid shrink-safe before the mobile breakpoint', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
const finalTopbarStart = css.indexOf('/* Final topbar layout guard: keep brand, nav, and user actions in their own lanes. */');
const finalTopbarEnd = css.indexOf('@media (max-width: 768px)', finalTopbarStart);
const finalTopbarCss = css.slice(finalTopbarStart, finalTopbarEnd);
expect(finalTopbarStart).toBeGreaterThanOrEqual(0);
expect(finalTopbarEnd).toBeGreaterThan(finalTopbarStart);
expect(finalTopbarCss).toContain('grid-template-columns: minmax(min(230px, 100%), 280px) auto minmax(min(300px, 100%), 1fr);');
expect(finalTopbarCss).toContain('grid-template-columns: minmax(min(170px, 100%), 220px) auto minmax(min(220px, 100%), 1fr);');
expect(finalTopbarCss).toContain('grid-template-columns: minmax(min(150px, 100%), 190px) auto minmax(min(150px, 100%), 1fr);');
expect(finalTopbarCss).toContain('width: max-content;');
expect(finalTopbarCss).toContain('max-width: min(430px, 100%);');
expect(finalTopbarCss).toContain('overflow-x: auto;');
expect(finalTopbarCss).toContain('.topbar-nav::-webkit-scrollbar {\n display: none;\n}');
expect(finalTopbarCss).not.toContain('width: min(520px, 100%);');
expect(finalTopbarCss).not.toContain('grid-template-columns: minmax(230px, 280px) minmax(320px, max-content) minmax(300px, 1fr);');
expect(finalTopbarCss).not.toContain('grid-template-columns: minmax(170px, 220px) minmax(280px, max-content) minmax(220px, 1fr);');
expect(finalTopbarCss).not.toContain('grid-template-columns: minmax(150px, 190px) minmax(240px, max-content) minmax(150px, 1fr);');
});
});
+685
View File
@@ -0,0 +1,685 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ReactNode } from 'react';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import App from './App.js';
const { apiMock, authSessionMock } = vi.hoisted(() => ({
apiMock: {
getEvents: vi.fn(),
},
authSessionMock: {
hasValidAuthSession: vi.fn(),
persistAuthSession: vi.fn(),
clearAuthSession: vi.fn(),
getSessionToken: vi.fn(),
commitSession: vi.fn(),
clearSession: vi.fn(),
},
}));
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
return {
...actual,
createPortal: (node: unknown) => node,
};
});
vi.mock('./api.js', () => ({
api: apiMock,
}));
vi.mock('./authSession.js', () => ({
hasValidAuthSession: authSessionMock.hasValidAuthSession,
persistAuthSession: authSessionMock.persistAuthSession,
clearAuthSession: authSessionMock.clearAuthSession,
getSessionToken: authSessionMock.getSessionToken,
commitSession: authSessionMock.commitSession,
clearSession: authSessionMock.clearSession,
}));
vi.mock('./components/SearchModal.js', () => ({
default: ({ open, dialogId }: { open: boolean; dialogId?: string }) => (
open ? <div id={dialogId} role="dialog" aria-modal="true" aria-label="搜索" /> : null
),
}));
vi.mock('./components/NotificationPanel.js', () => ({
default: () => null,
}));
vi.mock('./components/TooltipLayer.js', () => ({
default: () => null,
}));
vi.mock('./components/useAnimatedVisibility.js', () => ({
useAnimatedVisibility: (open: boolean) => ({
shouldRender: open,
isVisible: open,
}),
}));
vi.mock('./i18n.js', () => ({
I18nProvider: ({ children }: { children: ReactNode }) => children,
useI18n: () => ({
language: 'zh',
toggleLanguage: vi.fn(),
t: (text: string) => text,
}),
}));
vi.mock('./pages/Dashboard.js', () => ({
default: ({ adminName }: { adminName?: string }) => <div>{adminName || 'Dashboard'}</div>,
}));
function createLocalStorage() {
const store = new Map<string, string>([
['theme_mode', 'light'],
['theme_preset', 'clearops'],
['metapi.firstUseDocReminder', '1'],
['admin_profile', JSON.stringify({
name: '管理员',
avatarSeed: 'seed-1',
avatarStyle: 'identicon',
})],
]);
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, String(value));
},
removeItem: (key: string) => {
store.delete(key);
},
dump: () => new Map(store),
};
}
function setupRuntime() {
const matchMedia = (query: string) => ({
matches: query.includes('prefers-color-scheme') ? false : false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
onchange: null,
});
const storage = createLocalStorage();
const documentElementAttributes = new Map<string, string>();
vi.stubGlobal('localStorage', storage);
vi.stubGlobal('window', {
innerWidth: 1280,
matchMedia,
requestAnimationFrame: (callback: FrameRequestCallback) => {
callback(0);
return 1;
},
cancelAnimationFrame: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('document', {
body: { style: {} },
activeElement: null,
documentElement: {
setAttribute: vi.fn((name: string, value: string) => {
documentElementAttributes.set(name, value);
}),
getAttribute: vi.fn((name: string) => documentElementAttributes.get(name) ?? null),
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
return { documentElementAttributes, storage };
}
function keyEvent(key: string) {
return {
key,
preventDefault: vi.fn(),
};
}
function documentKeyEvent(overrides: Partial<KeyboardEvent> & { key: string }) {
return {
ctrlKey: false,
defaultPrevented: false,
metaKey: false,
preventDefault: vi.fn(),
...overrides,
};
}
function latestDocumentKeydownListener(): EventListener {
const calls = vi.mocked(document.addEventListener).mock.calls;
const listeners = calls
.filter(([type]) => type === 'keydown')
.map(([, listener]) => listener as EventListener);
const listener = listeners.at(-1);
if (!listener) throw new Error('No document keydown listener registered');
return listener;
}
function findButton(root: ReactTestInstance, matcher: (ariaLabel: string) => boolean): ReactTestInstance {
return root.find((node) => (
node.type === 'button'
&& typeof node.props['aria-label'] === 'string'
&& matcher(node.props['aria-label'])
));
}
function findMenus(root: ReactTestInstance, label: string): ReactTestInstance[] {
return root.findAll((node) => node.props.role === 'menu' && node.props['aria-label'] === label);
}
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
describe('App topbar menu behavior', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
apiMock.getEvents.mockResolvedValue([]);
authSessionMock.hasValidAuthSession.mockReturnValue(true);
authSessionMock.getSessionToken.mockImplementation((audience: string) =>
audience === 'admin' ? 'fake-admin-token' : null,
);
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('opens the theme menu from the keyboard trigger', async () => {
setupRuntime();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
);
});
await flushMicrotasks();
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
const event = keyEvent('ArrowDown');
await act(async () => {
themeTrigger.props.onKeyDown(event);
});
await flushMicrotasks();
expect(event.preventDefault).toHaveBeenCalledTimes(1);
expect(findButton(root.root, (label) => label.includes('浅色模式')).props['aria-expanded']).toBe(true);
expect(findMenus(root.root, '主题菜单')).toHaveLength(1);
expect(findMenus(root.root, '账户菜单')).toHaveLength(0);
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('moves focus across theme menu items without scrolling the page', async () => {
setupRuntime();
const menuItems: Array<{ label: string; focus: ReturnType<typeof vi.fn> }> = [
{ label: '跟随系统', focus: vi.fn() },
{ label: '浅色模式', focus: vi.fn() },
{ label: '深色模式', focus: vi.fn() },
{ label: '清透运维', focus: vi.fn() },
{ label: '深色终端', focus: vi.fn() },
{ label: '云端实验室', focus: vi.fn() },
];
let activeElement: unknown = menuItems[0];
Object.defineProperty(document, 'activeElement', {
configurable: true,
get: () => activeElement,
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
{
createNodeMock: (element) => {
const children = Array.isArray(element.props.children)
? element.props.children
: [element.props.children];
const isThemeMenuWrapper = (
element.type === 'div'
&& element.props.style?.position === 'relative'
&& children.some((child: any) => (
child?.type === 'button'
&& child.props?.['aria-haspopup'] === 'menu'
&& String(child.props?.['aria-label'] || '').includes('浅色模式')
))
);
if (isThemeMenuWrapper) {
return {
contains: () => true,
querySelectorAll: () => menuItems,
};
}
return null;
},
},
);
});
await flushMicrotasks();
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
const openEvent = keyEvent('ArrowDown');
await act(async () => {
themeTrigger.props.onKeyDown(openEvent);
});
await flushMicrotasks();
expect(openEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(menuItems[0]!.focus).toHaveBeenCalledWith({ preventScroll: true });
const menu = findMenus(root.root, '主题菜单')[0]!;
const arrowDownEvent = keyEvent('ArrowDown');
await act(async () => {
menu.props.onKeyDown(arrowDownEvent);
});
expect(arrowDownEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(menuItems[1]!.focus).toHaveBeenCalledWith({ preventScroll: true });
activeElement = menuItems[1];
const endEvent = keyEvent('End');
await act(async () => {
menu.props.onKeyDown(endEvent);
});
expect(endEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(menuItems[5]!.focus).toHaveBeenCalledWith({ preventScroll: true });
activeElement = menuItems[2];
const homeEvent = keyEvent('Home');
await act(async () => {
menu.props.onKeyDown(homeEvent);
});
expect(homeEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(menuItems[0]!.focus).toHaveBeenCalledTimes(2);
expect(menuItems[0]!.focus).toHaveBeenLastCalledWith({ preventScroll: true });
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('returns focus to the theme trigger after selecting a theme', async () => {
setupRuntime();
const themeFocus = vi.fn();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
{
createNodeMock: (element) => (
element.type === 'button'
&& String(element.props.className || '').includes('topbar-icon-btn')
&& element.props['aria-haspopup'] === 'menu'
? { focus: themeFocus }
: null
),
},
);
});
await flushMicrotasks();
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
await act(async () => {
themeTrigger.props.onKeyDown(keyEvent('ArrowDown'));
});
await flushMicrotasks();
const darkModeItem = root.root.find((node) => (
node.type === 'button'
&& node.props.role === 'menuitemradio'
&& collectText(node) === '深色模式'
));
await act(async () => {
darkModeItem.props.onClick();
});
await flushMicrotasks();
expect(themeFocus).toHaveBeenCalledWith({ preventScroll: true });
expect(findMenus(root.root, '主题菜单')).toHaveLength(0);
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('persists named theme presets and exposes root theme attributes', async () => {
const runtime = setupRuntime();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
);
});
await flushMicrotasks();
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
await act(async () => {
themeTrigger.props.onClick();
});
await flushMicrotasks();
const terminalPresetItem = root.root.find((node) => (
node.type === 'button'
&& node.props.role === 'menuitemradio'
&& collectText(node).includes('深色终端')
));
await act(async () => {
terminalPresetItem.props.onClick();
});
await flushMicrotasks();
expect(runtime.storage.getItem('theme_preset')).toBe('terminal');
expect(runtime.documentElementAttributes.get('data-theme-preset')).toBe('terminal');
expect(runtime.documentElementAttributes.get('data-theme-tone')).toBe('dark');
expect(findMenus(root.root, '主题菜单')).toHaveLength(0);
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('opens the notification panel from the keyboard trigger as a controlled dialog', async () => {
setupRuntime();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
);
});
await flushMicrotasks();
const notificationTrigger = findButton(root.root, (label) => label === '通知');
expect(notificationTrigger.props['aria-haspopup']).toBe('dialog');
expect(notificationTrigger.props['aria-expanded']).toBe(false);
expect(notificationTrigger.props['aria-controls']).toBeUndefined();
const openEvent = keyEvent('ArrowDown');
await act(async () => {
notificationTrigger.props.onKeyDown(openEvent);
});
await flushMicrotasks();
const openedNotificationTrigger = findButton(root.root, (label) => label === '通知');
expect(openEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(openedNotificationTrigger.props['aria-expanded']).toBe(true);
expect(typeof openedNotificationTrigger.props['aria-controls']).toBe('string');
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
await act(async () => {
themeTrigger.props.onClick();
});
await flushMicrotasks();
expect(findButton(root.root, (label) => label === '通知').props['aria-expanded']).toBe(false);
expect(findButton(root.root, (label) => label.includes('浅色模式')).props['aria-expanded']).toBe(true);
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('opens search as a controlled dialog trigger and closes other topbar surfaces', async () => {
setupRuntime();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
);
});
await flushMicrotasks();
const notificationTrigger = findButton(root.root, (label) => label === '通知');
await act(async () => {
notificationTrigger.props.onKeyDown(keyEvent('ArrowDown'));
});
await flushMicrotasks();
expect(findButton(root.root, (label) => label === '通知').props['aria-expanded']).toBe(true);
const searchTrigger = findButton(root.root, (label) => label === '搜索 (Ctrl+K)');
expect(searchTrigger.props['aria-haspopup']).toBe('dialog');
expect(searchTrigger.props['aria-expanded']).toBe(false);
expect(searchTrigger.props['aria-controls']).toBeUndefined();
await act(async () => {
searchTrigger.props.onClick();
});
await flushMicrotasks();
const openedSearchTrigger = findButton(root.root, (label) => label === '搜索 (Ctrl+K)');
expect(openedSearchTrigger.props['aria-expanded']).toBe(true);
expect(typeof openedSearchTrigger.props['aria-controls']).toBe('string');
const searchDialog = root.root.find((node) => (
node.props.role === 'dialog'
&& node.props.id === openedSearchTrigger.props['aria-controls']
));
expect(searchDialog.props['aria-modal']).toBe('true');
expect(findButton(root.root, (label) => label === '通知').props['aria-expanded']).toBe(false);
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
await act(async () => {
themeTrigger.props.onClick();
});
await flushMicrotasks();
expect(findButton(root.root, (label) => label === '搜索 (Ctrl+K)').props['aria-expanded']).toBe(false);
expect(findButton(root.root, (label) => label.includes('浅色模式')).props['aria-expanded']).toBe(true);
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('opens search from Ctrl or Command K without stealing handled shortcuts', async () => {
setupRuntime();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
);
});
await flushMicrotasks();
const keydown = latestDocumentKeydownListener();
const ignoredEvent = documentKeyEvent({ ctrlKey: true, defaultPrevented: true, key: 'k' });
await act(async () => {
keydown(ignoredEvent as unknown as Event);
});
await flushMicrotasks();
expect(ignoredEvent.preventDefault).not.toHaveBeenCalled();
expect(findButton(root.root, (label) => label === '搜索 (Ctrl+K)').props['aria-expanded']).toBe(false);
const ctrlEvent = documentKeyEvent({ ctrlKey: true, key: 'K' });
await act(async () => {
keydown(ctrlEvent as unknown as Event);
});
await flushMicrotasks();
expect(ctrlEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(findButton(root.root, (label) => label === '搜索 (Ctrl+K)').props['aria-expanded']).toBe(true);
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
await act(async () => {
themeTrigger.props.onClick();
});
await flushMicrotasks();
expect(findButton(root.root, (label) => label === '搜索 (Ctrl+K)').props['aria-expanded']).toBe(false);
const metaEvent = documentKeyEvent({ key: 'k', metaKey: true });
await act(async () => {
keydown(metaEvent as unknown as Event);
});
await flushMicrotasks();
expect(metaEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(findButton(root.root, (label) => label === '搜索 (Ctrl+K)').props['aria-expanded']).toBe(true);
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('renders skip-to-main and route status landmarks in the app shell', async () => {
setupRuntime();
let root!: WebTestRenderer;
const mainFocus = vi.fn();
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
{
createNodeMock: (element) => (
element.type === 'main'
? { focus: mainFocus }
: null
),
},
);
});
await flushMicrotasks();
const skipLink = root.root.find((node) => (
node.type === 'a'
&& node.props.className === 'skip-link'
));
expect(skipLink.props.href).toBe('#main-content');
await act(async () => {
skipLink.props.onClick();
});
expect(mainFocus).toHaveBeenCalledWith({ preventScroll: true });
const main = root.root.find((node) => node.type === 'main');
expect(main.props.id).toBe('main-content');
expect(main.props.tabIndex).toBe(-1);
expect(main.props['aria-label']).toBe('主内容');
const liveRegion = root.root.find((node) => (
node.props.className === 'sr-only'
&& node.props['aria-live'] === 'polite'
));
expect(liveRegion.props['aria-atomic']).toBe('true');
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('opens the account menu from the keyboard trigger and closes it with Escape', async () => {
setupRuntime();
const accountFocus = vi.fn();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
{
createNodeMock: (element) => (
element.type === 'button'
&& String(element.props.className || '').includes('topbar-user-trigger')
? { focus: accountFocus }
: null
),
},
);
});
await flushMicrotasks();
const accountTrigger = findButton(root.root, (label) => label.startsWith('账户菜单:'));
const openEvent = keyEvent('ArrowUp');
await act(async () => {
accountTrigger.props.onKeyDown(openEvent);
});
await flushMicrotasks();
expect(openEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(findButton(root.root, (label) => label.startsWith('账户菜单:')).props['aria-expanded']).toBe(true);
const menus = findMenus(root.root, '账户菜单');
expect(menus).toHaveLength(1);
expect(findMenus(root.root, '主题菜单')).toHaveLength(0);
const closeEvent = keyEvent('Escape');
await act(async () => {
menus[0]!.props.onKeyDown(closeEvent);
});
await flushMicrotasks();
expect(closeEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(accountFocus).toHaveBeenCalledWith({ preventScroll: true });
expect(findButton(root.root, (label) => label.startsWith('账户菜单:')).props['aria-expanded']).toBe(false);
expect(findMenus(root.root, '账户菜单')).toHaveLength(0);
} finally {
await act(async () => {
root?.unmount();
});
}
});
});
+6 -2
View File
@@ -3,16 +3,20 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('App topbar tooltips', () => {
it('removes topbar hover tooltips while preserving sidebar collapsed tooltips', () => {
it('removes hover-only tooltips from topbar and fixed desktop sidebar navigation', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
const 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).toContain("data-tooltip={sidebarCollapsed ? t(item.label) : undefined}");
expect(source).not.toContain('data-tooltip={sidebarCollapsed');
expect(sidebarItemStart).toBeGreaterThanOrEqual(0);
expect(sidebarItemEnd).toBeGreaterThan(sidebarItemStart);
});
});
+850 -181
View File
File diff suppressed because it is too large Load Diff
+385 -2
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { api, type ProxyTestRequestEnvelope } from './api.js';
import { persistAuthSession } from './authSession.js';
import { api, buildSiteProbeStreamUrl, userApi, type ProxyTestRequestEnvelope } from './api.js';
import { commitSession, persistAuthSession } from './authSession.js';
function createMemoryStorage() {
const store = new Map<string, string>();
@@ -122,6 +122,20 @@ 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();
@@ -179,3 +193,372 @@ 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,
});
});
});
+460 -58
View File
@@ -11,6 +11,22 @@ 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) {
@@ -28,13 +44,15 @@ function requireAuthToken(): string {
return token;
}
async function extractResponseErrorMessage(res: Response): Promise<string> {
async function extractResponseError(res: Response): Promise<ApiRequestError> {
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") {
@@ -47,12 +65,19 @@ async function extractResponseErrorMessage(res: Response): Promise<string> {
} 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 message;
return createApiRequestError(message, res.status, payload);
}
async function extractResponseErrorMessage(res: Response): Promise<string> {
return (await extractResponseError(res)).message;
}
async function parseJsonResponse<T = any>(res: Response): Promise<T> {
@@ -248,13 +273,54 @@ 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 new Error(await extractResponseErrorMessage(res));
throw await extractResponseError(res);
}
return parseJsonResponse<T>(res);
}
@@ -265,7 +331,7 @@ async function request<T = any>(
): Promise<T> {
const res = await fetchAuthenticatedResponse(url, options);
if (!res.ok) {
throw new Error(await extractResponseErrorMessage(res));
throw await extractResponseError(res);
}
return parseJsonResponse<T>(res);
}
@@ -299,6 +365,20 @@ function parseJsonArrayField(value: any): any[] {
}
}
function parseJsonObjectField(value: any): any {
if (!value) return null;
if (typeof value === "object") return value;
if (typeof value !== "string" || !value.trim()) return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: null;
} catch {
return null;
}
}
function normalizeRouteRow(row: any): any {
if (!row || typeof row !== "object") return row;
return {
@@ -392,6 +472,11 @@ 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,
};
}
@@ -469,6 +554,144 @@ function normalizeProxyDebugTraceDetail(response: any): ProxyDebugTraceDetail {
};
}
function normalizeOAuthConnectionRow(row: any): OAuthConnectionInfo | null {
if (!row || typeof row !== "object") return null;
const rawAccountId = row.accountId ?? row.account_id ?? row.id;
const accountId = Number(rawAccountId);
if (!Number.isFinite(accountId) || accountId <= 0) return null;
const rawSiteId = row.siteId ?? row.site_id ?? row.site?.id ?? 0;
const siteId = Number(rawSiteId);
const provider = String(row.provider ?? row.oauthProvider ?? row.oauth_provider ?? "").trim();
const modelsPreview = parseJsonArrayField(row.modelsPreview ?? row.models_preview)
.map((item) => String(item || "").trim())
.filter(Boolean);
const modelCount = Number(row.modelCount ?? row.model_count ?? modelsPreview.length);
const routeChannelCount = Number(row.routeChannelCount ?? row.route_channel_count);
const rawStatus = String(row.status ?? "").trim().toLowerCase();
const status: OAuthConnectionInfo["status"] = rawStatus === "healthy" || rawStatus === "active" || rawStatus === "enabled"
? "healthy"
: "abnormal";
const rawSite = row.site && typeof row.site === "object" ? row.site : null;
return {
...row,
accountId,
siteId: Number.isFinite(siteId) ? siteId : 0,
provider,
username: row.username ?? null,
email: row.email ?? null,
accountKey: row.accountKey ?? row.account_key ?? row.oauthAccountKey ?? row.oauth_account_key ?? null,
planType: row.planType ?? row.plan_type ?? null,
projectId: row.projectId ?? row.project_id ?? row.oauthProjectId ?? row.oauth_project_id ?? null,
modelCount: Number.isFinite(modelCount) ? modelCount : modelsPreview.length,
modelsPreview,
status,
quota: row.quota ?? null,
expiresAt: row.expiresAt ?? row.expires_at ?? null,
tokenExpiresAt: row.tokenExpiresAt ?? row.token_expires_at ?? null,
routeChannelCount: Number.isFinite(routeChannelCount) ? routeChannelCount : undefined,
lastModelSyncAt: row.lastModelSyncAt ?? row.last_model_sync_at ?? null,
lastModelSyncError: row.lastModelSyncError ?? row.last_model_sync_error ?? null,
proxyUrl: row.proxyUrl ?? row.proxy_url ?? null,
useSystemProxy: row.useSystemProxy ?? row.use_system_proxy ?? false,
routeUnit: row.routeUnit ?? row.route_unit ?? null,
routeParticipation: row.routeParticipation ?? row.route_participation ?? null,
site: rawSite
? {
id: Number(rawSite.id ?? rawSite.siteId ?? rawSite.site_id ?? siteId) || 0,
name: String(rawSite.name ?? rawSite.siteName ?? rawSite.site_name ?? "").trim(),
url: String(rawSite.url ?? rawSite.siteUrl ?? rawSite.site_url ?? "").trim(),
platform: String(rawSite.platform ?? provider).trim(),
}
: null,
};
}
function normalizeOAuthConnectionsResponse(response: any): OAuthConnectionsResponse {
const wrapped = wrapItemsResponse(response);
const items = unwrapArrayResponse(wrapped)
.map(normalizeOAuthConnectionRow)
.filter((item): item is OAuthConnectionInfo => item != null);
return {
...wrapped,
items,
total: Number(wrapped.total ?? items.length),
limit: Number(wrapped.limit ?? items.length),
offset: Number(wrapped.offset ?? 0),
};
}
function cleanOAuthProviderText(value: unknown): string {
if (typeof value !== "string") return "";
const text = value.trim();
const lowerText = text.toLowerCase();
if (lowerText === "undefined" || lowerText === "null") return "";
return text;
}
function resolveOAuthProviderFallbackLabel(providerKey: string): string {
const normalized = providerKey.trim().toLowerCase();
if (normalized === "codex") return "Codex";
if (normalized === "openai") return "OpenAI";
if (normalized === "anthropic") return "Anthropic";
if (normalized === "claude") return "Claude";
if (normalized === "google") return "Google AI";
if (normalized === "gemini-cli" || normalized === "gemini") return "Gemini CLI";
if (normalized === "antigravity") return "Antigravity";
return providerKey;
}
function normalizeOAuthProviderRow(row: any): OAuthProviderInfo | null {
if (!row || typeof row !== "object") return null;
const provider = cleanOAuthProviderText(row.provider)
|| cleanOAuthProviderText(row.oauthProvider)
|| cleanOAuthProviderText(row.oauth_provider)
|| cleanOAuthProviderText(row.id)
|| cleanOAuthProviderText(row.key)
|| cleanOAuthProviderText(row.value)
|| cleanOAuthProviderText(row.type);
if (!provider) return null;
const label = cleanOAuthProviderText(row.label)
|| cleanOAuthProviderText(row.name)
|| cleanOAuthProviderText(row.displayName)
|| cleanOAuthProviderText(row.display_name)
|| resolveOAuthProviderFallbackLabel(provider);
const platform = cleanOAuthProviderText(row.platform)
|| cleanOAuthProviderText(row.type)
|| provider;
return {
provider,
label,
platform,
enabled: row.enabled !== false,
loginType: "oauth",
requiresProjectId: row.requiresProjectId === true || row.requires_project_id === true,
supportsDirectAccountRouting: row.supportsDirectAccountRouting ?? row.supports_direct_account_routing ?? true,
supportsCloudValidation: row.supportsCloudValidation ?? row.supports_cloud_validation ?? true,
supportsNativeProxy: row.supportsNativeProxy ?? row.supports_native_proxy ?? true,
};
}
function normalizeOAuthProvidersResponse(response: any): OAuthProvidersResponse {
const rawProviders: unknown[] = Array.isArray(response?.providers) ? response.providers : [];
const providers = rawProviders
.map(normalizeOAuthProviderRow)
.filter((item): item is OAuthProviderInfo => item != null);
return {
...response,
providers: Array.from(
new Map(providers.map((provider) => [provider.provider, provider])).values(),
),
defaults: response?.defaults && typeof response.defaults === "object"
? response.defaults
: undefined,
};
}
async function streamSse(
url: string,
handlers: {
@@ -692,6 +915,8 @@ export type RuntimeSettingsPayload = {
barkUrl?: string;
webhookEnabled?: boolean;
barkEnabled?: boolean;
feishuEnabled?: boolean;
feishuWebhook?: string;
serverChanEnabled?: boolean;
serverChanKey?: string;
telegramEnabled?: boolean;
@@ -784,6 +1009,10 @@ 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 & {
@@ -808,6 +1037,7 @@ export type ProxyLogsQuery = {
search?: string;
client?: string;
siteId?: number;
downstreamKeyId?: number;
from?: string;
to?: string;
};
@@ -994,6 +1224,8 @@ 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;
@@ -1026,11 +1258,12 @@ export type OAuthQuotaBatchRefreshResponse = {
export type OAuthImportResponse = {
success: boolean;
imported: number;
updated?: number;
skipped: number;
failed: number;
items: Array<{
name: string;
status: "imported" | "skipped" | "failed";
status: "imported" | "updated" | "skipped" | "failed";
accountId?: number;
provider?: string;
message?: string;
@@ -1184,6 +1417,22 @@ 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 = {
@@ -1196,6 +1445,165 @@ 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 () => {
@@ -1225,12 +1633,14 @@ export const api = {
}),
getSiteAvailableModels: (siteId: number) =>
request(`/api/sites/${siteId}/available-models`),
probeSiteNow: (siteId: number, options?: { scope?: 'single' | 'all'; modelName?: string; latencyThresholdMs?: number }) =>
probeSiteNow: (siteId: number, options?: SiteProbeOptions) =>
request(`/api/sites/${siteId}/probe-now`, {
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 }) => {
@@ -1610,7 +2020,8 @@ export const api = {
// OAuth
getOAuthProviders: () =>
request("/api/oauth/providers") as Promise<OAuthProvidersResponse>,
request("/api/oauth/providers")
.then(normalizeOAuthProvidersResponse) as Promise<OAuthProvidersResponse>,
startOAuthProvider: (
provider: string,
data?: {
@@ -1639,14 +2050,14 @@ export const api = {
getOAuthConnections: (params?: { limit?: number; offset?: number }) =>
request(
`/api/oauth/connections${buildQueryString(params)}`,
) as Promise<OAuthConnectionsResponse>,
).then(normalizeOAuthConnectionsResponse) as Promise<OAuthConnectionsResponse>,
refreshOAuthConnectionQuota: (accountId: number) =>
request(`/api/oauth/connections/${accountId}/quota/refresh`, {
request(`/api/oauth/connections/${accountId}/refresh-quota`, {
method: "POST",
body: JSON.stringify({}),
}) as Promise<{ success: true; quota: OAuthQuotaInfo }>,
refreshOAuthConnectionQuotaBatch: (accountIds: number[]) =>
request("/api/oauth/connections/quota/refresh-batch", {
request("/api/oauth/connections/refresh-quota-batch", {
method: "POST",
body: JSON.stringify({ accountIds }),
}) as Promise<OAuthQuotaBatchRefreshResponse>,
@@ -1655,7 +2066,7 @@ export const api = {
data: { proxyUrl?: string | null; useSystemProxy?: boolean },
) =>
request(`/api/oauth/connections/${accountId}/proxy`, {
method: "PATCH",
method: "POST",
body: JSON.stringify(data || {}),
}) as Promise<{ success: true }>,
rebindOAuthConnection: (
@@ -1684,6 +2095,18 @@ 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",
@@ -1758,43 +2181,6 @@ 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",
@@ -1927,15 +2313,6 @@ 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;
@@ -2061,6 +2438,8 @@ 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) =>
@@ -2079,6 +2458,8 @@ 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",
@@ -2093,6 +2474,8 @@ 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",
@@ -2131,10 +2514,23 @@ 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; status?: string; date_from?: string; date_to?: string }) =>
getLogs: (params?: { limit?: number; offset?: number; page?: number; page_size?: number; q?: string; key_id?: number; key?: string; status?: string; date_from?: string; date_to?: string }) =>
userRequest(`/api/user/logs${buildQueryString(params)}`),
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;
@@ -2164,6 +2560,8 @@ 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) =>
@@ -2182,6 +2580,8 @@ 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",
@@ -2196,6 +2596,8 @@ 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",
+4
View File
@@ -1,11 +1,13 @@
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;
@@ -24,8 +26,10 @@ 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);
}
@@ -0,0 +1,73 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
function readSource(relativePath: string): string {
return readFileSync(resolve(process.cwd(), relativePath), 'utf8').replace(/\r\n/g, '\n');
}
describe('AppMotion animation contract', () => {
it('uses GSAP matchMedia so page transitions respect reduced motion and viewport density', () => {
const source = readSource('src/web/components/AppMotion.tsx');
expect(source).toContain("import { gsap } from 'gsap';");
expect(source).toContain('const mm = gsap.matchMedia();');
expect(source).toContain("reduceMotion: '(prefers-reduced-motion: reduce)'");
expect(source).toContain("isDesktop: '(min-width: 900px)'");
expect(source).toContain('if (conditions.reduceMotion) {');
expect(source).toContain("gsap.set([root, ...targets], { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });");
expect(source).toContain('return () => mm.revert();');
});
it('keeps route/login entrance motion scoped, subtle, and cleaned up on unmount', () => {
const source = readSource('src/web/components/AppMotion.tsx');
expect(source).toContain('motionVariant === \'login\' ? 14 : (conditions.isDesktop ? 26 : 16)');
expect(source).toContain('const timeline = gsap.timeline({');
expect(source).toContain("ease: 'power2.out'");
expect(source).toContain("overwrite: 'auto'");
expect(source).toContain('timeline.fromTo(');
expect(source).toContain("stagger: {\n each: motionVariant === 'login' ? 0.045 : 0.026,");
expect(source).toContain('timeline.kill();');
expect(source).toContain('gsap.killTweensOf([root, ...targets]);');
expect(source).toContain("gsap.set([root, ...targets], { clearProps: 'transform,opacity,visibility' });");
});
it('includes the newer dashboard operations blocks in the GSAP entrance target set', () => {
const source = readSource('src/web/components/AppMotion.tsx');
expect(source).toContain("':scope > .dashboard-war-room'");
expect(source).toContain("':scope > .dashboard-command-strip'");
expect(source).toContain("':scope .dashboard-pulse-card'");
expect(source).toContain("':scope .dashboard-risk-panel'");
expect(source).toContain("':scope .dashboard-command-item'");
expect(source).toContain("':scope .ops-monitor-log-card'");
expect(source).toContain("':scope .responsive-batch-action-bar'");
});
it('keeps responsive batch action bars reachable by the GSAP selector', () => {
const mobileBatchBar = readSource('src/web/components/MobileBatchBar.tsx');
const responsiveBatchBar = readSource('src/web/components/ResponsiveBatchActionBar.tsx');
expect(mobileBatchBar).toContain('responsive-batch-action-bar');
expect(responsiveBatchBar).toContain('responsive-batch-action-bar');
expect(mobileBatchBar).toContain('className="mobile-actions-info" role="status" aria-live="polite"');
expect(responsiveBatchBar).toContain('<span role="status" aria-live="polite"');
});
it('keeps high-frequency overlay motion reduced-motion friendly', () => {
const css = readSource('src/web/index.css');
expect(css).toContain('@media (prefers-reduced-motion: reduce) {');
expect(css).toContain('.user-dropdown,\n .user-dropdown.is-closing');
expect(css).toContain('.modal-backdrop,\n .modal-backdrop.is-closing');
expect(css).toContain('.mobile-drawer-panel,\n .mobile-drawer-panel-right');
expect(css).toContain('.filter-panel.filter-collapsible,\n .filter-panel.filter-collapsible.is-closing');
expect(css).toContain('animation: none !important;');
expect(css).toContain('transition: none !important;');
expect(css).toContain('transform: none !important;');
expect(css).toContain('.user-dropdown.is-closing,\n .modal-backdrop.is-closing');
expect(css).not.toContain('.main-content.is-route-transitioning');
expect(css).not.toContain('.route-progress');
});
});
+175
View File
@@ -0,0 +1,175 @@
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>
);
}
+1 -1
View File
@@ -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'] });
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-theme-preset'] });
return () => observer.disconnect();
}, []);
return isDark ? ICON_CDN : ICON_CDN_LIGHT;
+60 -4
View File
@@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
import React, { useEffect, useId, useRef } from 'react';
import { createPortal } from 'react-dom';
import { trapFocusInContainer } from './focusTrap.js';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
type CenteredModalProps = {
@@ -28,6 +29,9 @@ 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'
@@ -43,9 +47,52 @@ export default function CenteredModal({
}, [canUsePortal, open]);
useEffect(() => {
if (!open || !closeOnEscape || !canUsePortal) return;
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;
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
if (event.key === 'Escape' && closeOnEscape) {
onClose();
return;
}
trapFocusInContainer(event, dialogRef.current);
};
document.addEventListener('keydown', handleKeydown);
return () => {
@@ -55,24 +102,33 @@ 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 className="modal-title">{title}</div>
<div id={titleId} className="modal-title">{title}</div>
{showCloseButton ? (
<button
type="button"
className="modal-close-button"
onClick={onClose}
aria-label="关闭弹框"
tabIndex={interactive ? 0 : -1}
>
×
</button>
+54 -66
View File
@@ -1,12 +1,10 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import React, { useState } from 'react';
import { api } from '../api.js';
import { useToast } from './Toast.js';
import { commitSession } from '../authSession.js';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
import CenteredModal from './CenteredModal.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('');
@@ -14,15 +12,6 @@ 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) {
@@ -58,67 +47,66 @@ 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)',
};
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
type="password"
value={oldToken}
onChange={e => { setOldToken(e.target.value); setError(''); }}
placeholder="输入当前 Token"
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>新 Token</label>
<input
type="password"
value={newToken}
onChange={e => { setNewToken(e.target.value); setError(''); }}
placeholder="输入新 Token (至少 6 位)"
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>确认新 Token</label>
<input
type="password"
value={confirmToken}
onChange={e => { setConfirmToken(e.target.value); setError(''); }}
placeholder="再次输入新 Token"
style={inputStyle}
/>
</div>
{error && (
<div className="alert alert-error">
{error}
</div>
)}
</div>
<div className="modal-footer">
<button onClick={onClose} className="btn btn-ghost">取消</button>
<button onClick={handleSubmit} disabled={saving} className="btn btn-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>
</div>
</>
)}
>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>旧 Token</label>
<input
type="password"
value={oldToken}
onChange={e => { setOldToken(e.target.value); setError(''); }}
placeholder="输入当前 Token"
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>新 Token</label>
<input
type="password"
value={newToken}
onChange={e => { setNewToken(e.target.value); setError(''); }}
placeholder="输入新 Token (至少 6 位)"
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>确认新 Token</label>
<input
type="password"
value={confirmToken}
onChange={e => { setConfirmToken(e.target.value); setError(''); }}
placeholder="再次输入新 Token"
style={inputStyle}
/>
</div>
</div>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
{error && (
<div className="alert alert-error">
{error}
</div>
)}
</CenteredModal>
);
}
+3 -2
View File
@@ -28,11 +28,12 @@ export default function DeleteConfirmModal({
onClose={onClose}
title={title}
maxWidth={560}
closeOnEscape
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={(
<>
<button onClick={onClose} className="btn btn-ghost" disabled={loading}>{cancelText}</button>
<button onClick={onConfirm} className="btn btn-danger" disabled={loading}>
<button type="button" onClick={onClose} className="btn btn-ghost" disabled={loading}>{cancelText}</button>
<button type="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}
@@ -0,0 +1,90 @@
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();
});
});
@@ -0,0 +1,95 @@
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>
);
}
@@ -0,0 +1,38 @@
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('当前已加载路由');
});
});
+106
View File
@@ -0,0 +1,106 @@
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>
);
}
+4 -2
View File
@@ -7,8 +7,10 @@ type MobileBatchBarProps = {
export default function MobileBatchBar({ info, children }: MobileBatchBarProps) {
return (
<div className="mobile-actions-bar mobile-batch-bar">
<span className="mobile-actions-info">{info}</span>
<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-row">{children}</div>
</div>
);
+64 -22
View File
@@ -1,5 +1,7 @@
import React, { useEffect, useId, useMemo, useState, useCallback } from 'react';
import React, { useCallback, useEffect, useId, useRef } from 'react';
import { createPortal } from 'react-dom';
import { trapFocusInContainer } from './focusTrap.js';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
type MobileDrawerProps = {
open: boolean;
@@ -8,6 +10,7 @@ type MobileDrawerProps = {
title?: React.ReactNode;
closeLabel?: string;
side?: 'left' | 'right';
panelId?: string;
};
function MobileDrawer({
@@ -17,28 +20,15 @@ function MobileDrawer({
title,
closeLabel = '关闭导航',
side = 'left',
panelId,
}: MobileDrawerProps) {
const [shouldRender, setShouldRender] = useState(open);
const [isClosing, setIsClosing] = useState(false);
const presence = useAnimatedVisibility(open, 280);
const titleId = useId();
const labelledBy = title ? titleId : undefined;
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 panelRef = useRef<HTMLDivElement | null>(null);
const lastActiveElementRef = useRef<HTMLElement | null>(null);
const handleClose = useCallback(() => {
setIsClosing(true);
onClose();
}, [onClose]);
@@ -51,12 +41,53 @@ 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 () => {
@@ -64,23 +95,34 @@ function MobileDrawer({
};
}, [handleClose, open]);
if (!shouldRender) return null;
if (!presence.shouldRender) return null;
const interactive = open && presence.isVisible;
const drawer = (
<div className={`mobile-drawer-root ${isClosing ? 'is-closing' : ''}`}>
<div className={`mobile-drawer-root ${presence.isVisible ? '' : 'is-closing'}`.trim()}>
<div
className="mobile-drawer-backdrop"
onClick={handleClose}
aria-hidden="true"
/>
<div className={`mobile-drawer-panel mobile-drawer-panel-${side}`} role="dialog" aria-modal="true" aria-labelledby={labelledBy}>
<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-toolbar">
{title ? (
<div className="mobile-drawer-title" id={titleId}>
{title}
</div>
) : <div />}
<button type="button" className="mobile-drawer-close" onClick={handleClose} aria-label={closeLabel}>
<button type="button" className="mobile-drawer-close" onClick={handleClose} aria-label={closeLabel} tabIndex={interactive ? 0 : -1}>
×
</button>
</div>
+3 -1
View File
@@ -6,6 +6,7 @@ type MobileFilterSheetProps = {
onClose: () => void;
title?: React.ReactNode;
children: React.ReactNode;
panelId?: string;
};
export default function MobileFilterSheet({
@@ -13,9 +14,10 @@ export default function MobileFilterSheet({
onClose,
title = '筛选',
children,
panelId,
}: MobileFilterSheetProps) {
return (
<MobileDrawer open={open} onClose={onClose} title={title} closeLabel="关闭筛选">
<MobileDrawer open={open} onClose={onClose} title={title} closeLabel="关闭筛选" panelId={panelId}>
<div className="mobile-filter-panel">
{children}
</div>
@@ -1,4 +1,6 @@
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';
@@ -17,6 +19,27 @@ 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;
@@ -66,6 +89,151 @@ 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: {},
+187 -134
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useMemo, useRef, useState, type KeyboardEvent } from 'react';
import { VChart } from '@visactor/react-vchart';
import { InlineBrandIcon } from './BrandIcon.js';
import { formatCompactTokenMetric } from '../numberFormat.js';
@@ -64,7 +64,15 @@ 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),
@@ -119,10 +127,38 @@ 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 style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 20 }}>
<div className="model-analysis-summary-grid">
<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>
@@ -139,12 +175,21 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
{/* Pill Tabs */}
<div style={{ marginBottom: 16 }}>
<div className="pill-tabs">
<div className="pill-tabs" role="tablist" aria-label="模型分析视图" onKeyDown={handleTabKeyDown}>
{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' : ''}`}
onClick={() => setActiveTab(tab.key)}
role="tab"
aria-selected={activeTab === tab.key}
aria-controls={`model-analysis-panel-${tab.key}`}
tabIndex={activeTab === tab.key ? 0 : -1}
onClick={() => activateTab(tab.key)}
>
<span className="pill-tab-code">{tab.code}</span>
{tab.label}
@@ -154,144 +199,152 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
</div>
{/* Chart Content */}
{activeTab === 'spend' && (
<div>
<div style={{ height: 300 }}>
<VChart spec={spendBarSpec} />
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', marginTop: 10, padding: '0 4px' }}>
{spendDistribution.map(d => (
<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 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>
))}
</div>
</div>
)}
{activeTab === 'trend' && (
<div style={{ height: 300 }}>
<VChart spec={trendSpec} />
</div>
)}
{activeTab === 'calls' && (
<div>
<div style={{ height: 300 }}>
<VChart spec={callsPieSpec} />
</div>
<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} 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 }} />
<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">
{spendDistribution.map(d => (
<span key={d.model} className="model-analysis-legend-item">
<InlineBrandIcon model={d.model} size={13} />
<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 className="model-analysis-legend-name">{d.model}</span>
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{formatCurrency(d.spend)}</span>
</span>
);
})}
))}
</div>
</div>
</div>
)}
)}
{activeTab === 'rank' && (
<div style={{ overflow: 'hidden', border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-md)' }}>
<table className="data-table" style={{ width: '100%' }}>
<thead>
<tr>
<th style={{ width: 36, textAlign: 'center' }}>#</th>
<th>模型</th>
<th style={{ textAlign: 'center' }}>调用</th>
<th style={{ textAlign: 'center' }}>成功率</th>
<th style={{ textAlign: 'center' }}>平均延迟</th>
<th style={{ textAlign: 'right' }}>消耗</th>
</tr>
</thead>
<tbody>
{callRanking.map((item, index) => {
const latMs = item.avgLatencyMs;
const latSec = latMs / 1000;
// ≤15s green, 15-60s gradient green→yellow→red, >60s or failed → red
let latColor: string;
let latBg: string;
if (latSec <= 15) {
// green gradient: 0s=#22c55e → 15s=blend towards yellow
const t = Math.min(latSec / 15, 1);
const r = Math.round(34 + t * (245 - 34));
const g = Math.round(197 + t * (158 - 197));
const b = Math.round(94 + t * (11 - 94));
latColor = `rgb(${r},${g},${b})`;
latBg = `rgba(${r},${g},${b},0.08)`;
} else if (latSec <= 60) {
// yellow→red gradient: 15s=#f59e0b → 60s=#ef4444
const t = Math.min((latSec - 15) / 45, 1);
const r = Math.round(245 + t * (239 - 245));
const g = Math.round(158 + t * (68 - 158));
const b = Math.round(11 + t * (68 - 11));
latColor = `rgb(${r},${g},${b})`;
latBg = `rgba(${r},${g},${b},0.08)`;
} else {
latColor = '#ef4444';
latBg = 'rgba(239,68,68,0.08)';
}
const latText = latMs >= 1000 ? `${(latMs / 1000).toFixed(latSec >= 60 ? 0 : 1)}s` : `${latMs}ms`;
const rateColor = item.successRate >= 90 ? '#16a34a' : item.successRate >= 60 ? '#d97706' : '#dc2626';
const rateBg = item.successRate >= 90 ? 'rgba(34,197,94,0.1)' : item.successRate >= 60 ? 'rgba(245,158,11,0.1)' : 'rgba(239,68,68,0.1)';
{activeTab === 'trend' && (
<div style={{ height: 300 }}>
<VChart spec={trendSpec} />
</div>
)}
{activeTab === 'calls' && (
<div>
<div style={{ height: 300 }}>
<VChart spec={callsPieSpec} />
</div>
<div className="model-analysis-legend">
{callsDistribution.map((d, idx) => {
const pieColors = ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
return (
<tr key={item.model}>
<td style={{ textAlign: 'center', padding: '8px 4px' }}>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 22, height: 22, borderRadius: 6, fontSize: 11, fontWeight: 700,
background: index < 3
? ['linear-gradient(135deg,#fbbf24,#f59e0b)', 'linear-gradient(135deg,#94a3b8,#cbd5e1)', 'linear-gradient(135deg,#d97706,#fbbf24)'][index]
: 'var(--color-bg)',
color: index < 3 ? '#fff' : 'var(--color-text-muted)',
}}>
{index + 1}
</span>
</td>
<td>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<InlineBrandIcon model={item.model} size={14} />
<code style={{ fontSize: 12, fontWeight: 500 }}>{item.model}</code>
</span>
</td>
<td style={{ textAlign: 'center', fontWeight: 600, fontVariantNumeric: 'tabular-nums', fontSize: 13 }}>
{Math.round(item.calls).toLocaleString()}
</td>
<td style={{ textAlign: 'center' }}>
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 600,
background: rateBg, color: rateColor,
}}>
{formatPercent(item.successRate)}
</span>
</td>
<td style={{ textAlign: 'center' }}>
<span style={{
fontVariantNumeric: 'tabular-nums', fontSize: 12, fontWeight: 600,
color: latColor, background: latBg,
padding: '2px 8px', borderRadius: 4,
}}>
{latText}
</span>
</td>
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 500, fontSize: 13 }}>
{formatCurrency(item.spend)}
</td>
</tr>
<span key={d.model} className="model-analysis-legend-item">
<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={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{d.calls}</span>
</span>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{activeTab === 'rank' && (
<div style={{ overflow: 'hidden', border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-md)' }}>
<table className="data-table" style={{ width: '100%' }}>
<thead>
<tr>
<th style={{ width: 36, textAlign: 'center' }}>#</th>
<th>模型</th>
<th style={{ textAlign: 'center' }}>调用</th>
<th style={{ textAlign: 'center' }}>成功率</th>
<th style={{ textAlign: 'center' }}>平均延迟</th>
<th style={{ textAlign: 'right' }}>消耗</th>
</tr>
</thead>
<tbody>
{callRanking.map((item, index) => {
const latMs = item.avgLatencyMs;
const latSec = latMs / 1000;
// ≤15s green, 15-60s gradient green→yellow→red, >60s or failed → red
let latColor: string;
let latBg: string;
if (latSec <= 15) {
// green gradient: 0s=#22c55e → 15s=blend towards yellow
const t = Math.min(latSec / 15, 1);
const r = Math.round(34 + t * (245 - 34));
const g = Math.round(197 + t * (158 - 197));
const b = Math.round(94 + t * (11 - 94));
latColor = `rgb(${r},${g},${b})`;
latBg = `rgba(${r},${g},${b},0.08)`;
} else if (latSec <= 60) {
// yellow→red gradient: 15s=#f59e0b → 60s=#ef4444
const t = Math.min((latSec - 15) / 45, 1);
const r = Math.round(245 + t * (239 - 245));
const g = Math.round(158 + t * (68 - 158));
const b = Math.round(11 + t * (68 - 11));
latColor = `rgb(${r},${g},${b})`;
latBg = `rgba(${r},${g},${b},0.08)`;
} else {
latColor = '#ef4444';
latBg = 'rgba(239,68,68,0.08)';
}
const latText = latMs >= 1000 ? `${(latMs / 1000).toFixed(latSec >= 60 ? 0 : 1)}s` : `${latMs}ms`;
const rateColor = item.successRate >= 90 ? '#16a34a' : item.successRate >= 60 ? '#d97706' : '#dc2626';
const rateBg = item.successRate >= 90 ? 'rgba(34,197,94,0.1)' : item.successRate >= 60 ? 'rgba(245,158,11,0.1)' : 'rgba(239,68,68,0.1)';
return (
<tr key={item.model}>
<td style={{ textAlign: 'center', padding: '8px 4px' }}>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 22, height: 22, borderRadius: 6, fontSize: 11, fontWeight: 700,
background: index < 3
? ['linear-gradient(135deg,#fbbf24,#f59e0b)', 'linear-gradient(135deg,#94a3b8,#cbd5e1)', 'linear-gradient(135deg,#d97706,#fbbf24)'][index]
: 'var(--color-bg)',
color: index < 3 ? '#fff' : 'var(--color-text-muted)',
}}>
{index + 1}
</span>
</td>
<td>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<InlineBrandIcon model={item.model} size={14} />
<code style={{ fontSize: 12, fontWeight: 500 }}>{item.model}</code>
</span>
</td>
<td style={{ textAlign: 'center', fontWeight: 600, fontVariantNumeric: 'tabular-nums', fontSize: 13 }}>
{Math.round(item.calls).toLocaleString()}
</td>
<td style={{ textAlign: 'center' }}>
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 600,
background: rateBg, color: rateColor,
}}>
{formatPercent(item.successRate)}
</span>
</td>
<td style={{ textAlign: 'center' }}>
<span style={{
fontVariantNumeric: 'tabular-nums', fontSize: 12, fontWeight: 600,
color: latColor, background: latBg,
padding: '2px 8px', borderRadius: 4,
}}>
{latText}
</span>
</td>
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 500, fontSize: 13 }}>
{formatCurrency(item.spend)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
+281 -16
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import ModernSelect from './ModernSelect.js';
@@ -17,6 +17,33 @@ 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(
@@ -53,11 +80,7 @@ describe('ModernSelect', () => {
/>,
);
const trigger = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-trigger')
));
const trigger = findTrigger(root);
await act(async () => {
trigger.props.onClick();
@@ -72,22 +95,264 @@ describe('ModernSelect', () => {
searchInput.props.onChange({ target: { value: 'codex' } });
});
let optionButtons = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-option')
));
let optionButtons = findOptionButtons(root);
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 = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-option')
));
optionButtons = findOptionButtons(root);
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');
});
});
+189 -19
View File
@@ -1,13 +1,21 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from 'react';
type ModernSelectOption = {
value: string;
label: string;
description?: string;
label?: string | null;
description?: string | null;
disabled?: boolean;
iconNode?: ReactNode;
iconUrl?: string;
iconText?: string;
iconUrl?: string | null;
iconText?: string | null;
};
type ModernSelectProps = {
@@ -25,6 +33,14 @@ 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,
@@ -41,18 +57,54 @@ 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(
() => options.find((item) => item.value === value),
[options, value],
() => normalizedOptions.find((item) => item.value === value),
[normalizedOptions, value],
);
const visibleOptions = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!searchable || !query) return options;
if (!searchable || !query) return normalizedOptions;
return options.filter((item) => {
return normalizedOptions.filter((item) => {
const haystack = [
item.label,
item.description,
@@ -63,7 +115,31 @@ export default function ModernSelect({
.toLowerCase();
return haystack.includes(query);
});
}, [options, searchQuery, searchable]);
}, [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]);
useEffect(() => {
if (!open) return;
@@ -76,7 +152,7 @@ export default function ModernSelect({
}
};
const handleEscape = (event: KeyboardEvent) => {
const handleEscape = (event: globalThis.KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
@@ -111,6 +187,77 @@ 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}
@@ -121,9 +268,19 @@ export default function ModernSelect({
type="button"
className="modern-select-trigger"
onClick={() => {
if (!disabled) setOpen((prev) => !prev);
if (!disabled) {
setOpen((prev) => {
if (prev) setHighlightedIndex(-1);
return !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()}>
@@ -148,15 +305,23 @@ export default function ModernSelect({
</svg>
</button>
<div className="modern-select-panel" style={{ maxHeight: menuMaxHeight }}>
<div
id={panelId}
className="modern-select-panel"
style={{ maxHeight: menuMaxHeight }}
role="listbox"
aria-hidden={!open}
>
{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>
)}
@@ -164,19 +329,24 @@ export default function ModernSelect({
{visibleOptions.length === 0 ? (
<div className="modern-select-empty">{emptyLabel}</div>
) : (
visibleOptions.map((item) => {
visibleOptions.map((item, index) => {
const active = item.value === value;
return (
<button
key={item.value}
id={`${panelId}-option-${index}`}
key={`${item.value || 'option'}-${index}`}
type="button"
className={`modern-select-option ${active ? 'is-active' : ''} ${item.disabled ? 'is-disabled' : ''}`.trim()}
className={`modern-select-option ${active ? 'is-active' : ''} ${highlightedIndex === index ? 'is-highlighted' : ''} ${item.disabled ? 'is-disabled' : ''}`.trim()}
role="option"
aria-selected={active}
onClick={() => {
if (item.disabled) return;
onChange(item.value);
setOpen(false);
selectOption(index);
}}
onMouseEnter={() => {
if (!item.disabled) setHighlightedIndex(index);
}}
disabled={item.disabled}
tabIndex={open && !item.disabled ? 0 : -1}
>
<div className="modern-select-option-main">
{renderOptionIcon(item)}
+273 -27
View File
@@ -1,10 +1,11 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { api, userApi } 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)',
@@ -19,48 +20,220 @@ 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}` : '';
const data = await api.getEvents(params);
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;
setEvents(data);
// Auto mark all as read on open
const hasUnread = Array.isArray(data) && data.some((e: any) => !e.read);
if (hasUnread) {
api.markAllEventsRead().catch(() => {});
if (audience === 'user') {
userApi.markAllAnnouncementsRead().catch(() => {});
} else {
api.markAllEventsRead().catch(() => {});
}
onUnreadCountChange?.(0);
}
} catch { /* ignore */ }
finally { setLoading(false); }
}, [filter, onUnreadCountChange]);
finally {
if (mountedRef.current && openRef.current && loadSeqRef.current === loadSeq) {
setLoading(false);
}
}
}, [audience, filter, onUnreadCountChange]);
useEffect(() => () => {
mountedRef.current = false;
loadSeqRef.current += 1;
}, []);
useEffect(() => {
if (open) load();
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();
}, [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 (
@@ -74,54 +247,109 @@ 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 () => {
await api.clearEvents();
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 ${presence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ right: 0, top: '100%', width: 360, maxHeight: 480, padding: 0, marginTop: 4 }}
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);
}
}}
>
{/* 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 onClick={clearAll} className="btn btn-link">
<button type="button" onClick={clearAll} className="btn btn-link" tabIndex={interactive ? 0 : -1}>
{tr('清空')}
</button>
</div>
{/* Filters */}
<div style={{ display: 'flex', gap: 4, padding: '8px 12px', borderBottom: '1px solid var(--color-border-light)', flexWrap: 'wrap' }}>
{['', 'checkin', 'balance', 'token', 'proxy', 'status', 'site_notice'].map((filterType) => (
<button key={filterType} onClick={() => setFilter(filterType)}
style={{
fontSize: 11, padding: '3px 8px', borderRadius: 12,
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',
}}>
{filterType ? tr(typeLabels[filterType] || filterType) : tr('全部')}
</button>
))}
{(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)}
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)',
cursor: 'pointer',
}}
>
{filterLabel}
</button>
);
})}
</div>
{/* Events list */}
<div style={{ maxHeight: 360, overflow: 'auto' }}>
<div role="list" aria-busy={loading} aria-live="polite" className="notification-panel-list">
{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) => {
const targetPath = buildEventNavigationPath(ev);
{events.map((ev: any, index: number) => {
const targetPath = audience === 'user'
? buildUserNotificationNavigationPath(ev)
: buildEventNavigationPath(ev);
const diagnosticActions = buildNotificationDiagnosticActions(ev, targetPath);
const active = activeEventIndex === index;
const openTarget = () => {
onClose();
navigate(targetPath);
@@ -129,7 +357,10 @@ export default function NotificationPanel({
return (
<div
key={ev.id}
className="notification-event-item"
ref={(node) => {
eventRefs.current[index] = node;
}}
className={`notification-event-item ${active ? 'is-active' : ''}`.trim()}
style={{
padding: '10px 16px',
borderBottom: '1px solid var(--color-border-light)',
@@ -140,8 +371,18 @@ export default function NotificationPanel({
}}
onClick={openTarget}
role="button"
tabIndex={0}
tabIndex={interactive ? 0 : -1}
aria-current={active ? 'true' : undefined}
onMouseEnter={() => {
focusActiveEventRef.current = false;
setActiveEventIndex(index);
}}
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();
@@ -163,6 +404,11 @@ 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>
);
+32
View File
@@ -0,0 +1,32 @@
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,8 +11,12 @@ describe('ResponsiveBatchActionBar', () => {
</ResponsiveBatchActionBar>,
);
const bar = root.root.find((node) => node.props.className === 'mobile-actions-bar mobile-batch-bar');
const bar = root.root.find((node) => String(node.props.className || '').includes('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('批量启用');
});
@@ -23,8 +27,11 @@ describe('ResponsiveBatchActionBar', () => {
</ResponsiveBatchActionBar>,
);
const card = root.root.find((node) => node.props.className === 'card');
const card = root.root.find((node) => String(node.props.className || '').includes('responsive-batch-action-bar'));
expect(card).toBeTruthy();
expect(root.root.findAllByType('span').some((node) => node.children.includes('已选 3 项'))).toBe(true);
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');
});
});
@@ -34,8 +34,11 @@ export default function ResponsiveBatchActionBar({
}
return (
<div className="card" style={{ ...DEFAULT_DESKTOP_STYLE, ...desktopStyle }}>
<span style={{ ...DEFAULT_INFO_STYLE, ...infoStyle }}>{info}</span>
<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>
{children}
</div>
);
+6 -1
View File
@@ -32,6 +32,8 @@ export default function ResponsiveFilterPanel({
mobileTriggerButtonClassName = 'btn btn-ghost',
mobileTriggerButtonStyle = { border: '1px solid var(--color-border)' },
}: ResponsiveFilterPanelProps) {
const mobilePanelId = React.useId();
if (!isMobile) {
return <>{desktopContent}</>;
}
@@ -44,13 +46,16 @@ 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}>
<MobileFilterSheet open={mobileOpen} onClose={onMobileClose} title={mobileTitle} panelId={mobilePanelId}>
{mobileContent}
</MobileFilterSheet>
</>
+271 -37
View File
@@ -1,9 +1,10 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useId, type KeyboardEvent as ReactKeyboardEvent } 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 {
@@ -65,25 +66,106 @@ interface SearchResult {
models: ModelSearchResult[];
}
export default function SearchModal({ open, onClose }: { open: boolean; onClose: () => void }) {
type SearchResultAction = {
key: string;
path: string;
};
export default function SearchModal({
open,
onClose,
dialogId,
}: {
open: boolean;
onClose: () => void;
dialogId?: string;
}) {
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>();
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]);
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);
setTimeout(() => inputRef.current?.focus(), 100);
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 });
}
lastActiveElementRef.current = null;
}
}, [open]);
}, [clearFocusTimer, clearSearchTimer, open]);
const doSearch = useCallback(async (q: string) => {
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) => {
if (!q.trim()) {
setResults(null);
return;
@@ -92,6 +174,7 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
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 : [],
@@ -100,17 +183,25 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
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 {
setLoading(false);
if (mountedRef.current && openRef.current && searchSeqRef.current === searchSeq) setLoading(false);
}
}, []);
const handleInput = (val: string) => {
setQuery(val);
clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => doSearch(val), 300);
focusActiveResultRef.current = false;
setActiveResultIndex(-1);
clearSearchTimer();
const searchSeq = searchSeqRef.current;
timerRef.current = window.setTimeout(() => {
timerRef.current = null;
void doSearch(val, searchSeq);
}, 300);
};
const goTo = (path: string) => {
@@ -119,8 +210,24 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
};
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 (e.key === 'Escape' && open) onClose();
if (!open) return;
if (e.key === 'Escape') {
onClose();
return;
}
trapFocusInContainer(e, dialogRef.current);
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
@@ -128,6 +235,8 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
if (!presence.shouldRender) return null;
const interactive = open && presence.isVisible;
const hasResults = results && (
results.models.length
|| results.accounts.length
@@ -136,11 +245,103 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|| 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 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)' }}>
<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>
<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>
@@ -148,6 +349,12 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
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)' }}
/>
@@ -155,7 +362,7 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
<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 style={{ maxHeight: 400, overflow: 'auto', padding: '8px 0' }}>
<div id={resultsId} role="listbox" aria-hidden={!interactive} aria-busy={loading} aria-live="polite" className="search-modal-results">
{query && !loading && !hasResults && (
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 13 }}>
{t('没有找到匹配结果')}
@@ -165,8 +372,12 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
{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) => (
<button key={m.name} className="search-result-item" onClick={() => goTo(`/models?q=${encodeURIComponent(m.name)}`)}>
{results.models.map((m) => {
const action = resultActions[resultIndex];
const props = buildResultProps(action, resultIndex);
resultIndex += 1;
return (
<button type="button" key={m.name} {...props}>
<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>
@@ -177,15 +388,20 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
</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) => (
<button key={s.id} className="search-result-item" onClick={() => goTo(buildSiteFocusPath(s.id))}>
{results.sites.map((s) => {
const action = resultActions[resultIndex];
const props = buildResultProps(action, resultIndex);
resultIndex += 1;
return (
<button type="button" key={s.id} {...props}>
<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>
@@ -194,21 +410,23 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
<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) => (
{results.accounts.map((a) => {
const action = resultActions[resultIndex];
const props = buildResultProps(action, resultIndex);
resultIndex += 1;
return (
<button
type="button"
key={a.id}
className="search-result-item"
onClick={() => goTo(buildAccountFocusPath(a.id, {
openRebind: a.status === 'expired',
segment: a.segment,
}))}
{...props}
>
<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" />
@@ -225,18 +443,23 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
</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) => (
{results.accountTokens.map((token) => {
const action = resultActions[resultIndex];
const props = buildResultProps(action, resultIndex);
resultIndex += 1;
return (
<button
type="button"
key={token.id}
className="search-result-item"
onClick={() => goTo(buildTokenFocusPath(token.id))}
{...props}
>
<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" />
@@ -251,15 +474,20 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
</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) => (
<button key={l.id} className="search-result-item" onClick={() => goTo('/checkin')}>
{results.checkinLogs.map((l) => {
const action = resultActions[resultIndex];
const props = buildResultProps(action, resultIndex);
resultIndex += 1;
return (
<button type="button" key={l.id} {...props}>
<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>
@@ -270,15 +498,20 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
</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) => (
<button key={l.id} className="search-result-item" onClick={() => goTo('/logs')}>
{results.proxyLogs.map((l) => {
const action = resultActions[resultIndex];
const props = buildResultProps(action, resultIndex);
resultIndex += 1;
return (
<button type="button" key={l.id} {...props}>
<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>
@@ -289,12 +522,13 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
</div>
</div>
</button>
))}
);
})}
</div>
) : null}
</div>
<div style={{ padding: '8px 16px', borderTop: '1px solid var(--color-border-light)', fontSize: 11, color: 'var(--color-text-muted)', display: 'flex', gap: 12 }}>
<div className="search-modal-footer">
<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,6 +74,16 @@ 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 () => {
@@ -101,5 +111,6 @@ 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