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
344 changed files with 83681 additions and 9832 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
+91 -24
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,9 +230,9 @@ base_url = "http://127.0.0.1:8188"
backend = "boosapi"
```
### 将 Comfy 视频节点包装为 BossAI `/v1/videos`
### 将 Comfy 视频节点包装为 BoosAPI `/v1/videos`
ComfyUI/Comfy Cloud 没有 OpenAI 兼容 `/v1/videos`,但 BossAI 可以把一个已保存的视频工作流模板包装成统一视频模型:
ComfyUI/Comfy Cloud 没有 OpenAI 兼容 `/v1/videos`,但 BoosAPI 可以把一个已保存的视频工作流模板包装成统一视频模型:
1. 在工作流页面保存一个真实 Comfy 视频模板,模板需要能被 `POST /prompt` 执行。
2. 模板图里最好保存节点映射:
@@ -236,7 +248,7 @@ ComfyUI/Comfy Cloud 没有 OpenAI 兼容 `/v1/videos`,但 BossAI 可以把一
```bash
curl http://127.0.0.1:4000/v1/videos \
-H "Authorization: Bearer <BossAI downstream key>" \
-H "Authorization: Bearer <BoosAPI downstream key>" \
-H "Content-Type: application/json" \
-d '{
"model": "comfy-wan-video",
@@ -249,7 +261,7 @@ curl http://127.0.0.1:4000/v1/videos \
返回的 `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` 单独安装;生产构建会把可用资产嵌入 Rust 二进制。
> Workflow UI 有 cloud 与 external/runninghub 两套前端资产。开发环境可用 `npm run workflow-ui:install:cloud`、`npm run workflow-ui:install:external` 单独安装;安装脚本会写入 `boosapi-workflow-ui.json` 构建元数据,生产构建会把可用资产嵌入 Rust 二进制。若浏览器「关于」仍显示旧前端版本,请重启 Rust 服务并强刷浏览器缓存。
## 🚀 快速开始
@@ -275,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`,不要使用默认值。
@@ -325,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)
```
@@ -391,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 |
@@ -399,6 +413,8 @@ BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
> **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。
> 独立官网位于 `website/`。默认构建会把官网静态导出到 `dist/website/`,Rust 后端会让根路径 `/` 显示官网;管理台仍由 `dist/web/` 提供,管理员入口是 `/boos`,登录后进入 `/dashboard`。
---
## 🛠️ 本地开发
@@ -406,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
@@ -421,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:
@@ -435,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
@@ -456,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
@@ -469,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
+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");
}
+113 -51
View File
@@ -14,9 +14,7 @@ use crate::transformers::{
canonical::CanonicalRequest,
};
use crate::AppState;
use reqwest::Client;
use serde_json::{json, Value};
use std::time::Duration;
/// 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
@@ -532,7 +566,7 @@ pub async fn execute_video_task_status(
} else {
format!("/videos/{id}")
};
execute_proxy_get(route, &suffix).await
execute_proxy_get(state, route, &suffix).await
}
/// Execute an OpenAI-shaped video task request, with Volcengine Ark native
@@ -929,7 +963,7 @@ async fn comfy_json(
path: &str,
body: Option<Value>,
) -> AppResult<Value> {
let client = http_client();
let client = state.http.clone();
let mut request = client.request(method, comfy_native_url(state, path));
if let Some(api_key) = state
.config
@@ -963,16 +997,44 @@ async fn comfy_json(
}
async fn comfy_history(state: &AppState, prompt_id: &str) -> AppResult<Value> {
let history = comfy_json(state, reqwest::Method::GET, &format!("/history/{prompt_id}"), None).await?;
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 comfy_output_objects(&history) { "succeeded" } else { "running" },
"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) => {
@@ -988,11 +1050,11 @@ fn comfy_output_objects(value: &Value) -> bool {
/// 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
@@ -1045,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
+252 -8
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,17 +294,47 @@ 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 {
if is_degraded_latency {
match latency_ms {
Some(ms) => format!("最近代理请求成功但延迟偏高,用时 {}ms", ms),
None => "最近代理请求成功但延迟偏高".to_string(),
}
} else {
match latency_ms {
Some(ms) => format!("最近代理请求成功,用时 {}ms", ms),
None => "最近代理请求成功".to_string(),
}
}
} else {
error_message
.clone()
@@ -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"),
)
}
+76 -16
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,21 +37,9 @@ 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(_) => {
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")));
}
}
+130 -31
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) = 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/")
}
+4202 -113
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,
));
}
}
+11 -4
View File
@@ -32,6 +32,7 @@ struct PictureAgentAuth {
user_id: Option<i64>,
role: String,
outbound_key: String,
key_id: Option<i64>,
key_name: String,
}
@@ -123,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(),
});
}
@@ -148,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)),
@@ -225,7 +230,7 @@ async fn status_handler(
"role": auth.role,
"userId": auth.user_id,
"keyName": auth.key_name,
"upstreamLabel": "BossAI 本地图片路由",
"upstreamLabel": "BoosAPI 本地图片路由",
})))
}
@@ -655,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,
@@ -736,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,
+764 -329
View File
File diff suppressed because it is too large Load Diff
+701 -57
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,8 +735,15 @@ 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,
@@ -407,6 +752,7 @@ async fn proxy_logs(
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
l.error_message, l.retry_count, l.is_stream,
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
l.billing_details, l.content_summary, l.request_content, l.response_content,
l.created_at,
a.site_id AS site_id,
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
@@ -415,29 +761,51 @@ async fn proxy_logs(
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 ?
"#,
)
.bind(limit)
.bind(offset)
);
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();
.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?;
let mut summary_query = QueryBuilder::<Sqlite>::new(
r#"
SELECT
COUNT(l.id) AS total,
COALESCE(SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END), 0) AS success,
COALESCE(SUM(CASE WHEN l.status != 'success' OR l.status IS NULL THEN 1 ELSE 0 END), 0) AS failed,
COALESCE(SUM(l.estimated_cost), 0.0) AS cost,
COALESCE(SUM(l.total_tokens), 0) AS total_tokens
"#,
);
push_proxy_log_joined_from(&mut summary_query);
push_proxy_log_where(&mut summary_query, &filters);
let (total, success, failed, cost, total_tokens): (i64, i64, i64, f64, i64) =
summary_query.build_query_as().fetch_one(&*state.db).await?;
let (client_options, sites) = if include_meta {
(
build_proxy_log_client_options(&state, &filters).await?,
build_proxy_log_site_options(&state, &filters).await?,
)
} else {
(Vec::new(), Vec::new())
};
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,
})
},
))
}
+1643 -62
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(_))));
}
}
+202 -94
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,16 +4840,29 @@ 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);
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(
&state,
@@ -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!(
fn workflow_secret_cleanup_bootstrap() -> &'static str {
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);
}})();
(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"))
}
+520 -47
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 = ?",
)
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);
});
});
+835 -166
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>
+21 -33
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,20 +47,30 @@ 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 }}>
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>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>旧 Token</label>
<input
@@ -108,17 +107,6 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
{error}
</div>
)}
</div>
<div className="modal-footer">
<button onClick={onClose} className="btn btn-ghost">取消</button>
<button onClick={handleSubmit} disabled={saving} className="btn btn-primary">
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />更新中...</> : '确认修改'}
</button>
</div>
</div>
</div>
</CenteredModal>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
}
+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: {},
+63 -10
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,16 +199,23 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
</div>
{/* Chart Content */}
<div
key={activeTab}
id={activeTabPanelId}
className="model-analysis-tab-panel"
role="tabpanel"
aria-labelledby={activeTabId}
>
{activeTab === 'spend' && (
<div>
<div style={{ height: 300 }}>
<VChart spec={spendBarSpec} />
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', marginTop: 10, padding: '0 4px' }}>
<div className="model-analysis-legend">
{spendDistribution.map(d => (
<span key={d.model} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--color-text-secondary)' }}>
<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 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>
))}
@@ -182,14 +234,14 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
<div style={{ height: 300 }}>
<VChart spec={callsPieSpec} />
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', marginTop: 10, padding: '0 4px' }}>
<div className="model-analysis-legend">
{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 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 style={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.model}</span>
<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>
);
@@ -293,5 +345,6 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
</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)}
+267 -21
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) {
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 () => {
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)}
{(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: 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)',
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',
}}>
{filterType ? tr(typeLabels[filterType] || filterType) : tr('全部')}
}}
>
{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 });
}
}, [open]);
lastActiveElementRef.current = null;
}
}, [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);
});
});
+5 -1
View File
@@ -56,10 +56,12 @@ export default function SiteCreatedModal({
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={(
<>
<button onClick={() => onChoice('later')} className="btn btn-ghost">
<button type="button" onClick={() => onChoice('later')} className="btn btn-ghost">
稍后配置
</button>
<button
type="button"
aria-label={`站点创建后续操作:${secondaryAction.label}`}
onClick={() => onChoice(secondaryAction.choice)}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
@@ -67,6 +69,8 @@ export default function SiteCreatedModal({
{secondaryAction.label}
</button>
<button
type="button"
aria-label={`站点创建推荐操作:${primaryAction.label}`}
onClick={() => onChoice(primaryAction.choice)}
className="btn btn-primary"
>

Some files were not shown because too many files have changed in this diff Show More