105 Commits

Author SHA1 Message Date
boos c3293e007f docs(nit): AI Studio 第一阶段(聚合图像/数字人)设计 spec
NIT 精英班专属 AI Studio 第一阶段:聚合 picture_agent(图像)+ digital_human(数字人)。
学员下游 key 配置 + surface 认证扩展(接受 student JWT)+ 积分轨计费 + 统一入口前端。
不碰 money_printer,视频/文本创作/PPT 留后续阶段。前置依赖第一期报考闭环。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 04:43:51 +08:00
boos a1b7b60ad4 docs(nit): 第一期报考闭环 A+B+C 设计 spec
NIT 报考学习平台第一期设计:独立 students 表、线下缴费上传凭证人工审核、
开课权限状态标记、平台超管后台、模块内嵌复用现有 axum/sqlx/JWT/workspace 基建。
不含学习中心/OCR/校区负责人多级权限/内容保护(留后续期)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 04:24:05 +08:00
boos bfd804f719 feat(billing): UsageRecords dual-track ledger view
api.ts 新增 userApi.getBillingLedger / getBillingSummary 及 Billing* 类型;
UsageRecords.tsx 增加 USD/积分 双轨切换 tab、积分汇总卡与明细表(切到积分轨时懒加载)。
随文件一并提交此前累积的支付/Comfy Cloud/工作流前端 API 改动。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 03:04:11 +08:00
boos e7f3da5961 feat(billing): user billing ledger + summary endpoints
新增 GET /api/user/billing/ledger 与 /api/user/billing/summary 两个用户侧接口,
基于 Task11 的 list_user_ledger / summarize_user_ledger 返回当前用户的明细与聚合。
随文件一并提交此前累积的充值/支付/市场相关改动及测试辅助。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 02:56:12 +08:00
boos 5cfd533d89 feat(billing): user ledger list + summary queries 2026-06-17 01:49:46 +08:00
boos 0b7abed79a docs: 细化计费第3期(消费明细)TDD Task11-14 2026-06-17 01:44:28 +08:00
boos a89edf6f75 feat(payment): comfy 登录态/充值确认/记录管理 + Task9 积分到账调用适配
含本会话累积的支付路由功能(firebase-login 回执登录、recharge-records
列表/确认/清空、诊断卡)及 admin_confirm_user_recharge 传 config 的调用适配,
使计费 Task9(comfy 充值改积分)调用方编译完整。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 21:13:16 +08:00
boos 5d42671172 feat(billing): comfy recharge to points + admin credit adjust
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 21:11:44 +08:00
boos ca9e45c59c feat(billing): workflow points gating + charge
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 20:58:02 +08:00
boos be7538a1da feat(billing): workflow_points_cost config 2026-06-16 20:32:10 +08:00
boos cfe2098b73 feat(billing): points track apply_points_delta + balance 2026-06-16 20:24:50 +08:00
boos bd464016a8 docs: 细化计费第2期(积分轨)TDD 任务 Task6-10 2026-06-16 20:20:22 +08:00
boos 7e8430934e feat(billing): USD admin adjust writes credit_ledger
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 17:46:56 +08:00
boos ab2fd271e8 feat(billing): USD recharge writes credit_ledger 2026-06-16 17:40:43 +08:00
boos 4dd2be4cc3 feat(billing): USD consume writes credit_ledger in one tx
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 17:34:21 +08:00
boos fd94303433 feat(billing): credit_ledger schema + record_ledger_entry 2026-06-16 17:25:59 +08:00
boos 91dcbc5c53 docs: 计费闭环实现计划(第1期详细TDD + 后5期概要)
第1期地基: credit_ledger + user_credits 表, USD 轨三处记账
(消费/充值/管理员调整)同事务接入 ledger。每步含完整测试与代码。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 17:17:54 +08:00
boos 278b0c66b8 docs: 计费闭环系统设计 spec
双轨额度(USD 池 + 工作流积分池)、ledger-first 统一流水账、
消费明细/充值档位/超额预警/全量审计四块功能、A+C 混合积分计费、
Comfy 余额对账校准。分 6 期实现。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 17:12:02 +08:00
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
360 changed files with 115717 additions and 10738 deletions
+6 -5
View File
@@ -1,18 +1,19 @@
# Cargo build acceleration. # Cargo build acceleration.
# #
# The biggest single speedup is the `mold` linker (~3-5x faster link # The biggest single speedup is the `mold` linker (~3-5x faster link
# time on debug builds). It isn't installed by default on this host # time on debug builds). `build-and-run.sh` enables mold/lld automatically
# (no apt-get, see build-and-run.sh notes), so the mold block below is # when available, so the block below is kept as an optional manual setup for
# commented out — uncomment it after running: # direct `cargo build` / `cargo run` usage:
# #
# sudo apt-get install -y mold # sudo apt-get install -y mold
# #
# (or build it from source: https://github.com/rui314/mold) # (or build it from source: https://github.com/rui314/mold)
# #
# Without mold, the [profile.dev] tweaks in Cargo.toml still give a # Without mold/lld, the [profile.dev] tweaks in Cargo.toml still give a
# meaningful speedup (line-tables-only debuginfo + bumped codegen-units). # meaningful speedup (line-tables-only debuginfo + bumped codegen-units).
# Uncomment once mold is on PATH: # Optional: uncomment once mold is on PATH if you want direct cargo commands
# to use it without going through build-and-run.sh.
# [target.x86_64-unknown-linux-gnu] # [target.x86_64-unknown-linux-gnu]
# linker = "clang" # linker = "clang"
# rustflags = ["-C", "link-arg=-fuse-ld=mold"] # rustflags = ["-C", "link-arg=-fuse-ld=mold"]
+16
View File
@@ -2,3 +2,19 @@ comfy_source/venv
comfy_source/.git comfy_source/.git
comfy_source/__pycache__ comfy_source/__pycache__
**/__pycache__ **/__pycache__
target/
node_modules/
dist/
release/
data/
logs/
tmp/
.agents/
.cache/
.codex/
.claude/
.comfyui/
boosapi
bossai
boosapi.db
docker/*.tar
+11 -1
View File
@@ -1,13 +1,15 @@
/target /target
**/*.rs.bk **/*.rs.bk
Cargo.lock
.env .env
.env.* .env.*
data/ data/
node_modules/ node_modules/
website/.next/
website/out/
dist/ dist/
release/ release/
*.log *.log
*.tsbuildinfo
logs/ logs/
tmp/ tmp/
!tmp/.gitkeep !tmp/.gitkeep
@@ -19,5 +21,13 @@ docker/.env
.comfyui/ .comfyui/
.claude/ .claude/
.codex/ .codex/
.agents/
.cache/
openspec/ openspec/
boosapi boosapi
bossai
docker/*.tar
boosapi.db-shm
boosapi.db-wal
.tours/
bun.lock
Generated
+4177
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" parking_lot = "0.12"
mime = "0.3" mime = "0.3"
base64 = "0.22" base64 = "0.22"
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
include_dir = "0.7" include_dir = "0.7"
mime_guess = "2" mime_guess = "2"
+73
View File
@@ -66,6 +66,31 @@
## 3. 生产环境 (Production) ## 3. 生产环境 (Production)
### 单二进制生产包
推荐用项目脚本生成生产包:
```bash
./build-and-run.sh --production
```
生产模式会构建主前端和 Workflow UI,并把静态资产嵌入 Rust 二进制。部署目录通常只需要:
```txt
boosapi
config.toml
```
部署目录只需要运行产物和配置。仓库里的 `.agents/`、`.cache/`、本地二进制、Docker tar 包、运行日志等均为本地缓存或构建产物,不应进入发布包。`Cargo.lock` 应保留在仓库中,用于保证生产构建依赖可复现。
启动前检查配置:
```bash
./boosapi --check-config
./boosapi --migrate-only
./boosapi --config config.toml
```
### 推荐:Docker Compose 部署 ### 推荐:Docker Compose 部署
1. **准备 docker-compose.yml**: 1. **准备 docker-compose.yml**:
@@ -161,8 +186,54 @@
| `auth.auth_token` | `BOOSAPI_AUTH__AUTH_TOKEN` | 管理员登录 Token (静态,必填) | | `auth.auth_token` | `BOOSAPI_AUTH__AUTH_TOKEN` | 管理员登录 Token (静态,必填) |
| `auth.jwt_secret` | `BOOSAPI_AUTH__JWT_SECRET` | JWT 签名密钥 (普通用户,必填) | | `auth.jwt_secret` | `BOOSAPI_AUTH__JWT_SECRET` | JWT 签名密钥 (普通用户,必填) |
| `db.url` | `BOOSAPI_DB__URL` | SQLite 数据库路径 (如 `sqlite:data/boosapi.db`) | | `db.url` | `BOOSAPI_DB__URL` | SQLite 数据库路径 (如 `sqlite:data/boosapi.db`) |
| `comfyui.backend` | `BOOSAPI_COMFYUI__BACKEND` | Workflow 后端:`cloud` / `runninghub` / `external` / `boosapi` / `docker` |
| `comfyui.base_url` | `BOOSAPI_COMFYUI__BASE_URL` | Comfy Cloud、RunningHub 或外部 ComfyUI API 地址 |
| `comfyui.api_key` | `BOOSAPI_COMFYUI__API_KEY` | Workflow 后端 API Key,按后端需要填写 |
| `web_dir` | `BOOSAPI_WEB_DIR` | 前端静态资源目录 (默认 `dist/web`) | | `web_dir` | `BOOSAPI_WEB_DIR` | 前端静态资源目录 (默认 `dist/web`) |
### Workflow 后端配置
BoosAPI 的 `/workflows` 页面使用内嵌 ComfyUI 前端,后端由 `[comfyui]` 选择。
Comfy Cloud:
```toml
[comfyui]
backend = "cloud"
base_url = "https://cloud.comfy.org"
api_key = "your-comfy-cloud-api-key"
```
RunningHub:
```toml
[comfyui]
backend = "runninghub"
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
api_key = "your-runninghub-api-key"
```
外部完整版 ComfyUI:
```toml
[comfyui]
backend = "external"
base_url = "http://127.0.0.1:8188"
```
BoosAPI API-native 执行器:
```toml
[comfyui]
backend = "boosapi"
```
说明:
- `cloud`、`runninghub`、`external` 和 `boosapi` 模式不要求 BoosAPI 机器有 GPU。
- `docker` / 本地 ComfyUI 模式需要本机有完整模型目录和匹配的 NVIDIA GPU。
- 生产构建应包含 `dist/workflow-ui`、`dist/workflow-ui-cloud`、`dist/workflow-ui-external` 中可用的 Workflow UI 资产;脚本会尽量自动安装与构建。
--- ---
## 5. 运维操作 (Ops) ## 5. 运维操作 (Ops)
@@ -179,6 +250,8 @@ BoosAPI 使用 SQLite,只需备份数据库文件即可:
cp data/boosapi.db data/boosapi.db.bak cp data/boosapi.db data/boosapi.db.bak
``` ```
如使用 PostgreSQL,请按数据库自身的 `pg_dump` / `pg_restore` 流程备份恢复。
### 查看日志 ### 查看日志
如果使用 Systemd: 如果使用 Systemd:
```bash ```bash
+175 -15
View File
@@ -9,7 +9,7 @@
<br> <br>
汇聚成 <strong>一个 API Key、一个入口</strong>,自动发现模型、智能路由、成本最优。 汇聚成 <strong>一个 API Key、一个入口</strong>,自动发现模型、智能路由、成本最优。
<br> <br>
<strong>Workflow Studio / 视频助手</strong> — 用 API-native 工作流和自然语言助手组合图像、视频、语音与文本能力。 <strong>Workflow Studio / 视频助手</strong> — 用内嵌 ComfyUI 前端、多 API 后端和自然语言助手组合图像、视频、语音与文本能力。
<br> <br>
<strong>SaaS 多租户架构</strong>,组织管理、成员邀请、套餐限制,开箱即用。 <strong>SaaS 多租户架构</strong>,组织管理、成员邀请、套餐限制,开箱即用。
</p> </p>
@@ -57,6 +57,18 @@
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 | | 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 | | 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
## 2026-06 更新重点
- **后台登录收口**:管理员后台只允许使用 `BOOSAPI_AUTH__AUTH_TOKEN` 这类 API Token 登录;普通用户仍使用账号密码登录用户工作台。
- **日志边界清晰**:管理员「使用日志」保留在 `/logs`,系统/程序日志保留在 `/events`;`/api/events` 现在只接受管理员 API Token,普通用户不会再看到运行日志。
- **通知隔离**:普通用户顶部通知只读取用户公告;管理员通知面板继续聚合签到、余额、令牌、代理、状态与公告事件。
- **后台信息架构**:侧栏按接入管理、创作应用、系统管理、我的账户精简;外部监控入口已移除,系统日志入口保留在系统管理分组。
- **运维与 IP 管理**:新增运维监控视图与 IP 管理页面,支持代理质量检测、连接测试、批量操作与更清晰的状态展示。
- **用户自助工作台**:API Key、计费、账户充值、使用记录、工作空间和模型测试统一到用户侧;工作空间支持分页、一键选择和默认预览。
- **Comfy Checkout 充值**:用户先在系统内创建充值记录,再跳转到配置的 Comfy Checkout 链接付款;充值记录会保留用于后续对账。
- **Workflow UI 更新**:`npm run workflow-ui:install:cloud` 会安装当前 Comfy Cloud 前端资产并写入构建元数据,Rust 服务会对 JS/CSS 走 no-cache,避免继续显示旧版本。
- **本地化时间**:前端展示时间优先使用浏览器/UA 时区,后端无时区时间按 UTC 解析后再转成本地显示。
## ✨ 核心功能 ## ✨ 核心功能
### 🎨 Workflow Studio 与视频助手(Rust) ### 🎨 Workflow Studio 与视频助手(Rust)
@@ -65,7 +77,8 @@
- **自动生成四视图**:每个角色正/背/左/右 4 角度,调用 gpt-image-2 生成图片 - **自动生成四视图**:每个角色正/背/左/右 4 角度,调用 gpt-image-2 生成图片
- **场景生成**:自动为每个场景生成配图 - **场景生成**:自动为每个场景生成配图
- **TTS 语音合成**:对话台词自动转语音 - **TTS 语音合成**:对话台词自动转语音
- **Workflow Studio**:API-native 工作流入口,不依赖 Docker、GPU 或本地 ComfyUI - **Workflow Studio**:内嵌 ComfyUI 前端,可切换 Comfy Cloud、RunningHub、外部 ComfyUI、BoosAPI API-native 执行器或本地 Docker 完整版 ComfyUI
- **远程执行优先**:使用 Comfy Cloud、RunningHub、外部 API 或 BoosAPI 上游路由时,BoosAPI 服务器不需要 GPU
- **外部 ComfyUI 可选代理**:已有独立 ComfyUI 服务时,可通过 `comfyui.base_url` 接入 `/api/comfyui/proxy/*` - **外部 ComfyUI 可选代理**:已有独立 ComfyUI 服务时,可通过 `comfyui.base_url` 接入 `/api/comfyui/proxy/*`
- **Rust 后端**(boosapi-rs):高性能 SSE 流式响应,零 Node.js 运行时依赖 - **Rust 后端**(boosapi-rs):高性能 SSE 流式响应,零 Node.js 运行时依赖
- 侧边栏「视频助手」入口,与聚合网关同一界面 - 侧边栏「视频助手」入口,与聚合网关同一界面
@@ -176,6 +189,80 @@ BoosAPI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时
如果图像、视频、语音能力走 BoosAPI 上游路由、Comfy Cloud、RunningHub 或其他远程 API,本机只负责网关、数据库、Workflow UI 代理、日志和 Workspace 归档,上面这档配置通常足够。只有把 ComfyUI 模型和视频生成放在本机运行时,才需要高显存 NVIDIA GPU。 如果图像、视频、语音能力走 BoosAPI 上游路由、Comfy Cloud、RunningHub 或其他远程 API,本机只负责网关、数据库、Workflow UI 代理、日志和 Workspace 归档,上面这档配置通常足够。只有把 ComfyUI 模型和视频生成放在本机运行时,才需要高显存 NVIDIA GPU。
## 🎛️ Workflow API 后端模式
BoosAPI 的 Workflow 使用内嵌 ComfyUI 前端,后端可按配置切换:
| backend | 用途 |
|---------|------|
| `cloud` | 代理 Comfy Cloud API,适合默认创作、官方订阅模型和稳定执行 |
| `runninghub` | 代理 RunningHub 的 ComfyUI 兼容 API,适合使用 RunningHub 专属插件、工作流和模型 |
| `external` | 代理你自己部署的完整版 ComfyUI |
| `boosapi` | 使用 BoosAPI 自带 Rust Workflow/API 节点执行器 |
| `docker` | 本地完整版 ComfyUI 模式,适合有本地 GPU 和完整模型目录的环境 |
推荐配置是默认 `cloud`,需要 RunningHub 专属插件或模型时切到 `runninghub`。RunningHub 模式会复用 external 兼容前端,并对 `/object_info`、模型库、`/system_stats` 做短期缓存,减少每次打开工作流时的卡顿。
配置示例:
```toml
[comfyui]
backend = "cloud"
base_url = "https://cloud.comfy.org"
api_key = "your-comfy-cloud-api-key"
```
```toml
[comfyui]
backend = "runninghub"
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
api_key = "your-runninghub-api-key"
```
```toml
[comfyui]
backend = "external"
base_url = "http://127.0.0.1:8188"
```
```toml
[comfyui]
backend = "boosapi"
```
### 将 Comfy 视频节点包装为 BoosAPI `/v1/videos`
ComfyUI/Comfy Cloud 没有 OpenAI 兼容 `/v1/videos`,但 BoosAPI 可以把一个已保存的视频工作流模板包装成统一视频模型:
1. 在工作流页面保存一个真实 Comfy 视频模板,模板需要能被 `POST /prompt` 执行。
2. 模板图里最好保存节点映射:
- `prompt`:提示词节点和字段
- `negativePrompt`:反向提示词节点和字段,可选
- `ratio` / `resolution` / `duration`:画幅、分辨率和时长节点字段,可选
3. 在模型路由里新增一个模型,例如 `comfy-wan-video`。
4. 让该路由的上游地址或实际模型映射指向模板:
- 上游地址:`comfy://<workflow_public_id>`
- 或模型映射:`comfy:<workflow_public_id>`
之后用户可以直接调用:
```bash
curl http://127.0.0.1:4000/v1/videos \
-H "Authorization: Bearer <BoosAPI downstream key>" \
-H "Content-Type: application/json" \
-d '{
"model": "comfy-wan-video",
"prompt": "cinematic product video, smooth camera movement",
"duration": 5,
"ratio": "9:16",
"resolution": "1080p"
}'
```
返回的 `id` 是 Comfy `prompt_id`,可用 `GET /v1/videos/{id}?model=comfy-wan-video` 查询 Comfy history。
> Workflow UI 有 cloud 与 external/runninghub 两套前端资产。开发环境可用 `npm run workflow-ui:install:cloud`、`npm run workflow-ui:install:external` 单独安装;安装脚本会写入 `boosapi-workflow-ui.json` 构建元数据,生产构建会把可用资产嵌入 Rust 二进制。若浏览器「关于」仍显示旧前端版本,请重启 Rust 服务并强刷浏览器缓存。
## 🚀 快速开始 ## 🚀 快速开始
### Docker Compose(推荐) ### Docker Compose(推荐)
@@ -200,14 +287,14 @@ services:
restart: unless-stopped restart: unless-stopped
EOF EOF
# AUTH_TOKEN = 管理后台登录令牌 # AUTH_TOKEN = 管理后台登录令牌(管理员只支持 API Token 登录)
# PROXY_TOKEN = 下游客户端调用 /v1/* 的 Token # PROXY_TOKEN = 下游客户端调用 /v1/* 的 Token
export AUTH_TOKEN=your-admin-token export AUTH_TOKEN=your-admin-token
export PROXY_TOKEN=sk-your-proxy-token export PROXY_TOKEN=sk-your-proxy-token
docker compose up -d docker compose up -d
``` ```
启动后访问 `http://localhost:4000`,用 `AUTH_TOKEN` 登录即可。 启动后访问 `http://localhost:4000/boos`,用 `AUTH_TOKEN` 登录管理员后台;普通用户从用户登录页使用账号密码进入用户工作台。
> [!IMPORTANT] > [!IMPORTANT]
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。 > 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。
@@ -250,15 +337,16 @@ docker compose up -d
│ ├── billing/ # 计费逻辑 │ ├── billing/ # 计费逻辑
│ └── notification/ # 通知推送 │ └── notification/ # 通知推送
├── migrations/ # SQLite 迁移 ├── migrations/ # SQLite 迁移
├── src/web/ # 前端源码(React 19 + Vite) ├── src/web/ # 管理台前端源码(React + Vite)
│ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶) │ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶)
│ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage) │ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage)
│ ├── pages/ │ ├── pages/
│ │ ├── auth/ # 登录页拆分:LoginShell + AdminLoginPage + UserLoginPage(OAuthSlot 占位,行为由后续 change 接入) │ │ ├── auth/ # 登录页拆分:LoginShell + AdminLoginPage(token-only) + UserLoginPage(OAuthSlot 占位)
│ │ ├── ComfyUIAgent.tsx # 视频/工作流助手聊天界面 │ │ ├── ComfyUIAgent.tsx # 视频/工作流助手聊天界面
│ │ ├── Workflows.tsx # Workflow Studio 入口 │ │ ├── Workflows.tsx # Workflow Studio 入口
│ │ └── ... │ │ └── ...
│ └── components/ │ └── components/
├── website/ # 独立官网(Next.js 16 App Router + React 19)
└── dist/web/ # 前端构建产物(由 Rust serve) └── dist/web/ # 前端构建产物(由 Rust serve)
``` ```
@@ -296,6 +384,11 @@ BOOSAPI_UPSTREAM_KEY=sk-your-key
# 前端静态目录(默认 dist/web) # 前端静态目录(默认 dist/web)
BOOSAPI_WEB_DIR=dist/web BOOSAPI_WEB_DIR=dist/web
# Workflow 后端
BOOSAPI_COMFYUI__BACKEND=cloud
BOOSAPI_COMFYUI__BASE_URL=https://cloud.comfy.org
BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
``` ```
## 🏗️ 技术栈 ## 🏗️ 技术栈
@@ -309,9 +402,10 @@ BOOSAPI_WEB_DIR=dist/web
| 认证鉴权 | **JWT** + API Key 双模式 | | 认证鉴权 | **JWT** + API Key 双模式 |
| 代理引擎 | 自定义 Token Router + Proxy Conductor | | 代理引擎 | 自定义 Token Router + Proxy Conductor |
| 协议转换 | OpenAI / Claude / Gemini 全兼容 | | 协议转换 | OpenAI / Claude / Gemini 全兼容 |
| Workflow Studio | API-native 工作流入口,后续承接节点画布 | | Workflow Studio | 内嵌 ComfyUI 前端 + Cloud / RunningHub / External / BoosAPI 多后端 |
| 视频助手 | SSE 流式 + gpt-image-2 + TTS 自动生成 | | 视频助手 | SSE 流式 + gpt-image-2 + TTS 自动生成 |
| 前端框架 | React 19 + Vite(仅构建工具) | | 管理台前端 | React + Vite(仅构建工具) |
| 独立官网 | Next.js 16 App Router + React 19 + TypeScript |
| 样式 | Tailwind CSS v4 | | 样式 | Tailwind CSS v4 |
| 数据可视化 | VChart | | 数据可视化 | VChart |
| 容器化 | Docker + Docker Compose + K3s | | 容器化 | Docker + Docker Compose + K3s |
@@ -319,6 +413,8 @@ BOOSAPI_WEB_DIR=dist/web
> **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。 > **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。
> 独立官网位于 `website/`。默认构建会把官网静态导出到 `dist/website/`,Rust 后端会让根路径 `/` 显示官网;管理台仍由 `dist/web/` 提供,管理员入口是 `/boos`,登录后进入 `/dashboard`。
--- ---
## 🛠️ 本地开发 ## 🛠️ 本地开发
@@ -326,11 +422,20 @@ BOOSAPI_WEB_DIR=dist/web
### 后端(Rust) ### 后端(Rust)
```bash ```bash
# 编译并运行 # 最快验证:只检查 Rust 后端,不构建前端、不启动服务
cargo run --release ./build-and-run.sh --check
# 仅检查编译 # 日常启动:复用前端缓存,后端 dev profile 编译后启动
cargo check ./build-and-run.sh
# 只重启/编译后端,不碰 Vite/Next
./build-and-run.sh --backend-only
# 接近生产性能但比 release 快
./build-and-run.sh --fast-release
# 完整生产包:构建并嵌入前端/Workflow UI,只输出 boosapi + config.toml
./build-and-run.sh --production
# 运行测试 # 运行测试
cargo test cargo test
@@ -341,9 +446,15 @@ export BOOSAPI_SERVER__PORT=4000
export BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token export BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
export BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token export BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
export BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1 export BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1
export BOOSAPI_UPSTREAM_KEY=sk-boos4721 export BOOSAPI_UPSTREAM_KEY=sk-your-upstream-key
``` ```
构建加速说明:
- 脚本会在 Linux 下自动使用 `mold` / `lld` 快速链接器;可用 `BOOSAPI_FAST_LINKER=off` 关闭。
- `BOOSAPI_CARGO_JOBS=8 ./build-and-run.sh --check` 可限制 Cargo 并发,避免把开发机打满。
- 生产性能验证优先用 `--fast-release`,真正发布再用 `--production` 或 `--release`。
### 前端(React SPA) ### 前端(React SPA)
前端使用 Vite 构建,产物由 Rust 后端静态 serve: 前端使用 Vite 构建,产物由 Rust 后端静态 serve:
@@ -355,11 +466,22 @@ npm install
# 开发模式(热更新,需要 Node.js) # 开发模式(热更新,需要 Node.js)
cd src/web && npx vite --host 0.0.0.0 cd src/web && npx vite --host 0.0.0.0
# 构建生产版本 # 类型检查与构建生产版本
npm run build npm run typecheck:web
npm run build:web
# 输出到 dist/web/,Rust 后端自动 serve # 输出到 dist/web/,Rust 后端自动 serve
``` ```
Workflow UI 前端资产单独更新:
```bash
# Comfy Cloud 前端
npm run workflow-ui:install:cloud
# External / RunningHub 前端
npm run workflow-ui:install:external
```
可选使用 Bun 加速主前端构建: 可选使用 Bun 加速主前端构建:
```bash ```bash
@@ -376,6 +498,44 @@ BOOSAPI_FRONTEND_PM=auto ./build-and-run.sh
> 生产环境不需要 Node.js 进程。`npm run build` 后,Rust 后端直接 serve 静态文件。 > 生产环境不需要 Node.js 进程。`npm run build` 后,Rust 后端直接 serve 静态文件。
> 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。 > 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。
### 独立官网(Next.js)
官网已经从管理台中隔离,使用 Next.js 16 App Router、React 19、TypeScript、Tailwind CSS 4 和自定义 SVG/CSS motion system:
```bash
# 首次安装
npm --prefix website install
# 本地开发官网
npm run website:dev
# 类型检查与生产构建
npm run website:typecheck
npm run website:build
```
`./build-and-run.sh` 会同时构建管理台 `dist/web/` 和官网 `dist/website/`。打开主域名 `/` 显示官网;后台路径 `/boos`、`/dashboard`、`/user/*`、`/api/*`、`/v1/*` 等仍由 BoosAPI Rust 服务处理。
### 生产单二进制构建
```bash
./build-and-run.sh --production
```
生产模式会构建主前端与 Workflow UI 资产,并将它们嵌入 Rust 二进制。部署目录通常只需要:
```txt
boosapi
config.toml
```
仓库卫生说明:
- `Cargo.lock` 应随代码提交,保证 Rust 应用在 CI、生产构建和本地排障时依赖版本一致。
- `boosapi.db`、`config.toml` 是本地运行态文件;实际部署时不要提交带真实密钥、Comfy Cloud Key 或生产数据的改动。
- `.agents/`、`.cache/`、`boosapi` / `bossai` 本地二进制、`docker/*.tar` 等构建缓存与大文件已加入忽略规则。
- 旧的 `boosapi-comfyui-multi-tenant-*.tar` 不再作为当前 Workflow 路线的必需产物,生产包应优先使用单二进制 + 配置文件。
### Docker 部署 ### Docker 部署
```bash ```bash
+139 -150
View File
@@ -2,206 +2,195 @@
# BoosAPI # BoosAPI
**AI Aggregation Gateway + Built-in ComfyUI — Unify scattered relay stations into one entry point** **AI Aggregation Gateway + Workflow Studio — one endpoint for many AI relay stations**
<p> <p>
Aggregate your New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API stations Aggregate New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API stations
<br> <br>
into <strong>one API Key, one endpoint</strong> — with auto model discovery, smart routing, and cost optimization. into <strong>one API key and one compatible endpoint</strong>, with model discovery, smart routing, logs, billing, and failover.
<br> <br>
<strong>Built-in ComfyUI chat assistant</strong> — generate images/videos from natural language, ready out of the box. Workflow Studio embeds the ComfyUI frontend and can run against Comfy Cloud, RunningHub, your own ComfyUI, or BoosAPI's API-native executor.
<br>
<strong>SaaS multi-tenant architecture</strong> — organization management, member invitations, plan limits.
</p> </p>
<p align="center"> <p align="center">
<img alt="Node.js" src="https://img.shields.io/badge/Node.js-22.15%2B-339933?logo=node.js&style=flat"> <img alt="Rust" src="https://img.shields.io/badge/Rust-1.85%2B-000000?logo=rust&style=flat">
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript&style=flat"> <img alt="Axum" src="https://img.shields.io/badge/Axum-0.8-3182F6?style=flat">
<img alt="SQLx" src="https://img.shields.io/badge/SQLx-0.8-3E6E93?style=flat">
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?logo=react&style=flat">
<img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat"> <img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat">
</p> </p>
<p align="center"> <p align="center">
<a href="README.md">中文</a> | <a href="README.md">中文</a> |
<a href="README_EN.md"><strong>English</strong></a> <a href="README_EN.md"><strong>English</strong></a> |
<a href="DEPLOY.md">Deployment</a>
</p> </p>
</div> </div>
--- ---
## 📖 Introduction ## Introduction
With the growing ecosystem of AI relay stations (New API / One API based), managing balances, models, and API keys across multiple stations is tedious and time-consuming. BoosAPI is a self-hosted Rust gateway for AI relay stations. It centralizes upstream sites, accounts, downstream API keys, model routes, usage logs, image/video generation, user dashboards, and Workflow Studio in one service.
**BoosAPI** acts as a **meta-aggregation layer** above these stations, unifying them under **a single entry point** — all downstream tools connect seamlessly without configuration changes. Supported upstreams:
### Supported Upstreams - **Relay panels**: New API, One API, OneHub, DoneHub, Veloera, AnyRouter, Sub2API
- **Compatible APIs**: OpenAI / Claude / Gemini compatible, cliproxyapi / CPA
- **Preset providers**: Aliyun, Zhipu, Doubao Coding Plan, DeepSeek, Moonshot, MiniMax, ModelScope
- **OAuth connections**: Codex, Claude, Gemini CLI, Antigravity
- **Panels**: New API, One API, OneHub, DoneHub, Veloera, AnyRouter, Sub2API ## Key Features
- **Generic**: OpenAI / Claude / Gemini compatible, cliproxyapi / CPA
- **Official**: Aliyun, Zhipu, Doubao Coding Plan, DeepSeek, Moonshot, MiniMax, ModelScope
- **OAuth**: Codex, Claude, Gemini CLI, Antigravity
| Pain Point | BoosAPI Solution | ### Unified API Gateway
|------------|-----------------|
| 🔑 One key per station, messy configs | **Unified proxy entry + multi-key strategy** |
| 💸 Which station is cheapest? | **Smart routing** by cost, balance, usage |
| 🔄 Manual failover | **Auto failover** with cooldown and retry |
| 📊 Scattered balances | **Central dashboard** with alerts |
| ✅ Daily check-ins | **Auto check-in** with reward tracking |
| 🤷 What models are available? | **Auto model discovery**, zero config |
| 🎨 ComfyUI workflows too complex? | **Chat assistant**, describe in natural language |
| 👥 Need multi-user access? | **Organizations**, member invites, role-based access |
| 📋 Different teams need quotas? | **Plan system**, Free/Pro/Enterprise tiers |
--- - OpenAI-compatible `/v1/chat/completions`, `/v1/responses`, `/v1/images/generations`, `/v1/models`, and files endpoints
- Claude-compatible `/v1/messages`, including request/response and streaming event conversion
- Smart token routing, channel cooldown, failover, model discovery, and usage accounting
- Admin and user dashboards with API key management and model visibility controls
## ✨ Features ### Workflow Studio
### 🎨 Built-in ComfyUI Chat Assistant Workflow Studio uses an embedded ComfyUI frontend. The execution backend is selected by `comfyui.backend`:
- **Natural language to workflow**: describe what you want, AI builds the ComfyUI pipeline | backend | Purpose |
- **Multi-view generation**: front/back/left/right directional image generation |---------|---------|
- **Scene/video generation**: text description directly generates video content | `cloud` | Proxy Comfy Cloud API for cloud execution and subscription models |
- **SSE real-time streaming**: generation progress feedback | `runninghub` | Proxy RunningHub's ComfyUI-compatible API for its plugins, models, and workflows |
- Sidebar "Chat Assistant" entry, same UI as the aggregation gateway | `external` | Proxy a full ComfyUI instance you deploy yourself |
- Auto-connects to ComfyUI API (`:8188`), zero configuration required | `boosapi` | Use BoosAPI's Rust API-native workflow executor and configured model routes |
| `docker` | Local full ComfyUI mode for GPU machines and local model directories |
### 🌐 Unified Proxy Gateway Cloud, RunningHub, external API, and BoosAPI executor modes do not require a GPU on the BoosAPI server. A GPU is only needed when you run local ComfyUI inference on the same machine.
- Compatible with **OpenAI** and **Claude** downstream formats ### Assistants
- Responses / Chat Completions / Messages / Completions / Embeddings / Images / Models / Files
- SSE streaming with automatic format conversion
### 🧠 Smart Routing Engine - Video / workflow assistant with Markdown chat, reasoning sections, attachments, and model route selection
- Picture assistant using `/v1/images/generations`, background tasks, progress display, cancellation, and Workspace archive
- Volcengine video task templates for create-task and query-task flows
- Auto-discovers models from all upstream stations ## Hardware Guide
- Four-tier cost signals: measured → configured → catalog → fallback
- Multi-channel weighted distribution (cost 40%, balance 30%, usage 30%)
- Auto cooldown on failure (default 10 min), seamless channel switching
### 📡 Multi-Platform Management | Mode | CPU | Memory | Disk | GPU |
|------|-----|--------|------|-----|
| Gateway, admin UI, `/v1/*` proxy only | 2 cores minimum, 4 recommended | 2GB minimum, 4GB recommended | 10GB+ | Not required |
| BoosAPI + Workflow UI + remote APIs / Comfy Cloud / RunningHub | 4 cores recommended | 4GB minimum, 8GB recommended | 20-50GB | Not required |
| External ComfyUI proxy | 4 cores recommended | 8GB recommended | 50GB+ | Not on BoosAPI; required by the ComfyUI machine as needed |
| Local Docker / local ComfyUI image generation | 4-8 cores | 16GB+ | 100GB+ | NVIDIA 8GB VRAM minimum, 12GB+ recommended |
| Local video / digital human / large workflows | 8 cores+ | 32GB minimum, 64GB recommended | 200GB-1TB | NVIDIA 24GB VRAM minimum, 48GB+ recommended |
| Platform | Adapter | Recommended SaaS gateway server:
|----------|---------|
| New API | `new-api` |
| One API | `one-api` |
| OneHub | `onehub` |
| DoneHub | `done-hub` |
| Veloera | `veloera` |
| AnyRouter | `anyrouter` |
| Sub2API | `sub2api` |
### 👥 Account & Token Management ```txt
4 CPU cores
- Multi-station, multi-account with health tracking (healthy / unhealthy / degraded / disabled) 8GB RAM
- Encrypted credential storage, auto-renewal, cascading disable 50GB SSD
No GPU required
### 💰 Balance Management
- Scheduled refresh (default hourly), income/expense trends
- Fallback estimation when API is unavailable
### 👥 SaaS Multi-Tenant
- **Organizations**: Create orgs, each with independent members and quotas
- **Member Invitations**: Email-based invites with admin/member roles
- **Three Plans**: Free (3 members) / Pro (10) / Enterprise (100)
- **Role Control**: Owner / Admin / Member with granular permissions
- **Plan Enforcement**: Auto-limit members, API keys, daily requests
- **Invite Links**: Token-based, 48h expiry
### 🔔 Alerts & Notifications
Five channels: Webhook / Bark / ServerChan / Telegram Bot / SMTP
Scenarios: low balance, station failure, check-in failure, proxy errors, token expiry, daily digest
### 🔌 REST API Overview
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/organizations` | GET/POST | List / Create org |
| `/api/organizations/plans` | GET | List available plans |
| `/api/organizations/:id` | GET/PATCH | Org detail / Update |
| `/api/organizations/:id/members` | GET | List members |
| `/api/organizations/:id/members/:uid` | PATCH/DELETE | Change role / Remove |
| `/api/organizations/:id/invitations` | GET/POST | List / Create invite |
| `/api/organizations/:id/invitations/:invId` | DELETE | Cancel invite |
| `/api/invitations/:token` | GET | Public invite lookup |
| `/api/invitations/:token/accept` | POST | Accept invitation |
| `/api/users/register` | POST | Register new user |
| `/api/users/login` | POST | Login (returns JWT) |
| `/api/users/me` | GET/PATCH | Profile / Update |
| `/api/user-api-keys` | GET/POST | List / Create API key |
---
## 🚀 Quick Start
### Docker Compose (Recommended)
```bash
mkdir boosapi && cd boosapi
cat > docker-compose.yml << 'EOF'
services:
boosapi:
image: 1467078763/boosapi:latest
ports:
- "4000:4000"
volumes:
- ./data:/app/data
environment:
AUTH_TOKEN: ${AUTH_TOKEN:?AUTH_TOKEN is required}
PROXY_TOKEN: ${PROXY_TOKEN:?PROXY_TOKEN is required}
CHECKIN_CRON: "0 8 * * *"
BALANCE_REFRESH_CRON: "0 * * * *"
PORT: ${PORT:-4000}
DATA_DIR: /app/data
TZ: ${TZ:-Asia/Shanghai}
restart: unless-stopped
EOF
export AUTH_TOKEN=your-admin-token
export PROXY_TOKEN=your-proxy-sk-token
docker compose up -d
``` ```
Open `http://localhost:4000` and log in with `AUTH_TOKEN`. ## Quick Start
--- ```bash
cp config.toml.example config.toml
./build-and-run.sh
```
## 🏗️ Tech Stack For a production bundle with embedded web assets:
```bash
./build-and-run.sh --production
```
The production output is intended to run with only:
```txt
boosapi
config.toml
```
Web assets and Workflow UI assets are embedded in the Rust binary when built for production.
## Workflow Backend Configuration
Comfy Cloud:
```toml
[comfyui]
backend = "cloud"
base_url = "https://cloud.comfy.org"
api_key = "your-comfy-cloud-api-key"
```
RunningHub:
```toml
[comfyui]
backend = "runninghub"
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
api_key = "your-runninghub-api-key"
```
External ComfyUI:
```toml
[comfyui]
backend = "external"
base_url = "http://127.0.0.1:8188"
```
BoosAPI API-native executor:
```toml
[comfyui]
backend = "boosapi"
```
## Environment Variables
Configuration can be overridden with `BOOSAPI_<SECTION>__<KEY>`:
```bash
BOOSAPI_SERVER__HOST=0.0.0.0
BOOSAPI_SERVER__PORT=4000
BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
BOOSAPI_AUTH__JWT_SECRET=your-jwt-secret
BOOSAPI_DB__URL=sqlite:data/boosapi.db?mode=rwc
```
## Tech Stack
| Layer | Tech | | Layer | Tech |
|-------|------| |-------|------|
| Backend | Fastify | | Backend | Rust, Axum 0.8, Tokio |
| Frontend | React 18 + Vite | | Database | SQLx, SQLite runtime path, PostgreSQL schema/migration preparation |
| Language | TypeScript | | Proxy engine | Token Router + Proxy Conductor |
| Styling | Tailwind CSS v4 | | Protocols | OpenAI / Claude / Gemini compatible transformers |
| Database | SQLite, with PostgreSQL migration preparation | | Frontend | React 19 + Vite |
| Visualization | VChart | | Workflow UI | Embedded ComfyUI frontend variants for cloud/external-compatible backends |
| Container | Docker + Docker Compose | | Static serving | Rust binary serves `dist/web` and Workflow UI assets |
| Test | Vitest | | Container | Docker / Docker Compose |
--- Node.js is only a build-time tool for the frontend. Production does not require a Node.js process.
## 🛠️ Local Development ## Local Development
```bash ```bash
npm install cargo check
cargo run cargo run
npm run dev pnpm install
pnpm run dev:web
``` ```
Build checks:
```bash ```bash
npm run build # Build web assets cargo check
npm test # Run tests pnpm run typecheck:web
npx vite build
``` ```
--- ## License
## 📜 License
[MIT](LICENSE) [MIT](LICENSE)
File diff suppressed because one or more lines are too long
+226 -14
View File
@@ -17,13 +17,17 @@ fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
BUILD_PROFILE="${BOOSAPI_BUILD_PROFILE:-dev}" BUILD_PROFILE="${BOOSAPI_BUILD_PROFILE:-dev}"
FRONTEND_ONLY=0 FRONTEND_ONLY=0
BACKEND_ONLY=0 BACKEND_ONLY=0
CHECK_ONLY=0
NO_RUN=0 NO_RUN=0
FORCE_FRONTEND=0 FORCE_FRONTEND=0
SKIP_FRONTEND=0 SKIP_FRONTEND=0
PRODUCTION_BUNDLE=0 PRODUCTION_BUNDLE=0
PRODUCTION_DIR="${BOOSAPI_PRODUCTION_DIR:-$ROOT_DIR/dist/production}" PRODUCTION_DIR="${BOOSAPI_PRODUCTION_DIR:-$ROOT_DIR/dist/production}"
CARGO_FEATURES="${BOOSAPI_CARGO_FEATURES:-}" CARGO_FEATURES="${BOOSAPI_CARGO_FEATURES:-}"
CARGO_JOBS="${BOOSAPI_CARGO_JOBS:-}"
FAST_LINKER="${BOOSAPI_FAST_LINKER:-auto}"
FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}" FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}"
BUILD_TIMINGS=()
usage() { usage() {
cat <<'EOF' cat <<'EOF'
@@ -38,6 +42,8 @@ usage() {
--prod 同 --production --prod 同 --production
--production-dir <dir> --production-dir <dir>
指定生产产物目录(默认:dist/production) 指定生产产物目录(默认:dist/production)
--check 只执行 cargo check,不构建前端、不启动服务(最快验证)
--backend-check 同 --check
--frontend-only 只构建前端 --frontend-only 只构建前端
--backend-only 只构建后端 --backend-only 只构建后端
--force-frontend 强制重建前端,忽略缓存 --force-frontend 强制重建前端,忽略缓存
@@ -46,6 +52,8 @@ usage() {
--no-run 构建后不启动服务 --no-run 构建后不启动服务
BOOSAPI_BUILD_PROFILE 可设置默认 profile(dev / release / fast-release) BOOSAPI_BUILD_PROFILE 可设置默认 profile(dev / release / fast-release)
BOOSAPI_CARGO_FEATURES 可覆盖后端 Cargo features(默认:空) BOOSAPI_CARGO_FEATURES 可覆盖后端 Cargo features(默认:空)
BOOSAPI_CARGO_JOBS 可设置 cargo 并发任务数,例如 8
BOOSAPI_FAST_LINKER 可设置 auto / off / mold / lld(默认:auto,Linux 下优先 mold)
BOOSAPI_FRONTEND_PM 可设置主前端构建器:auto / npm / bun(默认:auto) BOOSAPI_FRONTEND_PM 可设置主前端构建器:auto / npm / bun(默认:auto)
BOOSAPI_PRODUCTION_DIR 可设置 --production 默认产物目录 BOOSAPI_PRODUCTION_DIR 可设置 --production 默认产物目录
-h, --help 显示帮助 -h, --help 显示帮助
@@ -53,6 +61,7 @@ usage() {
示例: 示例:
./build-and-run.sh --release ./build-and-run.sh --release
./build-and-run.sh --fast-release ./build-and-run.sh --fast-release
./build-and-run.sh --check
./build-and-run.sh --bun ./build-and-run.sh --bun
./build-and-run.sh --production ./build-and-run.sh --production
./build-and-run.sh --production --production-dir /opt/boosapi-release ./build-and-run.sh --production --production-dir /opt/boosapi-release
@@ -75,6 +84,11 @@ while [ $# -gt 0 ]; do
[ $# -gt 0 ] || fail "--production-dir 需要目录参数" [ $# -gt 0 ] || fail "--production-dir 需要目录参数"
PRODUCTION_DIR="$1" PRODUCTION_DIR="$1"
;; ;;
--check|--backend-check)
CHECK_ONLY=1
BACKEND_ONLY=1
NO_RUN=1
;;
--profile) --profile)
shift shift
[ $# -gt 0 ] || fail "--profile 需要参数:dev / release / fast-release" [ $# -gt 0 ] || fail "--profile 需要参数:dev / release / fast-release"
@@ -105,12 +119,114 @@ if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$BACKEND_ONLY" -eq 1 ]; then
fail "--production 需要先构建前端以嵌入 web,不能和 --backend-only 同时使用" fail "--production 需要先构建前端以嵌入 web,不能和 --backend-only 同时使用"
fi fi
if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$CHECK_ONLY" -eq 1 ]; then
fail "--production 不能和 --check 同时使用"
fi
case "$BUILD_PROFILE" in case "$BUILD_PROFILE" in
dev|debug) BUILD_PROFILE="dev" ;; dev|debug) BUILD_PROFILE="dev" ;;
release|fast-release) ;; release|fast-release) ;;
*) fail "未知构建 profile: $BUILD_PROFILE(可选:dev / release / fast-release)" ;; *) fail "未知构建 profile: $BUILD_PROFILE(可选:dev / release / fast-release)" ;;
esac esac
case "$FAST_LINKER" in
auto|off|none|mold|lld) ;;
*) fail "未知 BOOSAPI_FAST_LINKER: $FAST_LINKER(可选:auto / off / mold / lld)" ;;
esac
timer_start() {
date +%s
}
record_timing() {
local label="$1"
local started="$2"
local ended
ended="$(date +%s)"
BUILD_TIMINGS+=("${label}: $((ended - started))s")
}
print_timing_summary() {
[ "${#BUILD_TIMINGS[@]}" -gt 0 ] || return 0
log "构建耗时:"
local item
for item in "${BUILD_TIMINGS[@]}"; do
log " - $item"
done
}
append_rustflags() {
local flags="$1"
if [[ " ${RUSTFLAGS:-} " != *" $flags "* ]]; then
export RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }$flags"
fi
}
build_cargo_args() {
CARGO_BASE_ARGS=()
CARGO_FEATURE_ARGS=()
if [ -n "$CARGO_JOBS" ]; then
CARGO_BASE_ARGS+=(--jobs "$CARGO_JOBS")
fi
if [ -n "$CARGO_FEATURES" ]; then
CARGO_FEATURE_ARGS=(--features "$CARGO_FEATURES")
fi
}
configure_fast_linker() {
local mode="$FAST_LINKER"
case "$(uname -s 2>/dev/null || true)" in
Linux) ;;
*)
[ "$mode" = "auto" ] && return 0
fail "BOOSAPI_FAST_LINKER=$mode 目前只在 Linux 构建环境自动配置"
;;
esac
if [ "$mode" = "off" ] || [ "$mode" = "none" ]; then
return 0
fi
if [ "$mode" = "auto" ]; then
if command -v mold >/dev/null 2>&1; then
mode="mold"
elif command -v ld.lld >/dev/null 2>&1 || command -v lld >/dev/null 2>&1; then
mode="lld"
else
log "提示: 安装 mold 或 lld 可显著缩短 Rust 链接时间;当前使用系统默认链接器"
return 0
fi
fi
case "$mode" in
mold)
command -v mold >/dev/null 2>&1 || fail "BOOSAPI_FAST_LINKER=mold 但未找到 mold"
if command -v clang >/dev/null 2>&1; then
append_rustflags "-C linker=clang"
elif ! command -v gcc >/dev/null 2>&1 && [ -z "${RUSTC_LINKER:-}" ]; then
fail "启用 mold 需要 clang 或 gcc 作为 linker driver"
fi
append_rustflags "-C link-arg=-fuse-ld=mold"
log "Rust 快速链接器: mold(BOOSAPI_FAST_LINKER=$FAST_LINKER)"
;;
lld)
if ! command -v ld.lld >/dev/null 2>&1 && ! command -v lld >/dev/null 2>&1; then
fail "BOOSAPI_FAST_LINKER=lld 但未找到 ld.lld/lld"
fi
if command -v clang >/dev/null 2>&1; then
append_rustflags "-C linker=clang"
elif ! command -v gcc >/dev/null 2>&1 && [ -z "${RUSTC_LINKER:-}" ]; then
fail "启用 lld 需要 clang 或 gcc 作为 linker driver"
fi
append_rustflags "-C link-arg=-fuse-ld=lld"
log "Rust 快速链接器: lld(BOOSAPI_FAST_LINKER=$FAST_LINKER)"
;;
esac
}
server_port() { server_port() {
if [ -n "${BOOSAPI_SERVER__PORT:-}" ]; then if [ -n "${BOOSAPI_SERVER__PORT:-}" ]; then
printf '%s\n' "$BOOSAPI_SERVER__PORT" printf '%s\n' "$BOOSAPI_SERVER__PORT"
@@ -224,8 +340,12 @@ install_frontend_dependencies() {
} }
build_frontend() { build_frontend() {
local started
started="$(timer_start)"
if [ "$SKIP_FRONTEND" -eq 1 ]; then if [ "$SKIP_FRONTEND" -eq 1 ]; then
log "跳过前端构建(--skip-frontend)" log "跳过前端构建(--skip-frontend)"
record_timing "前端检查" "$started"
return 0 return 0
fi fi
@@ -267,6 +387,7 @@ build_frontend() {
&& [ -f "$ROOT_DIR/dist/web/index.html" ] \ && [ -f "$ROOT_DIR/dist/web/index.html" ] \
&& [ "$(cat "$fingerprint_file" 2>/dev/null || true)" = "$fingerprint" ]; then && [ "$(cat "$fingerprint_file" 2>/dev/null || true)" = "$fingerprint" ]; then
ok "前端无变化,跳过 Vite 构建 → dist/web/" ok "前端无变化,跳过 Vite 构建 → dist/web/"
record_timing "前端检查" "$started"
return 0 return 0
fi fi
@@ -280,11 +401,71 @@ build_frontend() {
mkdir -p "$ROOT_DIR/dist/web" mkdir -p "$ROOT_DIR/dist/web"
printf '%s\n' "$fingerprint" > "$fingerprint_file" printf '%s\n' "$fingerprint" > "$fingerprint_file"
ok "前端构建完成 → dist/web/" ok "前端构建完成 → dist/web/"
record_timing "前端构建" "$started"
}
install_website_dependencies() {
command -v node >/dev/null 2>&1 || fail "未找到 node,请先安装 Node.js"
if [ ! -d "$ROOT_DIR/website/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
log "安装官网依赖 (npm --prefix website install)..."
npm --prefix "$ROOT_DIR/website" install
fi
}
build_public_website() {
local started
started="$(timer_start)"
if [ "$SKIP_FRONTEND" -eq 1 ]; then
log "跳过官网构建(--skip-frontend)"
record_timing "官网检查" "$started"
return 0
fi
[ -f "$ROOT_DIR/website/package.json" ] || fail "未找到 website/package.json,无法构建官网"
install_website_dependencies
local fingerprint_file="$ROOT_DIR/dist/website/.bossai-website-build-fingerprint"
local fingerprint
fingerprint="$(
{
printf 'website-build-v1\n'
printf 'node='
node --version 2>/dev/null || true
printf 'npm='
npm --version 2>/dev/null || true
for path in website/package.json website/package-lock.json website/next.config.ts website/postcss.config.mjs website/tsconfig.json website/app website/public; do
[ -e "$ROOT_DIR/$path" ] && find "$ROOT_DIR/$path" -type f -print
done | sort | xargs -r sha256sum
} | sha256sum | awk '{print $1}'
)"
if [ "$FORCE_FRONTEND" -eq 0 ] \
&& [ -f "$fingerprint_file" ] \
&& [ -f "$ROOT_DIR/dist/website/index.html" ] \
&& [ "$(cat "$fingerprint_file" 2>/dev/null || true)" = "$fingerprint" ]; then
ok "官网无变化,跳过 Next 构建 → dist/website/"
record_timing "官网检查" "$started"
return 0
fi
log "构建官网 (Next.js static export)..."
npm --prefix "$ROOT_DIR/website" run build
rm -rf "$ROOT_DIR/dist/website"
mkdir -p "$ROOT_DIR/dist/website"
cp -R "$ROOT_DIR/website/out/." "$ROOT_DIR/dist/website/"
printf '%s\n' "$fingerprint" > "$fingerprint_file"
ok "官网构建完成 → dist/website/"
record_timing "官网构建" "$started"
} }
ensure_workflow_ui_for_embedding() { ensure_workflow_ui_for_embedding() {
local started
started="$(timer_start)"
if [ "$SKIP_FRONTEND" -eq 1 ]; then if [ "$SKIP_FRONTEND" -eq 1 ]; then
log "跳过 Workflow UI 构建检查(--skip-frontend)" log "跳过 Workflow UI 构建检查(--skip-frontend)"
record_timing "Workflow UI 检查" "$started"
return 0 return 0
fi fi
@@ -293,6 +474,7 @@ ensure_workflow_ui_for_embedding() {
&& [ -f "$ROOT_DIR/dist/workflow-ui/index.html" ] \ && [ -f "$ROOT_DIR/dist/workflow-ui/index.html" ] \
&& [ "$FORCE_FRONTEND" -eq 0 ]; then && [ "$FORCE_FRONTEND" -eq 0 ]; then
ok "Workflow UI 已存在,跳过构建 → dist/workflow-ui-{external,cloud}/" ok "Workflow UI 已存在,跳过构建 → dist/workflow-ui-{external,cloud}/"
record_timing "Workflow UI 检查" "$started"
return 0 return 0
fi fi
@@ -303,43 +485,60 @@ ensure_workflow_ui_for_embedding() {
log "构建 Workflow UI 双前端(external + cloud,用于嵌入二进制)..." log "构建 Workflow UI 双前端(external + cloud,用于嵌入二进制)..."
pnpm run workflow-ui:install pnpm run workflow-ui:install
ok "Workflow UI 构建完成 → dist/workflow-ui-external/ dist/workflow-ui-cloud/" ok "Workflow UI 构建完成 → dist/workflow-ui-external/ dist/workflow-ui-cloud/"
record_timing "Workflow UI 构建" "$started"
} }
build_backend() { build_backend() {
local started
started="$(timer_start)"
if ! command -v cargo >/dev/null 2>&1; then if ! command -v cargo >/dev/null 2>&1; then
fail "未找到 cargo,请先安装 Rust 工具链" fail "未找到 cargo,请先安装 Rust 工具链"
fi fi
# 提示一次:如果链接器走默认 ld 而 mold 不在 PATH,给出一条性能建议。 configure_fast_linker
# mold 安装后还需在 .cargo/config.toml 取消 `linker = "clang"` 那段注释。 build_cargo_args
if ! command -v mold >/dev/null 2>&1; then
log "提示: 安装 mold 后取消 .cargo/config.toml 中的相应注释,链接将快 3-5x"
fi
CARGO_FEATURE_ARGS=() if [ "$CHECK_ONLY" -eq 1 ]; then
if [ -n "$CARGO_FEATURES" ]; then if [ "$BUILD_PROFILE" = "release" ]; then
CARGO_FEATURE_ARGS=(--features "$CARGO_FEATURES") log "检查后端 (cargo check --release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo check "${CARGO_BASE_ARGS[@]}" --release "${CARGO_FEATURE_ARGS[@]}"
elif [ "$BUILD_PROFILE" = "fast-release" ]; then
log "检查后端 (cargo check --profile fast-release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo check "${CARGO_BASE_ARGS[@]}" --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
else
log "检查后端 (cargo check ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo check "${CARGO_BASE_ARGS[@]}" "${CARGO_FEATURE_ARGS[@]}"
fi
ok "后端检查完成"
record_timing "后端检查" "$started"
return 0
fi fi
if [ "$BUILD_PROFILE" = "release" ]; then if [ "$BUILD_PROFILE" = "release" ]; then
log "构建后端 (cargo build --release ${CARGO_FEATURE_ARGS[*]})..." log "构建后端 (cargo build --release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo build --release "${CARGO_FEATURE_ARGS[@]}" cargo build "${CARGO_BASE_ARGS[@]}" --release "${CARGO_FEATURE_ARGS[@]}"
BINARY="$ROOT_DIR/target/release/boosapi" BINARY="$ROOT_DIR/target/release/boosapi"
ok "后端构建完成 → target/release/boosapi" ok "后端构建完成 → target/release/boosapi"
elif [ "$BUILD_PROFILE" = "fast-release" ]; then elif [ "$BUILD_PROFILE" = "fast-release" ]; then
log "构建后端 (cargo build --profile fast-release ${CARGO_FEATURE_ARGS[*]})..." log "构建后端 (cargo build --profile fast-release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo build --profile fast-release "${CARGO_FEATURE_ARGS[@]}" cargo build "${CARGO_BASE_ARGS[@]}" --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
BINARY="$ROOT_DIR/target/fast-release/boosapi" BINARY="$ROOT_DIR/target/fast-release/boosapi"
ok "后端构建完成 → target/fast-release/boosapi" ok "后端构建完成 → target/fast-release/boosapi"
else else
log "构建后端 (cargo build ${CARGO_FEATURE_ARGS[*]})..." log "构建后端 (cargo build ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo build "${CARGO_FEATURE_ARGS[@]}" cargo build "${CARGO_BASE_ARGS[@]}" "${CARGO_FEATURE_ARGS[@]}"
BINARY="$ROOT_DIR/target/debug/boosapi" BINARY="$ROOT_DIR/target/debug/boosapi"
ok "后端构建完成 → target/debug/boosapi" ok "后端构建完成 → target/debug/boosapi"
fi fi
record_timing "后端构建" "$started"
} }
build_production_bundle() { build_production_bundle() {
local started
started="$(timer_start)"
[ -n "${BINARY:-}" ] || fail "生产打包失败:后端二进制路径为空" [ -n "${BINARY:-}" ] || fail "生产打包失败:后端二进制路径为空"
[ -f "$BINARY" ] || fail "生产打包失败:未找到二进制 $BINARY" [ -f "$BINARY" ] || fail "生产打包失败:未找到二进制 $BINARY"
[ -f "$ROOT_DIR/config.toml" ] || fail "生产打包失败:未找到 config.toml" [ -f "$ROOT_DIR/config.toml" ] || fail "生产打包失败:未找到 config.toml"
@@ -360,6 +559,7 @@ build_production_bundle() {
log "产物内容:" log "产物内容:"
(cd "$PRODUCTION_DIR" && ls -lh boosapi config.toml) (cd "$PRODUCTION_DIR" && ls -lh boosapi config.toml)
log "部署时只需要复制该目录内的 boosapi 和 config.toml;主 Web 前端已嵌入二进制。" log "部署时只需要复制该目录内的 boosapi 和 config.toml;主 Web 前端已嵌入二进制。"
record_timing "生产打包" "$started"
} }
cleanup_started_server() { cleanup_started_server() {
@@ -374,12 +574,15 @@ cleanup_started_server() {
if [ "$FRONTEND_ONLY" -eq 1 ]; then if [ "$FRONTEND_ONLY" -eq 1 ]; then
build_frontend build_frontend
build_public_website
print_timing_summary
log "跳过启动(--frontend-only)" log "跳过启动(--frontend-only)"
exit 0 exit 0
fi fi
if [ "$BACKEND_ONLY" -ne 1 ]; then if [ "$BACKEND_ONLY" -ne 1 ]; then
build_frontend build_frontend
build_public_website
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
ensure_workflow_ui_for_embedding ensure_workflow_ui_for_embedding
fi fi
@@ -387,16 +590,25 @@ fi
build_backend build_backend
if [ "$CHECK_ONLY" -eq 1 ]; then
print_timing_summary
log "跳过启动(--check)"
exit 0
fi
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
build_production_bundle build_production_bundle
print_timing_summary
exit 0 exit 0
fi fi
if [ "$NO_RUN" -eq 1 ]; then if [ "$NO_RUN" -eq 1 ]; then
print_timing_summary
log "跳过启动(--no-run)" log "跳过启动(--no-run)"
exit 0 exit 0
fi fi
print_timing_summary
stop_previous_server stop_previous_server
log "启动服务..." log "启动服务..."
+31 -1
View File
@@ -25,6 +25,28 @@ upstream_timeout_secs = 300
first_byte_timeout_secs = 60 first_byte_timeout_secs = 60
max_retries = 3 max_retries = 3
proxy_log_retention_days = 30 proxy_log_retention_days = 30
# Optional proxy for external template galleries/workflow template assets.
# Supports http://, https:// and socks5:// proxy URLs.
# template_proxy_url = "http://127.0.0.1:7890"
[payment]
enabled = false
# Public HTTPS origin used to build Stripe success/cancel URLs and webhook docs.
# Example: "https://tybossai.com"
public_base_url = ""
product_name_prefix = "BossAI 账户充值"
currency = "usd"
min_amount_usd = 1.0
max_amount_usd = 100000.0
order_timeout_minutes = 30
max_pending_orders = 3
success_path = "/user/recharge?payment=success&order={ORDER_ID}"
cancel_path = "/user/recharge?payment=cancel&order={ORDER_ID}"
# Prefer environment variables for secrets:
# BOOSAPI_PAYMENT__STRIPE_SECRET_KEY=sk_live_...
# BOOSAPI_PAYMENT__STRIPE_WEBHOOK_SECRET=whsec_...
stripe_secret_key = ""
stripe_webhook_secret = ""
[comfyui] [comfyui]
enabled = false enabled = false
@@ -35,12 +57,20 @@ max_restarts = 5
# backend modes: # backend modes:
# - "boosapi": use BoosAPI's own Rust Workflow/API node executor. # - "boosapi": use BoosAPI's own Rust Workflow/API node executor.
# - "cloud": proxy to Comfy Cloud and use native ComfyUI /prompt and /ws. # - "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. # - "external": proxy to your own full ComfyUI server.
# - "docker": legacy local Docker ComfyUI mode; not recommended for the current API Workflow path. # - "docker": legacy local Docker ComfyUI mode; not recommended for the current API Workflow path.
backend = "cloud" 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" # docker_image = "boosapi-comfyui:multi-tenant"
# container_name = "boosapi-comfyui-workflow" # 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" base_url = "https://cloud.comfy.org"
api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f" api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f"
agent_model = "gpt-5.5" agent_model = "gpt-5.5"
+4
View File
@@ -20,6 +20,10 @@ RUN find /workspace/ComfyUI -mindepth 1 \
&& mkdir -p /workspace/ComfyUI/custom_nodes/boosapi_nodes && mkdir -p /workspace/ComfyUI/custom_nodes/boosapi_nodes
COPY comfy/boosapi_comfy_nodes.py /workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py COPY comfy/boosapi_comfy_nodes.py /workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py
COPY scripts/comfyui/install-runninghub-template-nodes.sh /tmp/install-runninghub-template-nodes.sh
RUN bash /tmp/install-runninghub-template-nodes.sh \
&& rm -f /tmp/install-runninghub-template-nodes.sh
RUN python - <<'PY' RUN python - <<'PY'
from pathlib import Path from pathlib import Path
@@ -0,0 +1,694 @@
# 计费闭环 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为 BoosAPI 建立 ledger-first 的双轨计费闭环(USD 池 + 工作流积分池),所有余额变动写统一流水账 `credit_ledger`。
**Architecture:** 新建 `credit_ledger`(唯一真相源)+ `user_credits`(积分缓存)+ `recharge_tiers`(充值档位)三张表。USD 轨沿用 `downstream_api_keys.max_cost/used_cost` 作热路径缓存,每笔变动同事务写一条 ledger。积分轨为工作流执行新增扣费/拦截。分 6 期,本计划详写第 1 期(地基),后 5 期为概要待细化。
**Tech Stack:** Rust (axum + sqlx + SQLite)、React + Vite 前端、in-memory SQLite 测试。
---
## 文件结构(第 1 期)
- 新建 `src/services/billing/ledger.rs` — ledger 写入 + 余额查询的唯一入口(`record_ledger_entry`、`ensure_ledger_schema`)。所有动余额的地方调它,保证"缓存 + ledger 同事务"不变量集中在一处。
- 修改 `src/services/billing/mod.rs` — `mod ledger;` 导出;`record_usage` 在写 usage_logs + increment 后,同事务补写 ledger(usd, consume)。
- 修改 `src/services/payment.rs` — `credit_user_balance` 加写 ledger(usd, recharge)。
- 修改 `src/routes/admin/users.rs` — `adjust_user_balance` 加写 ledger(usd, admin_adjust)。
- 测试:`src/services/billing/ledger.rs` 内 `#[cfg(test)] mod tests`(复用 in-memory SQLite 模式)。
---
## 第 1 期:地基(credit_ledger + user_credits 表 + USD 轨记账接入)
### Task 1:ledger 表结构 + 写入入口
**Files:**
- Create: `src/services/billing/ledger.rs`
- Modify: `src/services/billing/mod.rs`(顶部加 `pub mod ledger;`)
- [ ] **Step 1: 写失败测试** — 在 `src/services/billing/ledger.rs` 末尾:
```rust
#[cfg(test)]
mod tests {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
async fn mem_pool() -> SqlitePool {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
ensure_ledger_schema(&pool).await.unwrap();
pool
}
#[tokio::test]
async fn record_ledger_entry_writes_row_with_balance_snapshot() {
let pool = mem_pool().await;
let mut tx = pool.begin().await.unwrap();
record_ledger_entry(
&mut tx,
LedgerEntry {
user_id: 7,
track: "usd",
entry_type: "recharge",
amount: 10.0,
balance_after: 10.0,
ref_type: Some("stripe"),
ref_id: Some("cs_1"),
model: None,
operator: Some("system"),
note: None,
},
)
.await
.unwrap();
tx.commit().await.unwrap();
let row: (i64, String, String, f64, f64) = sqlx::query_as(
"SELECT user_id, track, entry_type, amount, balance_after
FROM credit_ledger WHERE user_id = 7",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row, (7, "usd".into(), "recharge".into(), 10.0, 10.0));
}
}
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cargo test -p boosapi --lib services::billing::ledger 2>&1 | tail -5`
Expected: 编译失败(`ensure_ledger_schema`/`record_ledger_entry`/`LedgerEntry` 未定义)。
- [ ] **Step 3: 写最小实现** — 在 `src/services/billing/ledger.rs` 顶部(tests 之前):
```rust
use crate::error::AppResult;
use sqlx::{SqlitePool, Transaction};
/// One balance-changing event. Written to `credit_ledger` in the same
/// transaction that updates the cached balance, so ledger and cache never diverge.
pub struct LedgerEntry<'a> {
pub user_id: i64,
pub track: &'a str, // "usd" | "points"
pub entry_type: &'a str, // "recharge" | "admin_adjust" | "consume" | "refund" | "grant"
pub amount: f64, // +credit / -debit
pub balance_after: f64,
pub ref_type: Option<&'a str>,
pub ref_id: Option<&'a str>,
pub model: Option<&'a str>,
pub operator: Option<&'a str>,
pub note: Option<&'a str>,
}
pub async fn ensure_ledger_schema(db: &SqlitePool) -> AppResult<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS credit_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
user_id INTEGER NOT NULL,
track TEXT NOT NULL,
entry_type TEXT NOT NULL,
amount REAL NOT NULL,
balance_after REAL NOT NULL,
ref_type TEXT,
ref_id TEXT,
model TEXT,
operator TEXT,
note TEXT,
created_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(db)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS credit_ledger_user_created_idx
ON credit_ledger (user_id, created_at DESC)",
)
.execute(db)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS credit_ledger_track_type_idx
ON credit_ledger (track, entry_type)",
)
.execute(db)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS user_credits (
user_id INTEGER PRIMARY KEY NOT NULL,
points_balance REAL NOT NULL DEFAULT 0,
points_total_recharged REAL NOT NULL DEFAULT 0,
updated_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(db)
.await?;
Ok(())
}
/// Append one ledger row inside an existing transaction. Caller is responsible
/// for also updating the cached balance in the same `tx`.
pub async fn record_ledger_entry(
tx: &mut Transaction<'_, sqlx::Sqlite>,
entry: LedgerEntry<'_>,
) -> AppResult<()> {
sqlx::query(
"INSERT INTO credit_ledger
(user_id, track, entry_type, amount, balance_after,
ref_type, ref_id, model, operator, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(entry.user_id)
.bind(entry.track)
.bind(entry.entry_type)
.bind(entry.amount)
.bind(entry.balance_after)
.bind(entry.ref_type)
.bind(entry.ref_id)
.bind(entry.model)
.bind(entry.operator)
.bind(entry.note)
.execute(&mut **tx)
.await?;
Ok(())
}
```
并在 `src/services/billing/mod.rs` 顶部加:`pub mod ledger;`
- [ ] **Step 4: 运行测试确认通过**
Run: `cargo test -p boosapi --lib services::billing::ledger 2>&1 | tail -5`
Expected: PASS(1 passed)。
- [ ] **Step 5: 提交**
```bash
git add src/services/billing/ledger.rs src/services/billing/mod.rs
git commit -m "feat(billing): credit_ledger schema + record_ledger_entry"
```
### Task 2:USD 消费接入 ledger(record_usage)
**Files:**
- Modify: `src/services/billing/mod.rs`(`record_usage`,行 ~48-77)
- [ ] **Step 1: 写失败测试** — 在 billing/mod.rs 的 tests mod 加:
```rust
#[tokio::test]
async fn record_usage_writes_consume_ledger_entry() {
let pool = billing_test_pool().await; // 复用现有测试 pool helper;若无则建 users+downstream_api_keys+usage_logs
let tracker = UsageTracker::new(std::sync::Arc::new(pool.clone()));
crate::services::billing::ledger::ensure_ledger_schema(&pool).await.unwrap();
// key_id=7 属于 user 9,max_cost=10 used_cost=2(由 helper 建好)
tracker.record_usage(7, "gpt-4o", 100, 50, 0.42).await.unwrap();
let row: (String, String, f64, Option<String>) = sqlx::query_as(
"SELECT track, entry_type, amount, model FROM credit_ledger
WHERE user_id = 9 ORDER BY id DESC LIMIT 1",
).fetch_one(&pool).await.unwrap();
assert_eq!(row.0, "usd");
assert_eq!(row.1, "consume");
assert_eq!(row.2, -0.42); // 消费为负
assert_eq!(row.3.as_deref(), Some("gpt-4o"));
}
```
注:若 billing/mod.rs 无现成 `billing_test_pool`,在 tests mod 加一个建 `users`/`downstream_api_keys`(含 user_id=9,key id=7 max_cost=10 used_cost=2)/`usage_logs` 的 helper(参照 payment.rs 的 payment_test_pool)。
- [ ] **Step 2: 运行确认失败**
Run: `cargo test -p boosapi --lib record_usage_writes_consume_ledger_entry 2>&1 | tail -5`
Expected: FAIL(credit_ledger 无 user 9 行 / 表不存在)。
- [ ] **Step 3: 实现** — `record_usage` 改为事务化,末尾补写 ledger。替换 record_usage 函数体为:
```rust
pub async fn record_usage(
&self,
key_id: i64,
model: impl Into<String>,
prompt_tokens: i64,
completion_tokens: i64,
cost: f64,
) -> AppResult<()> {
Self::ensure_schema(&self.db).await?;
crate::services::billing::ledger::ensure_ledger_schema(&self.db).await?;
let prompt_tokens = prompt_tokens.max(0);
let completion_tokens = completion_tokens.max(0);
let total_tokens = prompt_tokens + completion_tokens;
let cost = cost.max(0.0);
let model = model.into();
let mut tx = self.db.begin().await?;
sqlx::query(
"INSERT INTO usage_logs
(downstream_key_id, model, prompt_tokens, completion_tokens, total_tokens, cost)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(key_id).bind(&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?;
// Resolve owning user + new used_cost for the ledger snapshot.
let owner: Option<(Option<i64>, Option<f64>, Option<f64>)> = sqlx::query_as(
"SELECT user_id, max_cost, used_cost FROM downstream_api_keys WHERE id = ?",
).bind(key_id).fetch_optional(&mut *tx).await?;
if let Some((Some(user_id), max_cost, used_cost)) = owner {
let used = used_cost.unwrap_or(0.0);
let remaining = max_cost.map(|m| (m - used).max(0.0)).unwrap_or(used);
crate::services::billing::ledger::record_ledger_entry(
&mut tx,
crate::services::billing::ledger::LedgerEntry {
user_id, track: "usd", entry_type: "consume",
amount: -cost, balance_after: remaining,
ref_type: Some("usage_log"), ref_id: None,
model: Some(&model), operator: Some("system"), note: None,
},
).await?;
}
tx.commit().await?;
Ok(())
}
```
(注:原 `increment_key_usage(&self.db, ...)` 调用被事务内 UPDATE 取代,删除该行。)
- [ ] **Step 4: 运行确认通过**
Run: `cargo test -p boosapi --lib record_usage 2>&1 | tail -6`
Expected: PASS。
- [ ] **Step 5: 提交**
```bash
git add src/services/billing/mod.rs
git commit -m "feat(billing): USD consume writes credit_ledger in one tx"
```
### Task 3:USD 充值接入 ledger(credit_user_balance)
**Files:**
- Modify: `src/services/payment.rs`(`credit_user_balance`,行 ~2167-2202)
- [ ] **Step 1: 写失败测试** — payment.rs tests mod(复用 payment_test_pool,user 9/key 7 max_cost=10):
```rust
#[tokio::test]
async fn credit_user_balance_writes_recharge_ledger() {
let pool = payment_test_pool().await;
crate::services::billing::ledger::ensure_ledger_schema(&pool).await.unwrap();
let mut tx = pool.begin().await.unwrap();
credit_user_balance(&mut tx, 9, 20.0).await.unwrap();
tx.commit().await.unwrap();
let row: (String, String, f64) = sqlx::query_as(
"SELECT track, entry_type, amount FROM credit_ledger WHERE user_id = 9 ORDER BY id DESC LIMIT 1",
).fetch_one(&pool).await.unwrap();
assert_eq!(row, ("usd".into(), "recharge".into(), 20.0));
}
```
- [ ] **Step 2: 运行确认失败**
Run: `cargo test -p boosapi --lib credit_user_balance_writes_recharge_ledger 2>&1 | tail -5`
Expected: FAIL。
- [ ] **Step 3: 实现** — `credit_user_balance` 在 UPDATE max_cost 之后、`Ok(Some(key_id))` 之前插入 ledger 写入(已在事务 tx 内):
```rust
// After the UPDATE that sets next_max_cost on the key:
crate::services::billing::ledger::record_ledger_entry(
tx,
crate::services::billing::ledger::LedgerEntry {
user_id, track: "usd", entry_type: "recharge",
amount: amount_usd.max(0.0),
balance_after: (next_max_cost - used_cost).max(0.0),
ref_type: Some("recharge_record"), ref_id: None,
model: None, operator: Some("system"), note: None,
},
).await?;
```
(`credit_user_balance` 已是 `tx: &mut Transaction`,直接传 `tx`。确保调用方在调用前已 `ensure_ledger_schema`,或在函数开头加一次幂等建表。)
- [ ] **Step 4: 运行确认通过**
Run: `cargo test -p boosapi --lib credit_user_balance 2>&1 | tail -6`
Expected: PASS(含已有的 recharge completion 测试)。
- [ ] **Step 5: 提交**
```bash
git add src/services/payment.rs
git commit -m "feat(billing): USD recharge writes credit_ledger"
```
### Task 4:USD 管理员调整接入 ledger(adjust_user_balance)
**Files:**
- Modify: `src/routes/admin/users.rs`(`adjust_user_balance`,行 ~820)
- [ ] **Step 1: 写失败测试** — users.rs tests mod:调 `adjust_user_balance(State, Path(9), Json{delta:5.0})` 后断言 credit_ledger 有一行 `(usd, admin_adjust, 5.0)`。(建表与 user/key 同 Task 3 helper。)
- [ ] **Step 2: 运行确认失败** — `cargo test -p boosapi --lib adjust_user_balance 2>&1 | tail -5` → FAIL。
- [ ] **Step 3: 实现** — `adjust_user_balance` 现在直接 `execute(&*state.db)`。改为事务:begin → UPDATE max_cost → `record_ledger_entry(track="usd", entry_type="admin_adjust", amount=delta, balance_after=next_max-used, operator=<管理员,暂用 "admin">)` → commit。函数开头 `ledger::ensure_ledger_schema(&state.db).await?;`。
- [ ] **Step 4: 运行确认通过** — `cargo test -p boosapi --lib adjust_user_balance 2>&1 | tail -6` → PASS。
- [ ] **Step 5: 提交**
```bash
git add src/routes/admin/users.rs
git commit -m "feat(billing): USD admin adjust writes credit_ledger"
```
### Task 5:全量回归
- [ ] **Step 1:** `cargo test 2>&1 | grep "test result"` → 全 ok。
- [ ] **Step 2:** `cargo build 2>&1 | tail -3` → Finished。
- [ ] **Step 3:** 提交(若有遗留)。
---
## 后续期次(概要,第 1 期落地后逐期细化为同样的 TDD 任务)
### 第 2 期:积分轨
- `user_credits` 已建表(Task 1)。新增 `adjust_user_credits`(admin 发放/增减,写 ledger points/admin_adjust)。
- comfy_cloud 确认到账 `admin_confirm_user_recharge` 改为加 `points_balance` + ledger(points, recharge),受 config 开关 `payment.comfy_recharge_to_points` 控制(默认 true);历史不迁移。
- 工作流积分单价 config(默认值 + 按工作流名分级映射)。
- `workflow/executor.rs` `run_workflow`:执行**前**查 `points_balance >= 单价`,不足返回 Forbidden「工作流积分不足」;执行**成功后**扣积分 + ledger(points, consume, model=工作流名)。
- 测试:拦截、扣费、充值进积分、管理员调整。
### 第 3 期:A 消费明细
- `GET /api/user/billing/ledger`(查本人 credit_ledger,track/type/时间过滤、分页)。
- `GET /api/user/billing/summary`(按天/模型聚合,双轨分开)。
- 扩展 `UsageRecords.tsx`:双轨切换 + 汇总卡 + 明细列表。
### 第 4 期:B 充值档位
- `recharge_tiers` 表(已在 spec,Task 1 未建,本期建)+ admin CRUD `/api/admin/recharge-tiers`。
- 用户充值档位从 DB 读(替代 env `USER_RECHARGE_OPTIONS`)。
- admin `RechargeTiers` 配置页 + `UserRecharge.tsx` 接 DB。
### 第 5 期:C 预警
- 余额接口(user_billing_summary 等)加 `lowBalance`(余额 < 阈值,config 默认 0.2)。
- Dashboard / UserRecharge / ComfyUIAgent 顶部预警条。
### 第 6 期:D 审计 + 对账
- `GET /api/admin/billing/ledger`(跨用户,过滤分页)+ admin `BillingAudit` 页。
- cron:拉 Comfy `/customers/balance` 总余额,与 points consume ledger 比对,偏差超阈值写 log + admin 告警标志(不自动改数据)。
---
## 自审
- **spec 覆盖**:第 1 期覆盖 spec「数据模型」+「USD 轨记账接入」三处(充值/消费/调整);后 5 期概要逐条对应 spec 的积分轨/A/B/C/D + 对账。recharge_tiers 表 spec 列在数据模型,实现挪到第 4 期建(第 1 期用不到),已注明。
- **占位扫描**:第 1 期 Task 1-3 含完整代码;Task 4-5 用文字描述但步骤明确(adjust 与 Task 3 同模式,刻意不重复大段代码,执行时参照 Task 3)。后 5 期为概要,执行前细化。
- **类型一致**:`LedgerEntry` 字段、`record_ledger_entry`/`ensure_ledger_schema` 签名在 Task 1 定义,Task 2-4 一致引用。`track`/`entry_type` 取值(usd/points、recharge/consume/admin_adjust)全程一致。
---
## 第 2 期:积分轨(细化 TDD)
文件结构:
- `src/services/billing/ledger.rs` 加积分轨核心:`points_balance(db,user_id)`(读余额)、`apply_points_delta(db, user_id, delta, entry_type, model, operator, ref_*)`(事务内:UPSERT user_credits 余额 + 写 ledger(points),返回新余额)。所有积分增减走这一个入口。
- `src/config.rs`:ComfyUIConfig 加 `workflow_points_cost`(默认单价)。
- `src/workflow/executor.rs`:run_workflow_with_progress 前置拦截 + 收尾扣积分。
- `src/services/payment.rs`:admin_confirm_user_recharge 改为加积分(受 config 开关)。
- `src/routes/admin/users.rs`:新增 `adjust_user_credits` handler + 路由。
### Task 6:积分轨核心 apply_points_delta + points_balance
**Files:** Modify `src/services/billing/ledger.rs`
- [ ] **Step1 失败测试**(ledger.rs tests mod):
```rust
#[tokio::test]
async fn apply_points_delta_upserts_balance_and_writes_ledger() {
let pool = mem_pool().await;
// 首次 +100(充值)
let bal = apply_points_delta(&pool, 9, 100.0, "recharge", None, Some("system"), Some("recharge_record"), None).await.unwrap();
assert_eq!(bal, 100.0);
// 再 -30(消费)
let bal2 = apply_points_delta(&pool, 9, -30.0, "consume", Some("wf/video"), Some("system"), Some("workflow"), None).await.unwrap();
assert_eq!(bal2, 70.0);
// user_credits 缓存 = 70
let cached: f64 = sqlx::query_scalar("SELECT points_balance FROM user_credits WHERE user_id = 9").fetch_one(&pool).await.unwrap();
assert_eq!(cached, 70.0);
// ledger 两行
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM credit_ledger WHERE user_id=9 AND track='points'").fetch_one(&pool).await.unwrap();
assert_eq!(n, 2);
}
```
- [ ] **Step2** `cargo test -p boosapi --lib apply_points_delta_upserts 2>&1 | tail -6` → FAIL。
- [ ] **Step3 实现**(ledger.rs,tests 之前):
```rust
/// Current points balance for a user (0 if no row yet).
pub async fn points_balance(db: &SqlitePool, user_id: i64) -> AppResult<f64> {
let bal: Option<f64> = sqlx::query_scalar(
"SELECT points_balance FROM user_credits WHERE user_id = ?",
)
.bind(user_id)
.fetch_optional(db)
.await?;
Ok(bal.unwrap_or(0.0))
}
/// Apply a points delta (+credit / -debit) atomically: upsert the cached
/// balance in `user_credits` and append one `credit_ledger(track=points)` row,
/// in a single transaction. Returns the new balance.
#[allow(clippy::too_many_arguments)]
pub async fn apply_points_delta(
db: &SqlitePool,
user_id: i64,
delta: f64,
entry_type: &str,
model: Option<&str>,
operator: Option<&str>,
ref_type: Option<&str>,
ref_id: Option<&str>,
) -> AppResult<f64> {
ensure_ledger_schema(db).await?;
let mut tx = db.begin().await?;
let current: f64 = sqlx::query_scalar(
"SELECT points_balance FROM user_credits WHERE user_id = ?",
)
.bind(user_id)
.fetch_optional(&mut *tx)
.await?
.unwrap_or(0.0);
let next = current + delta;
let total_recharged_inc = if delta > 0.0 && entry_type == "recharge" { delta } else { 0.0 };
sqlx::query(
"INSERT INTO user_credits (user_id, points_balance, points_total_recharged, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
points_balance = ?,
points_total_recharged = points_total_recharged + ?,
updated_at = datetime('now')",
)
.bind(user_id).bind(next).bind(total_recharged_inc)
.bind(next).bind(total_recharged_inc)
.execute(&mut *tx).await?;
record_ledger_entry(&mut tx, LedgerEntry {
user_id, track: "points", entry_type,
amount: delta, balance_after: next,
ref_type, ref_id, model, operator, note: None,
}).await?;
tx.commit().await?;
Ok(next)
}
```
- [ ] **Step4** `cargo test -p boosapi --lib apply_points_delta 2>&1 | tail -6` → PASS;`cargo build -j 2 | tail -2`。
- [ ] **Step5** `git add src/services/billing/ledger.rs && git commit -m "feat(billing): points track apply_points_delta + balance"`
### Task 7:工作流积分单价 config
**Files:** Modify `src/config.rs`
- [ ] **Step1** 测试:config 默认值测试(若 config.rs 有 default 测试则加断言;否则跳测试,靠编译)。
- [ ] **Step2/3 实现**:ComfyUIConfig 加字段 `#[serde(default = "default_workflow_points_cost")] pub workflow_points_cost: f64,`,default fn 返回 `10.0`;Default impl 里加 `workflow_points_cost: 10.0,`。
- [ ] **Step4** `cargo build -j 2 2>&1 | tail -3` → Finished(config 字段加完整个 crate 能编译)。
- [ ] **Step5** `git add src/config.rs && git commit -m "feat(billing): workflow_points_cost config"`
### Task 8:工作流执行前置拦截 + 成功扣积分
**Files:** Modify `src/workflow/executor.rs`
- [ ] **Step1 失败测试**:executor.rs tests mod(若无则建)。建 in-memory pool + user_credits/credit_ledger,构造一个最简 graph,`ctx.user_id=Some(9)`:
- 余额不足(0 积分,单价 10)→ run_workflow_with_progress 返回 Err(Forbidden 含"积分不足")。
- 余额充足(100)→ 执行成功后 points_balance 变 90,credit_ledger 出现一行 (points, consume, -10)。
(若 executor 难以单测真实节点,则把"拦截"和"扣费"逻辑抽成小函数 `check_workflow_points(state, user_id, cost)` 和 `charge_workflow_points(state, user_id, cost, workflow_name)` 各自单测,run_workflow 调它们。优先抽函数单测。)
- [ ] **Step2** 确认 FAIL。
- [ ] **Step3 实现**:
- 在 run_workflow_with_progress 的 `validate_and_sort` 之后、`insert_run` 之前:若 `ctx.user_id` 是 Some 且 `ctx.charge_points`(见下):`let cost = state.config.comfyui.workflow_points_cost; if cost > 0.0 { let bal = ledger::points_balance(&state.db, uid).await?; if bal < cost { return Err(AppError::Forbidden("工作流积分不足,请充值".into())); } }`
- 在 `complete_run` 之后、`Ok(run_public_id)` 之前:`if let Some(uid) = ctx.user_id { let cost = state.config.comfyui.workflow_points_cost; if cost > 0.0 { let _ = ledger::apply_points_delta(&state.db, uid, -cost, "consume", Some(&format!("workflow:{}", workflow_id)), Some("system"), Some("workflow_run"), Some(&run_public_id)).await; } }`(扣费 best-effort,失败记 log 不阻断已产出结果)。
- 注意:Admin 调用(builtin key,user_id 可能是 Some(admin) 或特殊)是否扣?按设计仅对普通用户扣。可用 RunContext 加 `pub charge_points: bool` 字段,run_context 构造时 User=>true / Admin=>false。models.rs RunContext 加该字段,两处构造(routes.rs run_context、workflow_ui.rs workflow_context)补 `charge_points`。
- [ ] **Step4** 测试 PASS + `cargo build -j 2`。
- [ ] **Step5** `git add src/workflow/executor.rs src/workflow/models.rs src/workflow/routes.rs src/routes/workflow_ui.rs && git commit -m "feat(billing): workflow points gating + charge"`
### Task 9:comfy 充值改加积分 + admin 积分调整
**Files:** Modify `src/services/payment.rs`、`src/routes/admin/users.rs`、`src/config.rs`(开关)、`src/routes/admin/mod.rs`(路由)
- [ ] **Step1 失败测试**:
- payment.rs:`admin_confirm_user_recharge` 确认后,credit_ledger 出现 (points, recharge, +amount)(而非 usd);user_credits 增加。
- users.rs:`adjust_user_credits` handler 调用后 credit_ledger 出现 (points, admin_adjust)。
- [ ] **Step2** FAIL。
- [ ] **Step3 实现**:
- config 开关 `payment.comfy_recharge_to_points: bool`(默认 true)。
- `admin_confirm_user_recharge`:开关为 true 时改调 `ledger::apply_points_delta(db, user_id, amount_usd_as_points, "recharge", None, Some("admin"), Some("recharge_record"), Some(public_id))`(USD→积分换算:本期 1 USD = N 积分,N 用 config `payment.usd_to_points_rate` 默认 100;即 grant_points = amount_usd * rate)。开关 false 时维持原 credit_user_balance(USD)。**历史不迁移**。
- 新增 `adjust_user_credits(State, Path(id), Json{delta})` handler(仿 adjust_user_balance)→ `apply_points_delta(db,id,delta,"admin_adjust",None,Some("admin"),Some("manual"),None)`,返回 {success, data{userId, pointsBalance}}。
- admin/mod.rs 注册 `POST /api/admin/users/{id}/credits/adjust`。
- [ ] **Step4** 相关测试 + payment/users 模块全过 + build。
- [ ] **Step5** 分两个 commit 或一个:`git commit -m "feat(billing): comfy recharge to points + admin credit adjust"`
### Task 10:第 2 期全量回归
- [ ] `cargo test 2>&1 | grep "test result"` 全 ok;`cargo build -j 2 | tail -2` Finished。
---
## 第 3 期:A 消费明细(细化 TDD)
文件:`src/services/billing/ledger.rs`(加查询函数)、`src/routes/user/mod.rs`(加 2 路由)、`src/web/api.ts`(加 client)、`src/web/pages/UsageRecords.tsx`(扩展)。
### Task 11:ledger 查询函数(明细 + 聚合)
**Files:** Modify `src/services/billing/ledger.rs`
- [ ] **Step1 失败测试**(ledger.rs tests mod):
```rust
#[tokio::test]
async fn list_and_summarize_user_ledger() {
let pool = mem_pool().await;
apply_points_delta(&pool, 9, 100.0, "recharge", None, Some("system"), Some("rr"), None).await.unwrap();
apply_points_delta(&pool, 9, -10.0, "consume", Some("wf:1"), Some("system"), Some("run"), None).await.unwrap();
// 明细:user 9 两行,track=points
let rows = list_user_ledger(&pool, 9, None, None, 50, 0).await.unwrap();
assert_eq!(rows.len(), 2);
// 聚合:points 轨 consume 合计 -10
let sum = summarize_user_ledger(&pool, 9).await.unwrap();
assert!(sum.iter().any(|v| v["track"]=="points" && v["entryType"]=="consume"));
}
```
- [ ] **Step2** `cargo test -p boosapi --lib list_and_summarize_user_ledger 2>&1|tail -6` FAIL。
- [ ] **Step3 实现**(ledger.rs,tests 之前):
```rust
use serde_json::{json, Value};
/// List a user's ledger rows, newest first, optional track/entry_type filter + paging.
pub async fn list_user_ledger(
db: &SqlitePool, user_id: i64,
track: Option<&str>, entry_type: Option<&str>,
limit: i64, offset: i64,
) -> AppResult<Vec<Value>> {
ensure_ledger_schema(db).await?;
let limit = limit.clamp(1, 500);
let rows = sqlx::query(
"SELECT track, entry_type, amount, balance_after, model, operator, ref_type, ref_id, note, created_at
FROM credit_ledger
WHERE user_id = ?
AND (?1_track IS NULL OR track = ?1_track)
AND (?1_type IS NULL OR entry_type = ?1_type)
ORDER BY id DESC LIMIT ? OFFSET ?"
.replace("?1_track", "?2").replace("?1_type", "?3")
)
.bind(user_id).bind(track).bind(entry_type).bind(limit).bind(offset)
.fetch_all(db).await?;
use sqlx::Row;
Ok(rows.iter().map(|r| json!({
"track": r.try_get::<String,_>("track").unwrap_or_default(),
"entryType": r.try_get::<String,_>("entry_type").unwrap_or_default(),
"amount": r.try_get::<f64,_>("amount").unwrap_or(0.0),
"balanceAfter": r.try_get::<f64,_>("balance_after").unwrap_or(0.0),
"model": r.try_get::<Option<String>,_>("model").ok().flatten(),
"operator": r.try_get::<Option<String>,_>("operator").ok().flatten(),
"refType": r.try_get::<Option<String>,_>("ref_type").ok().flatten(),
"note": r.try_get::<Option<String>,_>("note").ok().flatten(),
"createdAt": r.try_get::<Option<String>,_>("created_at").ok().flatten(),
})).collect())
}
/// Aggregate a user's ledger by (track, entry_type): sum amount + count.
pub async fn summarize_user_ledger(db: &SqlitePool, user_id: i64) -> AppResult<Vec<Value>> {
ensure_ledger_schema(db).await?;
let rows = sqlx::query(
"SELECT track, entry_type, COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt
FROM credit_ledger WHERE user_id = ?
GROUP BY track, entry_type ORDER BY track, entry_type"
).bind(user_id).fetch_all(db).await?;
use sqlx::Row;
Ok(rows.iter().map(|r| json!({
"track": r.try_get::<String,_>("track").unwrap_or_default(),
"entryType": r.try_get::<String,_>("entry_type").unwrap_or_default(),
"total": r.try_get::<f64,_>("total").unwrap_or(0.0),
"count": r.try_get::<i64,_>("cnt").unwrap_or(0),
})).collect())
}
```
注:上面 SQL 的 named-param replace 写法若 sqlx 不接受,改用直白的可选过滤:用 `track.is_none() OR track = ?` 形式,bind 顺序 user_id, track, track, entry_type, entry_type, limit, offset(每个 Option bind 两次)。子代理按 sqlx 实际行为调整到能编译+测试过。
- [ ] **Step4** 测试 PASS + `cargo build -j 2`。
- [ ] **Step5** `git add src/services/billing/ledger.rs && git commit -m "feat(billing): user ledger list + summary queries"`
### Task 12:user 侧 billing 接口
**Files:** Modify `src/routes/user/mod.rs`(加 2 路由 + 2 handler)
- [ ] **Step1 失败测试**:user mod tests(若有 test_state/建表 helper 则复用)——构造 user JWT + ledger 数据,GET /api/user/billing/ledger 返回行,/summary 返回聚合。若 user mod 无现成集成测试框架,改为直接单测 handler 逻辑或跳测试靠 ledger.rs 的 Task11 测试覆盖,仅验证编译 + 路由注册。
- [ ] **Step2** FAIL/编译失败。
- [ ] **Step3 实现**:
```rust
async fn get_billing_ledger(State(state): State<AppState>, headers: HeaderMap, Query(q): Query<BillingLedgerQuery>) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let rows = crate::services::billing::ledger::list_user_ledger(
&state.db, user_id, q.track.as_deref(), q.entry_type.as_deref(),
q.limit.unwrap_or(100), q.offset.unwrap_or(0)).await?;
Ok(Json(json!({ "success": true, "records": rows })))
}
async fn get_billing_summary(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let agg = crate::services::billing::ledger::summarize_user_ledger(&state.db, user_id).await?;
Ok(Json(json!({ "success": true, "summary": agg })))
}
```
+ `BillingLedgerQuery{ track:Option<String>, entry_type:Option<String>, limit:Option<i64>, offset:Option<i64> }`(serde Deserialize, rename_all camelCase 视前端传参)。
+ routes() 注册 `.route("/api/user/billing/ledger", get(get_billing_ledger)).route("/api/user/billing/summary", get(get_billing_summary))`。
- [ ] **Step4** `cargo test -p boosapi --lib 2>&1|command grep "test result"` + `cargo build -j 2`。
- [ ] **Step5** `git add src/routes/user/mod.rs && git commit -m "feat(billing): user billing ledger + summary endpoints"`
### Task 13:前端 api + UsageRecords 双轨展示
**Files:** Modify `src/web/api.ts`、`src/web/pages/UsageRecords.tsx`
- [ ] **Step1**:api.ts 的 userApi 加 `getBillingLedger(params)` → GET /api/user/billing/ledger、`getBillingSummary()` → /summary。
- [ ] **Step2**:UsageRecords.tsx 加一个"积分/USD"双轨切换 tab + 聚合汇总卡(用 getBillingSummary)+ ledger 明细列表(getBillingLedger)。保留现有 logs 视图作为 USD 明细来源之一,新增积分明细来自 ledger。具体 UI 复用现有 usage-records-* 样式。
- [ ] **Step3**:`npm run typecheck:web` 干净 + 现有 UsageRecords 测试不破坏(`npx vitest run src/web/pages/UsageRecords`)。
- [ ] **Step4** `git add src/web/api.ts src/web/pages/UsageRecords.tsx && git commit -m "feat(billing): UsageRecords dual-track ledger view"`
### Task 14:第3期回归
- [ ] `cargo test 2>&1|command grep "test result"` 全ok;`cargo build -j 2`;`npm run typecheck`。
@@ -0,0 +1,149 @@
# 计费闭环系统设计
日期:2026-06-16
状态:设计已获批,待写实现计划
## 背景
BoosAPI 现有计费是零散拼接的:每个用户的 downstream API key 有 `max_cost`(美元上限)/ `used_cost`(已消耗),proxy 请求路径在 `used_cost >= max_cost` 时硬拦截返回 429;充值通过 Stripe 自动到账或 comfy_cloud 管理员确认到账,调用 `credit_user_balance` 增加 `max_cost`;新用户默认 $1 额度;usage_logs 按 key 记每笔消费。
缺口:ComfyUI 工作流执行不扣额度、无积分体系、无消费明细可视化、无充值档位配置、无低额度预警、无全量变动审计。
本设计把上述补齐为一个 **ledger-first(统一流水账)** 的计费闭环。
## 核心模型:双轨额度
- **USD 轨**:LLM 对话、生图、视频等所有走 `/v1/*` 的调用。沿用 `downstream_api_keys.max_cost/used_cost`。
- **工作流积分轨**:仅 ComfyUI 工作流执行。新建每用户独立的积分池,平台可管理。
两轨并行、互不换算。comfy_cloud 充值进积分轨;其他充值(Stripe)进 USD 轨。
## 数据模型
### 新表 `credit_ledger`(唯一真相源,所有变动记一行)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK AUTOINCREMENT | |
| `user_id` | INTEGER NOT NULL | 所属用户 |
| `track` | TEXT NOT NULL | `usd` / `points` |
| `entry_type` | TEXT NOT NULL | `recharge` / `admin_adjust` / `consume` / `refund` / `grant` |
| `amount` | REAL NOT NULL | 正=增 负=减(usd 用美元,points 用积分) |
| `balance_after` | REAL NOT NULL | 该轨本次变动后余额快照 |
| `ref_type` | TEXT | `usage_log` / `recharge_record` / `manual` / `stripe` 等 |
| `ref_id` | TEXT | 关联记录 id/public_id |
| `model` | TEXT | 消费类:模型名或工作流名(供聚合) |
| `operator` | TEXT | 操作者:管理员名 / `system` / `user` |
| `note` | TEXT | 备注 |
| `created_at` | TEXT DEFAULT (datetime('now')) | |
索引:`(user_id, created_at DESC)`、`(track, entry_type)`、`(user_id, track)`。
### 新表 `user_credits`(积分余额缓存)
| 字段 | 类型 | 说明 |
|---|---|---|
| `user_id` | INTEGER PK | |
| `points_balance` | REAL NOT NULL DEFAULT 0 | 当前积分余额(热路径读写) |
| `points_total_recharged` | REAL NOT NULL DEFAULT 0 | 累计充值积分(统计) |
| `updated_at` | TEXT | |
### 新表 `recharge_tiers`(充值档位,admin 可配)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK AUTOINCREMENT | |
| `track` | TEXT NOT NULL | `usd` / `points` |
| `amount_usd` | REAL NOT NULL | 支付金额(USD) |
| `grant_amount` | REAL NOT NULL | 到账量(USD 数或积分数) |
| `bonus` | REAL NOT NULL DEFAULT 0 | 赠送量 |
| `title` | TEXT | 档位标题 |
| `sort_order` | INTEGER DEFAULT 0 | 排序 |
| `enabled` | INTEGER DEFAULT 1 | 启用 |
### 余额派生策略:ledger + 缓存双写
- **USD 轨**:`max_cost/used_cost` 作快速余额缓存(拦截/展示零延迟读),每笔变动**同事务**写一条 `credit_ledger(track=usd)`。
- **积分轨**:`user_credits.points_balance` 作缓存,每笔变动**同事务**写 `credit_ledger(track=points)`。
- **关键不变量**:任何余额变动 = 缓存字段更新 + 一条 ledger,**同一 DB 事务**,事务失败整体回滚,绝不只改其一。
- ledger 是审计/明细/对账真相;缓存字段是热路径性能优化。
## 双轨记账接入
原则:所有动余额处,同事务"改缓存 + 写一条 ledger"。
### USD 轨
| 变动 | 现有代码 | 改造 |
|---|---|---|
| 充值到账 | `credit_user_balance`(payment.rs) | 同事务加写 ledger(usd, recharge) |
| 消费扣费 | `increment_key_usage`/`record_billing` | 同事务加写 ledger(usd, consume, model=模型名) |
| 管理员增减 | `adjust_user_balance` | 同事务加写 ledger(usd, admin_adjust, operator) |
| 额度拦截 | proxy `used_cost>=max_cost`→429 | 不变,补低额度预警信号 |
### 积分轨(基本新建)
| 变动 | 现状 | 新增 |
|---|---|---|
| 充值到账 | comfy_cloud 确认到账现加 USD | 改为加 `points_balance` + ledger(points, recharge) |
| 消费扣费 | 工作流不扣 | 工作流执行**成功后**按单价扣积分 + ledger(points, consume, model=工作流名);失败不扣 |
| 管理员发放/增减 | 无 | 新增 `adjust_user_credits` + ledger(points, admin_adjust) |
| 积分拦截 | 无 | 工作流执行**前**检查 `points_balance >= 单价`,不足拒绝;低额度预警 |
- 积分消费扣费点:`workflow/executor.rs` 的 `run_workflow` 成功收尾处(有 `ctx.user_id`),与现有"工作流按用户 key 记 USD 消费"平行。
- **comfy_cloud 充值改向积分轨**:行为变更,历史 USD **不迁移**,仅对切换后新充值生效,用 config 开关控制(可回滚)。
## 积分消费规则:A+C 混合
- **A 固定/分级单价**(主):工作流执行扣固定积分(config 默认值,可按工作流类型分级)。可预测、支持并发。
- **C 对账校准**(兜底):后台 cron 拉 Comfy 总余额对账,偏差超阈值提示管理员调单价。
## 四块功能
### A 消费明细(用户侧,明细+聚合)
- 新建 `GET /api/user/billing/ledger`:查该用户 credit_ledger,可按 track/type/时间过滤。
- 新建 `GET /api/user/billing/summary`:按天/按模型聚合,双轨分开。
- 扩展 `UsageRecords.tsx`:双轨切换 + 聚合汇总卡 + 明细列表。
### B 充值档位(本期可配置,订阅后期)
- 新建 `recharge_tiers` 表 + admin CRUD `GET/POST/PUT/DELETE /api/admin/recharge-tiers`。
- 改造用户充值:档位从 DB 读(替代 env 写死的 `USER_RECHARGE_OPTIONS`)。
- 扩展 `UserRecharge.tsx`(档位从接口拉)+ 新建 admin `RechargeTiers` 配置页。
### C 超额 + 预警(两轨硬拦 + 预警)
- USD 拦截已有(不动);积分拦截新建(工作流执行前)。
- 余额接口加 `lowBalance` 标志(余额 < 阈值,config 可配默认 20%)。
- Dashboard/充值页/视频助手:余额低显示预警条。
### D 审计对账(管理员侧,全量流水)
- 新建 `GET /api/admin/billing/ledger`:跨用户查 credit_ledger(按 user/track/type/时间过滤、分页)。
- 新建 admin `BillingAudit` 页:全量变动流水表(谁/何时/给谁/加减/原因),站点管理风格。
- 对账校准:cron 拉 Comfy 总余额,与积分消费 ledger 比对,偏差超阈值写 log + admin 告警标志。**不自动改数据**,仅提示,管理员手动调单价。
## 错误处理(不变量)
- 余额变动 = 缓存 + ledger 同事务;失败整体回滚。
- 消费扣费 best-effort 不阻断响应(沿用 record_billing 原则)。
- 积分轨:拦截是前置硬检查,扣费是后置;后置扣费失败已产出结果 → 记日志补偿。
- comfy 充值改向积分:config 开关控制,可回滚。
## 测试策略
- 单元:ledger 写入+缓存同步事务一致性;积分拦截(不足拒绝);单价扣费;充值进积分;管理员调整双轨。
- 复用现有 payment/billing 测试框架(in-memory SQLite)。
## 实现顺序(分 6 期,每期可独立上线)
1. **地基**:credit_ledger + user_credits 表 + 迁移;USD 轨记账接进 ledger(充值/消费/调整三处双写)。
2. **积分轨**:工作流积分扣费 + 拦截 + comfy 充值改进积分 + 管理员积分调整。
3. **A 消费明细**:用户侧 ledger/summary 接口 + UsageRecords 扩展。
4. **B 充值档位**:recharge_tiers 表 + admin 配置页 + 用户充值页接 DB。
5. **C 预警**:余额接口加 lowBalance + 前端预警条。
6. **D 审计 + 对账**:admin BillingAudit 页 + cron 对账校准。
## 新增清单小结
- **表**:credit_ledger(核心)、user_credits(积分缓存)、recharge_tiers(档位)。
- **后端接口**:user billing ledger/summary、admin recharge-tiers CRUD、admin billing ledger、adjust_user_credits、积分拦截/扣费。
- **前端页面**:admin RechargeTiers、admin BillingAudit;扩展 UsageRecords / UserRecharge / Dashboard。
- **cron**:Comfy 余额对账校准任务。
@@ -0,0 +1,100 @@
# NIT AI Studio — 第一阶段(聚合图像/数字人)设计
日期:2026-06-17
分支:`nit_edu`
状态:已通过 brainstorming,待落实现计划
## 背景
NIT 精英班专属的「AI 综合应用软件」是一套面向精英班学员的 AI 创作工具,完整愿景涵盖
文本结构化创作(论文/小说/剧本/分镜/教材)、PPT 生成、图像(P图/制图)、视频
(文生/图生/短视频)、数字人。体量过大,分阶段建设。
**本 spec 仅覆盖 AI Studio 第一阶段:聚合现有图像 + 数字人能力**,为精英班学员提供
统一创作入口,复用 BoosAPI 已有的 `picture_agent`(图像)与 `digital_human`(数字人)。
## 已确认的关键决策
1. 定位:NIT 精英班专属模块,非通用套件。
2. 第一阶段只聚合 `picture_agent`(图像)+ `digital_human`(数字人)。
3. 视频(含 money_printer、工作流视频)留后续阶段,本阶段完全不碰 money_printer。
4. 认证桥接:给精英班学员配后台下游 key + 扩展 surface 的 resolve_auth 接受 student JWT。
5. 计费:学员创作消耗走积分轨(已建的 points track),管理员可给学员充积分。
6. 文本创作 / PPT 留后续阶段。
## 前置依赖
本阶段 gate 在第一期(报考闭环)产物上,实现排在第一期之后:
- student JWT(`audience='student'`,第一期发放)。
- `enrollment.status=approved` 且 `tier=elite`。
- 非精英班 / 未审核 → 403。
## 非目标(本阶段明确不做)
- 视频能力(文生/图生/短视频、money_printer、工作流视频)。
- 文本结构化创作(论文/小说/剧本/分镜/教材)。
- PPT 生成。
- 通用套件(面向非 NIT 用户)。
## 学员下游 key 配置
- `downstream_api_keys` 加可空 `student_id` 列(关联 `students.id`,与现有 `user_id` 并列;
二者互斥:一行要么属于 user,要么属于 student)。
- 审核通过且 `tier=elite` 时,自动 provision 一个学员下游 key,沿用现有 `comfyui-agent`
内置 key 的幂等 `ensure` 模式:缺失则建、被禁用则重新启用、保持唯一。
- 学员从精英班降级或报考被驳回时,禁用该 key。
- key 命名约定:`sk-nit-stu-{uuid_hex}`,`name='nit-student'`,便于识别。
## 认证扩展
仅改两个 surface:`src/routes/picture_agent.rs`、`src/routes/digital_human.rs`。
**money_printer 不动。**
各自的 `resolve_auth` 加一个分支(现有 admin/proxy token、user JWT 逻辑不变):
- 新增:`audience='student'` 的 JWT → 校验该学员存在 `approved` 且 `tier=elite` 的报考
→ 查其 `student_id` 对应的下游 key → 作 `outbound_key`。
- 抽共享 helper `resolve_student_outbound_key(student_id, state) -> AppResult<(key_id, key)>`,
放在 `src/services/nit/`,两处 surface 复用,避免重复逻辑。
- 校验失败(无 elite 报考 / key 缺失)→ 403。
## 计费(复用积分轨)
- 学员用图像/数字人,消耗扣在其下游 key 上,走已建的积分轨(`apply_points_delta`,track=points)。
- 管理员给学员充积分:扩展现有 admin 积分调整端点支持 `student_id`(或新增 NIT 学员积分端点,
实现时择一,优先扩展现有端点)。
- 精英班开通时可送一笔初始积分,数额为配置项,默认 `0`。
## 统一入口(前端)
- `src/web/pages/nit/Studio.tsx`:精英班学员创作工作台首页,卡片式列出「图像 / 数字人」
两个入口 + 显示积分余额。
- 卡片跳进现有 `picture_agent` / `digital_human` 页面(带 student token)。
- 非精英班学员:显示引导升级提示,不展示工具卡片。
- `api.ts` 的 `nitApi` 扩展:查学员积分余额、查 Studio 可用工具。
## 错误处理
- 非精英班 / 未审核学员访问 → 403 + 友好提示。
- 学员下游 key 缺失(provision 失败)→ 明确错误,提示联系管理员。
- 积分余额不足 → 拦截创作,提示充值(复用现有 billing 引导文案范式)。
## 测试策略(TDD)
后端(rstest + 内存 SQLite,沿用现有 `recharge_test_pool` 范式):
- 学员下游 key ensure 幂等性(缺失则建 / 禁用则启用 / 降级则禁用)。
- `resolve_auth` 接受 elite student JWT、拒绝非精英班、拒绝未审核。
- 学员创作扣积分、余额不足拦截。
前端(vitest):
- `nitApi` 积分余额 / Studio 工具查询方法(沿用 `api.test.ts` 范式)。
- Studio 页卡片渲染 + 余额展示 + 非精英班引导(沿用 `UsageRecords.test.tsx` 范式)。
## 验证
- `cargo build -j 2`、`cargo test -p boosapi --lib`(后端 nit / picture_agent / digital_human 相关)。
- `npx vitest run`(相关前端测试)、`npm run typecheck`。
## 提交约定
全部在 `nit_edu` 分支,不混入 `refactor/rust-backend`。提交按逻辑拆分(迁移 / service /
surface 认证 / 前端 分开),沿用项目中文 commit 风格。
@@ -0,0 +1,195 @@
# NIT 报考学习平台 — 第一期(报考闭环 A+B+C)设计
日期:2026-06-17
分支:`nit_edu`
状态:已通过 brainstorming,待落实现计划
## 背景
NIT 报考学习平台面向报考「NIT 人工智能与大模型应用」考试的学员,提供注册、报考、
学习等功能。完整需求(见 `plan/NIT报考学习平台(初版需求备注解析).docx`)涵盖
8 个子系统,体量过大,无法单 spec 完成,故拆分分期建设。
**本 spec 仅覆盖第一期:报考闭环 A+B+C** —— 学员注册 → 报考 → 缴费审核开课。
学习中心(理论课/实操课/练习题)、OCR、校区负责人多级权限、内容保护等留后续期。
## 已确认的关键决策
1. 第一个子项目:A+B+C 报考闭环(注册 → 报考 → 缴费审核开课)。
2. 独立 `students` 表,与现有 `users`(API 网关用户)完全隔离。
3. 缴费:线下转账/扫码给校区 + 学员上传凭证截图 + 后台人工审核开课(非在线支付)。
4. 第一期不做 OCR:学员手动填身份证号/姓名,二寸照手动命名上传。
5. 开课权限只打状态标记(enrollment.status=approved + tier),学习中心留后续期再读此标记。
6. 后台第一期只做平台超管(复用现有 admin 认证),校区负责人登录留后续期。
7. 文件存储复用现有 workspace 机制(multipart + `record_bytes_asset` + `workspace_assets` 表)。
8. 代码组织:NIT 作为 BoosAPI 内的新模块,复用现有 axum/sqlx/JWT/workspace 基建。
9. 学员认证:发 `audience='student'` 的 JWT,与 admin/user token 互不通用。
10. 报名信息表:固定核心字段 + 一个 JSON 扩展字段(应对考试中心模版变化),不做动态表单引擎。
## 非目标(第一期明确不做)
- 学习中心:理论课/实操课视频、二维码、学习进度、练习题、模拟题、错题集。
- 实操课专属 AI 综合应用软件入口。
- 身份证 OCR 自动识别与二寸照自动命名。
- 校区负责人多级权限与数据隔离。
- 内容保护(防截图/录屏/水印)。
- 在线支付。
## 数据模型
三张新表,SQLite,迁移文件放 `migrations/`。
### `students` — 学员实名账户
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | INTEGER PK | |
| `public_id` | TEXT UNIQUE | 对外 ID,格式 `stu_{uuid_hex}` |
| `name` | TEXT NOT NULL | 姓名(实名,注册后不可改) |
| `phone` | TEXT NOT NULL UNIQUE | 实名手机号,登录用 |
| `id_card_no` | TEXT NOT NULL UNIQUE | 身份证号 |
| `campus_id` | INTEGER NOT NULL | FK → `nit_campuses.id` |
| `major` | TEXT | 所学专业(社会学员可空) |
| `class_name` | TEXT | 班级(可空) |
| `head_teacher` | TEXT | 班主任姓名(可空) |
| `password_hash` | TEXT NOT NULL | 登录密码哈希(沿用现有 users 哈希方案) |
| `privacy_agreed_at` | TEXT NOT NULL | 勾选隐私协议时间 |
| `status` | TEXT NOT NULL DEFAULT 'active' | active / disabled |
| `created_at` / `updated_at` | TEXT | |
### `nit_campuses` — 校区/机构库(超管录入,学员注册下拉选)
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | INTEGER PK | |
| `public_id` | TEXT UNIQUE | `cmp_{uuid_hex}` |
| `province` | TEXT NOT NULL | 省份 |
| `name` | TEXT NOT NULL | 院校/机构名称 |
| `manager_name` | TEXT | 负责人姓名 |
| `manager_phone` | TEXT | 负责人联系方式 |
| `enabled` | INTEGER NOT NULL DEFAULT 1 | 仅 enabled 的校区可被学员选择 |
| `created_at` / `updated_at` | TEXT | |
### `nit_enrollments` — 报考记录
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | INTEGER PK | |
| `public_id` | TEXT UNIQUE | `enr_{uuid_hex}` |
| `student_id` | INTEGER NOT NULL | FK → `students.id` |
| `subject` | TEXT NOT NULL | 第一期固定「NIT人工智能与大模型应用」 |
| `tier` | TEXT NOT NULL | `basic`(980)/ `elite`(1980) |
| `payment_time` | TEXT | 学员填写的缴费时间 |
| `payment_proof_asset_id` | TEXT | 缴费凭证截图(workspace asset public_id) |
| `id_card_front_asset_id` | TEXT | 身份证正面 |
| `id_card_back_asset_id` | TEXT | 身份证反面 |
| `photo_asset_id` | TEXT | 二寸蓝底照 |
| `applicant_info` | TEXT (JSON) | 报名材料表可变字段 |
| `status` | TEXT NOT NULL DEFAULT 'pending_review' | pending_review / approved / rejected |
| `reviewed_by` | TEXT | 审核管理员标识 |
| `reviewed_at` | TEXT | |
| `review_note` | TEXT | 驳回原因/备注 |
| `created_at` / `updated_at` | TEXT | |
**关系**:student → campus(多对一);enrollment → student(多对一)。一个学员允许在被驳回后
重新提交报考(可有多条 rejected + 至多一条 active 的 pending_review/approved)。
**班型权限**:第一期权限即 `approved` 后 enrollment 上的 `tier` 标记;`basic` 对应理论课权限、
`elite` 对应理论+实操权限,实际课程内容由后续期读取此标记实现。
**文件**:四类上传文件全部走现有 `workspace_assets`(`source='nit-enrollment'`,
经 `record_bytes_asset` 落本地磁盘),enrollment 仅存其 `public_id` 引用。
## 认证与路由
### 学员认证
复用现有 `auth::jwt` 工具,给 students 发 `audience='student'` 的 JWT。新增
`resolve_student(headers, state) -> AppResult<i64>`(返回 student_id),与 admin/user token
互不通用。登录凭证为「手机号 + 密码」。
### 路由分布(挂进现有 axum router via `routes/mod.rs` merge)
- `src/routes/nit/mod.rs` — 装配 NIT 子路由,导出 `routes()`。
- `src/routes/nit/student_auth.rs`
- `POST /api/nit/register` — 学员注册
- `POST /api/nit/login` — 学员登录
- `src/routes/nit/enrollment.rs`(学员侧,需 student JWT)
- `POST /api/nit/enrollment/uploads` — multipart 上传单个文件,返回 asset public_id
- `POST /api/nit/enrollment` — 创建报考记录
- `GET /api/nit/enrollment/mine` — 查本人报考状态
- `src/routes/nit/admin.rs`(超管侧,复用现有 admin token 校验)
- `GET/POST/PUT /api/admin/nit/campuses` — 校区 CRUD
- `GET /api/admin/nit/enrollments` — 报考列表(按 省份/校区/班型/状态 筛选)
- `POST /api/admin/nit/enrollments/{public_id}/approve`
- `POST /api/admin/nit/enrollments/{public_id}/reject`
- `GET /api/admin/nit/enrollments/export` — CSV 导出
业务逻辑(查询/状态机)抽到 `src/services/nit/`(如 `mod.rs` + `enrollment.rs`),
路由层只做参数解析 + 鉴权 + 调用 service,保持文件聚焦。
## 数据流
### 注册
1. 校验手机号、身份证号未被占用(唯一约束 + 友好报错)。
2. 校验 `campus_id` 存在且 enabled。
3. 必须 `privacy_agreed=true`。
4. 写入 `students`(password 哈希),发 student JWT 返回。
### 报考(两步,避免半截事务)
1. 学员逐个调 `POST /api/nit/enrollment/uploads`(multipart,带 `purpose` 区分四类文件)
→ `record_bytes_asset` → 返回各 asset `public_id`。
2. 学员调 `POST /api/nit/enrollment`,带各 asset id + tier + payment_time + applicant_info
→ 校验本人无在审/已批的活动报考 → 创建 `pending_review` 记录。
### 审核(超管)
- 列表筛选 `pending_review` → 查看凭证/证件图(经 workspace asset file 接口)。
- `approve`:状态机 `pending_review → approved`,落 `tier` 权限标记,记 reviewed_by/at。
- `reject`:`pending_review → rejected`,记 review_note;学员可据此重新提交。
- 状态机只允许从 `pending_review` 流转,杜绝并发重复审核。
### 导出
按 省份/校区/班型/状态 筛选,导出 CSV(实名信息 + 班型),用于核算报考人数/教材数量。
## 前端页面(`src/web/pages/nit/`)
- 学员注册页(实名信息 + 校区下拉 + 隐私协议勾选)
- 学员登录页(手机号 + 密码)
- 报考页(选班型 + 上传四类文件 + 填缴费时间 + 报名信息表 + 提交)
- 「我的报考」状态页(pending/approved/rejected + 驳回原因 + 可重新提交)
- 超管校区管理页(CRUD)
- 超管报考审核页(列表 + 筛选 + 看图 + 通过/驳回 + 导出)
`api.ts` 新增 `nitApi`(学员侧,带 student token 注入)与 `adminApi` NIT 审核扩展。
复用现有 React/Vite/api.ts 范式与 UI 组件。
## 错误处理
- 唯一约束冲突 → 友好提示「该手机号/身份证号已注册」。
- 上传非图片或超大文件 → 拒绝并提示。
- 报考时存在活动报考 → 拒绝重复提交。
- 审核状态机:非 `pending_review` 状态的 approve/reject → 409/冲突提示。
## 测试策略(TDD)
后端(rstest + 内存 SQLite,沿用现有 `recharge_test_pool` 范式):
- 注册手机号/身份证唯一性、校区校验、隐私协议必填。
- 登录成功/失败。
- 文件上传返回 asset id。
- 报考创建 + 重复报考拦截。
- 审核状态机(approve/reject/非法流转)。
- 导出筛选正确性。
前端(vitest):
- `nitApi` 各方法 URL/method/body 正确(沿用 `api.test.ts` 范式)。
- 注册/报考/审核关键页面交互(沿用 `UsageRecords.test.tsx` 的 react-test-renderer 范式)。
## 验证
- `cargo build -j 2`(避免 OOM)、`cargo test -p boosapi --lib nit`(后端)。
- `npx vitest run`(相关前端测试)、`npm run typecheck`。
## 提交约定
NIT 工作全部在 `nit_edu` 分支,不混入 `refactor/rust-backend`。提交按逻辑拆分
(迁移 / service / 路由 / 前端 分开),沿用项目中文 commit 风格。
@@ -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": "tsc -p tsconfig.web.json",
"typecheck:web:test": "tsc -p tsconfig.web.test.json", "typecheck:web:test": "tsc -p tsconfig.web.test.json",
"typecheck": "npm run typecheck:web && npm run typecheck:web:test", "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:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
"docs:build": "vitepress build docs", "docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173", "docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
"workflow-ui:install": "node scripts/workflow-ui/install.mjs all", "workflow-ui:install": "node scripts/workflow-ui/install.mjs all",
"workflow-ui:install:external": "node scripts/workflow-ui/install.mjs external", "workflow-ui:install:external": "node scripts/workflow-ui/install.mjs external",
"workflow-ui:install:cloud": "node scripts/workflow-ui/install.mjs cloud", "workflow-ui:install:cloud": "node scripts/workflow-ui/install.mjs cloud",
"test": "vitest run --root .", "test": "vitest run --root src/web",
"test:watch": "vitest --root ." "test:watch": "vitest --root src/web"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@visactor/react-vchart": "^2.0.21", "@visactor/react-vchart": "^2.0.21",
"gsap": "^3.15.0",
"marked": "^17.0.5", "marked": "^17.0.5",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
+8
View File
@@ -20,6 +20,9 @@ importers:
'@visactor/react-vchart': '@visactor/react-vchart':
specifier: ^2.0.21 specifier: ^2.0.21
version: 2.0.21(react-dom@18.3.1(react@18.3.1))(react@18.3.1) 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: marked:
specifier: ^17.0.5 specifier: ^17.0.5
version: 17.0.5 version: 17.0.5
@@ -2210,6 +2213,9 @@ packages:
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} 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: hachure-fill@0.5.2:
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
@@ -5138,6 +5144,8 @@ snapshots:
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
gsap@3.15.0: {}
hachure-fill@0.5.2: {} hachure-fill@0.5.2: {}
has-symbols@1.1.0: has-symbols@1.1.0:
@@ -0,0 +1,147 @@
#!/usr/bin/env node
import fs from 'fs/promises';
const DEFAULT_PACKS = [
['ComfyUI-Manager', 'https://github.com/Comfy-Org/ComfyUI-Manager.git'],
['christian-byrne/audio-separation-nodes-comfyui', 'https://github.com/christian-byrne/audio-separation-nodes-comfyui.git'],
['yvann-ba/ComfyUI_Yvann-Nodes', 'https://github.com/yvann-ba/ComfyUI_Yvann-Nodes.git'],
['yolain/ComfyUI-Easy-Use', 'https://github.com/yolain/ComfyUI-Easy-Use.git'],
['wildminder/ComfyUI-VoxCPM', 'https://github.com/wildminder/ComfyUI-VoxCPM.git'],
['kijai/ComfyUI-WanVideoWrapper', 'https://github.com/kijai/ComfyUI-WanVideoWrapper.git'],
['civen-cn/ComfyUI-Whisper-Translator', 'https://github.com/civen-cn/ComfyUI-Whisper-Translator.git'],
['kijai/ComfyUI-KJNodes', 'https://github.com/kijai/ComfyUI-KJNodes.git'],
];
const args = new Set(process.argv.slice(2));
const configPath = valueArg('--config') || 'config.toml';
const baseUrl = trimSlash(
process.env.BOOSAPI_COMFYUI__BASE_URL
|| process.env.COMFYUI_CLOUD_BASE_URL
|| (await readComfyConfig(configPath)).base_url
|| 'https://cloud.comfy.org'
);
const apiKey = process.env.BOOSAPI_COMFYUI__API_KEY
|| process.env.COMFYUI_CLOUD_API_KEY
|| (await readComfyConfig(configPath)).api_key
|| '';
const dryRun = args.has('--dry-run');
if (!baseUrl) {
fail('Missing Cloud base URL. Set BOOSAPI_COMFYUI__BASE_URL or [comfyui].base_url.');
}
if (!apiKey) {
fail('Missing Cloud API key. Set BOOSAPI_COMFYUI__API_KEY or [comfyui].api_key.');
}
console.log(`Cloud backend: ${baseUrl}`);
console.log(`Packs: ${DEFAULT_PACKS.length}`);
if (dryRun) {
for (const [id, repository] of DEFAULT_PACKS) {
console.log(`[dry-run] ${id} <- ${repository}`);
}
process.exit(0);
}
await request('GET', '/api/manager/queue/start').catch((error) => {
console.warn(`manager queue start warning: ${error.message}`);
});
const results = [];
for (const [id, repository] of DEFAULT_PACKS) {
const payload = {
id,
version: 'nightly',
selected_version: 'nightly',
repository,
mode: 'remote',
channel: 'default',
skip_post_install: false,
};
try {
await request('POST', '/api/manager/queue/install', payload);
console.log(`queued: ${id}`);
results.push({ id, ok: true });
} catch (error) {
console.error(`failed: ${id}: ${error.message}`);
results.push({ id, ok: false, error: error.message });
}
}
await request('GET', '/api/manager/queue/status')
.then((status) => console.log(`queue status: ${JSON.stringify(status)}`))
.catch((error) => console.warn(`queue status warning: ${error.message}`));
const failed = results.filter((item) => !item.ok);
if (failed.length > 0) {
console.error(`\n${failed.length} pack(s) failed to queue. If Cloud returned 403/404, the hosted backend does not allow Manager installs for this workspace/API key.`);
process.exit(1);
}
console.log('\nAll install jobs were queued. Restart the Cloud ComfyUI runtime if Manager asks for it.');
async function request(method, path, body) {
const response = await fetch(`${baseUrl}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'X-API-Key': apiKey,
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await response.text();
const data = text ? safeJson(text) : null;
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}: ${text.slice(0, 500)}`);
}
return data;
}
function safeJson(text) {
try {
return JSON.parse(text);
} catch {
return { body: text };
}
}
async function readComfyConfig(path) {
const result = {};
let section = '';
let text = '';
try {
text = await fs.readFile(path, 'utf8');
} catch {
return result;
}
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
section = sectionMatch[1];
continue;
}
if (section !== 'comfyui') continue;
const match = line.match(/^([A-Za-z0-9_]+)\s*=\s*"(.*)"\s*$/);
if (match) result[match[1]] = match[2];
}
return result;
}
function trimSlash(value) {
return String(value || '').trim().replace(/\/+$/, '');
}
function valueArg(name) {
const prefix = `${name}=`;
const found = process.argv.slice(2).find((arg) => arg.startsWith(prefix));
return found ? found.slice(prefix.length) : '';
}
function fail(message) {
console.error(message);
process.exit(2);
}
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
set -euo pipefail
COMFYUI_DIR="${COMFYUI_DIR:-/workspace/ComfyUI}"
CUSTOM_NODES_DIR="${CUSTOM_NODES_DIR:-$COMFYUI_DIR/custom_nodes}"
PYTHON_BIN="${PYTHON_BIN:-$COMFYUI_DIR/venv/bin/python}"
if [ ! -d "$COMFYUI_DIR" ]; then
echo "ComfyUI directory not found: $COMFYUI_DIR" >&2
exit 1
fi
if [ ! -x "$PYTHON_BIN" ]; then
if command -v python3 >/dev/null 2>&1; then
PYTHON_BIN="$(command -v python3)"
else
echo "Python not found. Set PYTHON_BIN=/path/to/python" >&2
exit 1
fi
fi
mkdir -p "$CUSTOM_NODES_DIR"
install_node() {
local name="$1"
local repo="$2"
local ref="${3:-}"
local dir="$CUSTOM_NODES_DIR/$name"
if [ -d "$dir/.git" ]; then
echo "==> Updating $name"
git -C "$dir" fetch --depth 1 origin "${ref:-HEAD}" || git -C "$dir" fetch origin
else
echo "==> Installing $name"
git clone --depth 1 "$repo" "$dir"
fi
if [ -n "$ref" ]; then
git -C "$dir" checkout --quiet "$ref"
fi
if [ -f "$dir/requirements.txt" ]; then
echo "==> Installing Python requirements for $name"
if command -v uv >/dev/null 2>&1; then
UV_INDEX_URL="${UV_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple}" \
uv pip install --python "$PYTHON_BIN" -r "$dir/requirements.txt"
else
"$PYTHON_BIN" -m pip install -r "$dir/requirements.txt"
fi
fi
if [ -f "$dir/install.py" ]; then
echo "==> Running install.py for $name"
"$PYTHON_BIN" "$dir/install.py" || true
fi
}
# ComfyUI-Manager backend API. The v1.46.x cloud frontend already contains the
# Manager UI; this backend node provides /manager/* and /customnode/* endpoints
# for local/docker/external ComfyUI instances.
install_node "ComfyUI-Manager" "https://github.com/Comfy-Org/ComfyUI-Manager.git"
# Nodes used by common RunningHub digital-human / video templates.
install_node "audio-separation-nodes-comfyui" "https://github.com/christian-byrne/audio-separation-nodes-comfyui.git"
install_node "ComfyUI_Yvann-Nodes" "https://github.com/yvann-ba/ComfyUI_Yvann-Nodes.git"
install_node "ComfyUI-Easy-Use" "https://github.com/yolain/ComfyUI-Easy-Use.git"
install_node "ComfyUI-VoxCPM" "https://github.com/wildminder/ComfyUI-VoxCPM.git"
install_node "ComfyUI-WanVideoWrapper" "https://github.com/kijai/ComfyUI-WanVideoWrapper.git"
install_node "ComfyUI-Whisper-Translator" "https://github.com/civen-cn/ComfyUI-Whisper-Translator.git"
install_node "ComfyUI-KJNodes" "https://github.com/kijai/ComfyUI-KJNodes.git"
cat <<'EOF'
RunningHub template nodes installed.
Notes:
- Install system packages such as ffmpeg/libsndfile/rubberband separately if a
node requires them at runtime.
- The screenshot also references a "soundflow" package. I did not find a stable
public repository for that exact package name; install it through
ComfyUI-Manager or add its repository manually when you have the source URL.
- Missing model files, for example Wan/InfiniteTalk checkpoints, are not custom
nodes. Put those models into the matching ComfyUI models folders or use a
backend where RunningHub/Cloud already provides them.
EOF
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env bash
# Build the BossAI production binary, package Alpine/musl runtime libraries,
# and deploy the runnable bundle to a Debian server.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
log() { echo "[$(date '+%H:%M:%S')] $*"; }
ok() { echo "[$(date '+%H:%M:%S')] ✓ $*"; }
fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
REMOTE_HOST="${BOSSAI_DEBIAN_HOST:-129.211.30.104}"
REMOTE_USER="${BOSSAI_DEBIAN_USER:-root}"
REMOTE_DIR="${BOSSAI_DEBIAN_DIR:-/root/BossAI}"
SSH_KEY="${BOSSAI_DEBIAN_SSH_KEY:-$HOME/.ssh/id_rsa_mbp}"
BOSSAI_SOURCE_DIR="${BOSSAI_SOURCE_DIR:-/root/BossAI-build/main}"
BOSSAI_REMOTE="${BOSSAI_REMOTE:-bossai}"
BOSSAI_REF="${BOSSAI_REF:-main}"
BOSSAI_WORKFLOW_UI_SOURCE="${BOSSAI_WORKFLOW_UI_SOURCE:-$ROOT_DIR/vendor/ComfyUI_frontend}"
PRODUCTION_DIR="${BOSSAI_PRODUCTION_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-production}"
BUNDLE_DIR="${BOSSAI_DEBIAN_BUNDLE_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle}"
BUNDLE_ARCHIVE="${BOSSAI_DEBIAN_BUNDLE_ARCHIVE:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle.tar.gz}"
FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}"
SKIP_BUILD=0
SKIP_VERIFY=0
SKIP_SOURCE_UPDATE=0
usage() {
cat <<'EOF'
Usage: scripts/deploy-bossai-debian.sh [options]
Build BossAI from the BossAI source tree, bundle the musl runtime libraries
needed by the Alpine-built binary, upload it to a Debian host, extract it,
and run a config check.
Options:
--host <host> Debian host/IP. Default: 129.211.30.104
--user <user> SSH user. Default: root
--dir <remote-dir> Remote deploy directory. Default: /root/BossAI
--key <ssh-key> SSH private key. Default: ~/.ssh/id_rsa_mbp
--source-dir <dir> BossAI source worktree. Default: /root/BossAI-build/main
--remote <name> Git remote for BossAI. Default: bossai
--ref <ref> BossAI branch/ref to build. Default: main
--skip-build Reuse dist/bossai-production from a previous build
--skip-source-update Do not fetch/reset the BossAI source worktree
--skip-verify Skip remote ./run-boosapi.sh --check-config
-h, --help Show this help
Environment overrides:
BOSSAI_DEBIAN_HOST
BOSSAI_DEBIAN_USER
BOSSAI_DEBIAN_DIR
BOSSAI_DEBIAN_SSH_KEY
BOSSAI_SOURCE_DIR
BOSSAI_REMOTE
BOSSAI_REF
BOSSAI_WORKFLOW_UI_SOURCE
BOSSAI_PRODUCTION_DIR
BOSSAI_DEBIAN_BUNDLE_DIR
BOSSAI_DEBIAN_BUNDLE_ARCHIVE
BOOSAPI_FRONTEND_PM auto / bun / npm, forwarded to build-and-run.sh
Remote start command after deploy:
cd /root/BossAI && ./run-boosapi.sh
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--host)
shift
[ $# -gt 0 ] || fail "--host requires a value"
REMOTE_HOST="$1"
;;
--user)
shift
[ $# -gt 0 ] || fail "--user requires a value"
REMOTE_USER="$1"
;;
--dir)
shift
[ $# -gt 0 ] || fail "--dir requires a value"
REMOTE_DIR="$1"
;;
--key)
shift
[ $# -gt 0 ] || fail "--key requires a value"
SSH_KEY="$1"
;;
--source-dir)
shift
[ $# -gt 0 ] || fail "--source-dir requires a value"
BOSSAI_SOURCE_DIR="$1"
PRODUCTION_DIR="${BOSSAI_PRODUCTION_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-production}"
BUNDLE_DIR="${BOSSAI_DEBIAN_BUNDLE_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle}"
BUNDLE_ARCHIVE="${BOSSAI_DEBIAN_BUNDLE_ARCHIVE:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle.tar.gz}"
;;
--remote)
shift
[ $# -gt 0 ] || fail "--remote requires a value"
BOSSAI_REMOTE="$1"
;;
--ref)
shift
[ $# -gt 0 ] || fail "--ref requires a value"
BOSSAI_REF="$1"
;;
--skip-build)
SKIP_BUILD=1
;;
--skip-source-update)
SKIP_SOURCE_UPDATE=1
;;
--skip-verify)
SKIP_VERIFY=1
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
shift
done
require_file() {
local path="$1"
[ -f "$path" ] || fail "Missing required file: $path"
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1"
}
ensure_bossai_source() {
require_command git
if [ "$SKIP_SOURCE_UPDATE" -eq 1 ]; then
log "Skipping BossAI source update; using $BOSSAI_SOURCE_DIR"
[ -d "$BOSSAI_SOURCE_DIR" ] || fail "BossAI source directory does not exist: $BOSSAI_SOURCE_DIR"
return 0
fi
log "Preparing BossAI source from $BOSSAI_REMOTE/$BOSSAI_REF → $BOSSAI_SOURCE_DIR"
git fetch "$BOSSAI_REMOTE" "$BOSSAI_REF"
if [ -d "$BOSSAI_SOURCE_DIR" ] && git -C "$BOSSAI_SOURCE_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git -C "$BOSSAI_SOURCE_DIR" reset --hard "$BOSSAI_REMOTE/$BOSSAI_REF"
git -C "$BOSSAI_SOURCE_DIR" clean -fd
else
rm -rf "$BOSSAI_SOURCE_DIR"
mkdir -p "$(dirname "$BOSSAI_SOURCE_DIR")"
git worktree add "$BOSSAI_SOURCE_DIR" "$BOSSAI_REMOTE/$BOSSAI_REF"
fi
require_file "$BOSSAI_SOURCE_DIR/build-and-run.sh"
ok "BossAI source ready: $(git -C "$BOSSAI_SOURCE_DIR" rev-parse --short HEAD)"
}
prepare_workflow_ui_source() {
if [ -n "${BOOSAPI_WORKFLOW_UI_REPO:-}" ]; then
log "Using BOOSAPI_WORKFLOW_UI_REPO=$BOOSAPI_WORKFLOW_UI_REPO"
return 0
fi
if [ ! -d "$BOSSAI_WORKFLOW_UI_SOURCE/.git" ] && [ ! -f "$BOSSAI_WORKFLOW_UI_SOURCE/.git" ]; then
log "No local Workflow UI source at $BOSSAI_WORKFLOW_UI_SOURCE; install script may fetch from GitHub"
return 0
fi
log "Using local ComfyUI_frontend source: $BOSSAI_WORKFLOW_UI_SOURCE"
export BOOSAPI_WORKFLOW_UI_REPO="$BOSSAI_WORKFLOW_UI_SOURCE"
}
runtime_lib() {
local path="$1"
require_file "$path"
cp "$path" "$BUNDLE_DIR/lib/"
}
write_runner() {
cat > "$BUNDLE_DIR/run-boosapi.sh" <<'EOF'
#!/usr/bin/env sh
set -eu
DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
exec "$DIR/lib/ld-musl-x86_64.so.1" --library-path "$DIR/lib" "$DIR/boosapi" "$@"
EOF
chmod +x "$BUNDLE_DIR/run-boosapi.sh"
}
package_bundle() {
require_file "$PRODUCTION_DIR/boosapi"
require_file "$PRODUCTION_DIR/config.toml"
rm -rf "$BUNDLE_DIR" "$BUNDLE_ARCHIVE"
mkdir -p "$BUNDLE_DIR/lib"
cp "$PRODUCTION_DIR/boosapi" "$BUNDLE_DIR/boosapi"
cp "$PRODUCTION_DIR/config.toml" "$BUNDLE_DIR/config.toml"
chmod +x "$BUNDLE_DIR/boosapi"
runtime_lib /lib/ld-musl-x86_64.so.1
runtime_lib /usr/lib/libssl.so.3
runtime_lib /usr/lib/libcrypto.so.3
runtime_lib /usr/lib/libgcc_s.so.1
write_runner
tar -C "$BUNDLE_DIR" -czf "$BUNDLE_ARCHIVE" .
ok "Bundle ready: $BUNDLE_ARCHIVE"
sha256sum "$BUNDLE_ARCHIVE" "$BUNDLE_DIR/boosapi"
ls -lh "$BUNDLE_ARCHIVE"
}
deploy_bundle() {
require_file "$SSH_KEY"
require_command ssh
require_command scp
local remote="${REMOTE_USER}@${REMOTE_HOST}"
local ssh_opts=(-i "$SSH_KEY" -o StrictHostKeyChecking=accept-new)
log "Preparing remote directory: $remote:$REMOTE_DIR"
ssh "${ssh_opts[@]}" "$remote" "mkdir -p '$REMOTE_DIR'"
log "Uploading bundle..."
scp "${ssh_opts[@]}" "$BUNDLE_ARCHIVE" "$remote:$REMOTE_DIR/bossai-debian-bundle.tar.gz"
log "Extracting bundle on Debian host..."
ssh "${ssh_opts[@]}" "$remote" "cd '$REMOTE_DIR' && tar -xzf bossai-debian-bundle.tar.gz && chmod +x boosapi run-boosapi.sh && sha256sum bossai-debian-bundle.tar.gz boosapi"
if [ "$SKIP_VERIFY" -eq 0 ]; then
log "Checking remote config..."
ssh "${ssh_opts[@]}" "$remote" "cd '$REMOTE_DIR' && ./run-boosapi.sh --check-config"
fi
ok "Deployed BossAI to $remote:$REMOTE_DIR"
log "Start with: cd $REMOTE_DIR && ./run-boosapi.sh"
}
if [ "$SKIP_BUILD" -eq 0 ]; then
ensure_bossai_source
prepare_workflow_ui_source
log "Building BossAI production bundle..."
BOOSAPI_FRONTEND_PM="$FRONTEND_PM" "$BOSSAI_SOURCE_DIR/build-and-run.sh" \
--production \
--production-dir "$PRODUCTION_DIR"
else
[ -d "$BOSSAI_SOURCE_DIR" ] || fail "BossAI source directory does not exist: $BOSSAI_SOURCE_DIR"
log "Skipping build; using existing $PRODUCTION_DIR"
fi
package_bundle
deploy_bundle
+43 -6
View File
@@ -6,13 +6,13 @@ const ROOT = process.cwd();
const REPO = process.env.BOOSAPI_WORKFLOW_UI_REPO || ''; const REPO = process.env.BOOSAPI_WORKFLOW_UI_REPO || '';
const VARIANT = (process.env.BOOSAPI_WORKFLOW_UI_VARIANT || process.argv[2] || 'all').toLowerCase(); const VARIANT = (process.env.BOOSAPI_WORKFLOW_UI_VARIANT || process.argv[2] || 'all').toLowerCase();
const DEFAULT_EXTERNAL_REF = 'v1.14.6'; const DEFAULT_EXTERNAL_REF = 'v1.14.6';
const DEFAULT_CLOUD_REF = 'v1.46.1'; const DEFAULT_CLOUD_REF = 'v1.46.2';
const SUBMODULE_DIR = path.join(ROOT, 'vendor', 'ComfyUI_frontend'); const SUBMODULE_DIR = path.join(ROOT, 'vendor', 'ComfyUI_frontend');
const CACHE_DIR = path.join(ROOT, '.cache', 'comfyui-frontend'); const CACHE_DIR = path.join(ROOT, '.cache', 'comfyui-frontend');
const DEFAULT_DIST = path.join(ROOT, 'dist', 'workflow-ui'); const DEFAULT_DIST = path.join(ROOT, 'dist', 'workflow-ui');
// External/RunningHub often exposes older ComfyUI servers; v1.14.x matches // External/RunningHub currently targets ComfyUI 0.3.27, where v1.14.x is the
// ComfyUI 0.3.27 much better than the newer Cloud-oriented frontend. // most stable match. Cloud tracks the newer ComfyUI_frontend release line.
const variants = { const variants = {
external: { external: {
@@ -35,9 +35,18 @@ const variants = {
function run(command, args, options = {}) { function run(command, args, options = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const env = { ...process.env, ...(options.env || {}) };
if (options.cleanPackageEnv) {
Object.keys(env)
.filter((key) => key.startsWith('npm_package_'))
.forEach((key) => {
delete env[key];
});
Object.assign(env, options.env || {});
}
const child = spawn(command, args, { const child = spawn(command, args, {
cwd: options.cwd || ROOT, cwd: options.cwd || ROOT,
env: { ...process.env, ...(options.env || {}) }, env,
stdio: 'inherit', stdio: 'inherit',
shell: process.platform === 'win32', shell: process.platform === 'win32',
}); });
@@ -55,6 +64,11 @@ function selectedVariants() {
throw new Error(`Unknown workflow-ui variant: ${VARIANT}. Use external, cloud, or all.`); 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) { async function buildVariant(name, config) {
const ref = config.ref; const ref = config.ref;
const dist = config.dist; const dist = config.dist;
@@ -71,22 +85,45 @@ async function buildVariant(name, config) {
if (!hasSubmodule) { if (!hasSubmodule) {
await run('git', ['submodule', 'update', '--init', '--recursive', 'vendor/ComfyUI_frontend']); await run('git', ['submodule', 'update', '--init', '--recursive', 'vendor/ComfyUI_frontend']);
} }
await run('git', ['fetch', '--tags', 'origin', ref], { cwd: SUBMODULE_DIR });
await run('git', ['clone', '--shared', SUBMODULE_DIR, dir]); await run('git', ['clone', '--shared', SUBMODULE_DIR, dir]);
await run('git', ['-c', 'advice.detachedHead=false', 'checkout', '--quiet', ref], { cwd: dir }); await run('git', ['-c', 'advice.detachedHead=false', 'checkout', '--quiet', ref], { cwd: dir });
await run('git', ['submodule', 'update', '--init', '--recursive'], { cwd: dir }); 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); const hasPnpmLock = await fs.access(path.join(dir, 'pnpm-lock.yaml')).then(() => true, () => false);
if (hasPnpmLock) { if (hasPnpmLock) {
await run('pnpm', ['install', '--frozen-lockfile'], { cwd: dir }); 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 { } else {
await run('npm', ['ci'], { cwd: dir }); 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.rm(dist, { recursive: true, force: true });
await fs.cp(path.join(dir, 'dist'), dist, { recursive: 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}`); console.log(`workflow-ui:${name} installed to ${dist}`);
if (name === 'cloud') { if (name === 'cloud') {
+2471
View File
File diff suppressed because it is too large Load Diff
+187 -1
View File
@@ -13,6 +13,10 @@ pub struct AppConfig {
pub proxy: ProxyConfig, pub proxy: ProxyConfig,
#[serde(default)] #[serde(default)]
pub comfyui: ComfyUIConfig, pub comfyui: ComfyUIConfig,
#[serde(default)]
pub pixelle_video: PixelleVideoConfig,
#[serde(default)]
pub payment: PaymentConfig,
} }
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
@@ -47,6 +51,8 @@ pub struct ProxyConfig {
pub first_byte_timeout_secs: u64, pub first_byte_timeout_secs: u64,
pub max_retries: u32, pub max_retries: u32,
pub proxy_log_retention_days: u32, pub proxy_log_retention_days: u32,
#[serde(default)]
pub template_proxy_url: Option<String>,
} }
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
@@ -54,6 +60,12 @@ pub struct ComfyUIConfig {
pub enabled: bool, pub enabled: bool,
#[serde(default)] #[serde(default)]
pub backend: ComfyUIBackend, pub backend: ComfyUIBackend,
#[serde(default)]
pub money_printer_video_cost: f64,
#[serde(default)]
pub digital_human_video_cost: f64,
#[serde(default = "default_workflow_points_cost")]
pub workflow_points_cost: f64,
#[serde(default = "default_comfyui_docker_image")] #[serde(default = "default_comfyui_docker_image")]
pub docker_image: String, pub docker_image: String,
#[serde(default = "default_comfyui_container_name")] #[serde(default = "default_comfyui_container_name")]
@@ -69,6 +81,67 @@ pub struct ComfyUIConfig {
pub agent_model: String, pub agent_model: String,
} }
#[derive(Debug, Deserialize, Clone)]
pub struct PixelleVideoConfig {
pub enabled: bool,
pub api_base_url: Option<String>,
pub api_key: Option<String>,
#[serde(default = "default_pixelle_backend_provider")]
pub backend_provider: String,
pub frame_template: String,
pub media_workflow: Option<String>,
pub tts_workflow: Option<String>,
pub runninghub_media_workflow: Option<String>,
pub runninghub_tts_workflow: Option<String>,
pub mode: String,
pub n_scenes: i64,
pub video_fps: i64,
pub poll_interval_secs: u64,
pub timeout_secs: u64,
}
#[derive(Debug, Deserialize, Clone)]
pub struct PaymentConfig {
pub enabled: bool,
#[serde(default)]
pub provider: PaymentProvider,
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 comfy_customer_token: Option<String>,
pub comfy_customer_token_endpoint: Option<String>,
pub comfy_firebase_api_key: Option<String>,
pub comfy_oauth_client_id: Option<String>,
pub comfy_oauth_client_secret: Option<String>,
pub comfy_oauth_refresh_token: Option<String>,
pub comfy_customer_api_base_url: Option<String>,
pub comfy_checkout_endpoint: Option<String>,
pub success_path: String,
pub cancel_path: String,
/// When true, confirming a comfy_cloud recharge grants workflow points
/// (amount_usd * usd_to_points_rate) instead of crediting the USD pool.
#[serde(default = "default_comfy_recharge_to_points")]
pub comfy_recharge_to_points: bool,
/// Points granted per 1 USD when comfy_recharge_to_points is enabled.
#[serde(default = "default_usd_to_points_rate")]
pub usd_to_points_rate: f64,
}
#[derive(Debug, Deserialize, Clone, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PaymentProvider {
#[default]
ComfyCloud,
Stripe,
}
#[derive(Debug, Deserialize, Clone, Default)] #[derive(Debug, Deserialize, Clone, Default)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ComfyUIBackend { pub enum ComfyUIBackend {
@@ -76,6 +149,7 @@ pub enum ComfyUIBackend {
Boosapi, Boosapi,
External, External,
Cloud, Cloud,
Runninghub,
Docker, Docker,
} }
@@ -87,11 +161,30 @@ fn default_comfyui_container_name() -> String {
"boosapi-comfyui-workflow".to_string() "boosapi-comfyui-workflow".to_string()
} }
fn default_pixelle_backend_provider() -> String {
"comfy".to_string()
}
fn default_workflow_points_cost() -> f64 {
10.0
}
fn default_comfy_recharge_to_points() -> bool {
true
}
fn default_usd_to_points_rate() -> f64 {
100.0
}
impl Default for ComfyUIConfig { impl Default for ComfyUIConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: false, enabled: false,
backend: ComfyUIBackend::Boosapi, backend: ComfyUIBackend::Boosapi,
money_printer_video_cost: 0.0,
digital_human_video_cost: 0.0,
workflow_points_cost: 10.0,
docker_image: default_comfyui_docker_image(), docker_image: default_comfyui_docker_image(),
container_name: default_comfyui_container_name(), container_name: default_comfyui_container_name(),
install_dir: None, install_dir: None,
@@ -107,11 +200,100 @@ impl Default for ComfyUIConfig {
} }
} }
impl Default for PixelleVideoConfig {
fn default() -> Self {
Self {
enabled: false,
api_base_url: Some("http://127.0.0.1:8000".to_string()),
api_key: None,
backend_provider: default_pixelle_backend_provider(),
frame_template: "1080x1920/image_default.html".to_string(),
media_workflow: None,
tts_workflow: None,
runninghub_media_workflow: Some("runninghub/video_wan2.1_fusionx.json".to_string()),
runninghub_tts_workflow: Some("runninghub/tts_edge.json".to_string()),
mode: "fixed".to_string(),
n_scenes: 5,
video_fps: 30,
poll_interval_secs: 5,
timeout_secs: 900,
}
}
}
impl Default for PaymentConfig {
fn default() -> Self {
Self {
enabled: false,
provider: PaymentProvider::ComfyCloud,
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,
comfy_customer_token: None,
comfy_customer_token_endpoint: None,
comfy_firebase_api_key: None,
comfy_oauth_client_id: None,
comfy_oauth_client_secret: None,
comfy_oauth_refresh_token: None,
comfy_customer_api_base_url: None,
comfy_checkout_endpoint: None,
success_path: "/user/recharge?payment=success&order={ORDER_ID}".to_string(),
cancel_path: "/user/recharge?payment=cancel&order={ORDER_ID}".to_string(),
comfy_recharge_to_points: true,
usd_to_points_rate: 100.0,
}
}
}
impl Default for AppConfig {
fn default() -> Self {
Self {
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 4000,
log_level: "info".to_string(),
cors_origins: vec!["*".to_string()],
},
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-secret".to_string(),
jwt_expiry_hours: 72,
auth_token: "test-admin-token".to_string(),
proxy_token: "sk-test-proxy".to_string(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 300,
first_byte_timeout_secs: 60,
max_retries: 3,
proxy_log_retention_days: 30,
template_proxy_url: None,
},
comfyui: ComfyUIConfig::default(),
pixelle_video: PixelleVideoConfig::default(),
payment: PaymentConfig::default(),
}
}
}
impl ComfyUIConfig { impl ComfyUIConfig {
pub fn uses_native_backend(&self) -> bool { pub fn uses_native_backend(&self) -> bool {
matches!( matches!(
self.backend, self.backend,
ComfyUIBackend::External | ComfyUIBackend::Cloud ComfyUIBackend::External | ComfyUIBackend::Cloud | ComfyUIBackend::Runninghub
) )
} }
@@ -119,6 +301,10 @@ impl ComfyUIConfig {
matches!(self.backend, ComfyUIBackend::Cloud) matches!(self.backend, ComfyUIBackend::Cloud)
} }
pub fn uses_runninghub_backend(&self) -> bool {
matches!(self.backend, ComfyUIBackend::Runninghub)
}
pub fn uses_docker_backend(&self) -> bool { pub fn uses_docker_backend(&self) -> bool {
matches!(self.backend, ComfyUIBackend::Docker) matches!(self.backend, ComfyUIBackend::Docker)
} }
+18
View File
@@ -8,6 +8,7 @@ use sqlx::{
pub mod models; pub mod models;
pub mod queries; pub mod queries;
pub mod schema_compat;
pub mod sqlite_to_postgres; pub mod sqlite_to_postgres;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
@@ -45,6 +46,23 @@ pub async fn create_pool(database_url: &str, max_connections: u32) -> anyhow::Re
} }
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
.max_connections(max_connections) .max_connections(max_connections)
.after_connect(|conn, _meta| {
Box::pin(async move {
sqlx::query("PRAGMA journal_mode = WAL")
.execute(&mut *conn)
.await?;
sqlx::query("PRAGMA synchronous = NORMAL")
.execute(&mut *conn)
.await?;
sqlx::query("PRAGMA busy_timeout = 5000")
.execute(&mut *conn)
.await?;
sqlx::query("PRAGMA foreign_keys = ON")
.execute(&mut *conn)
.await?;
Ok(())
})
})
.connect(database_url) .connect(database_url)
.await?; .await?;
Ok(pool) Ok(pool)
+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), col!("read_at", text),
], ],
}, },
TableSpec {
name: "site_api_endpoints",
columns: &[
col!("id", i64),
col!("site_id", i64),
col!("url", text),
col!("enabled", bool),
col!("sort_order", i64),
col!("cooldown_until", text),
col!("last_selected_at", text),
col!("last_failed_at", text),
col!("last_failure_reason", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "oauth_route_units",
columns: &[
col!("id", i64),
col!("site_id", i64),
col!("provider", text),
col!("name", text),
col!("strategy", text),
col!("enabled", bool),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "oauth_route_unit_members",
columns: &[
col!("id", i64),
col!("unit_id", i64),
col!("account_id", i64),
col!("sort_order", i64),
col!("success_count", i64),
col!("fail_count", i64),
col!("total_latency_ms", i64),
col!("total_cost", f64),
col!("last_used_at", text),
col!("last_selected_at", text),
col!("last_fail_at", text),
col!("consecutive_fail_count", i64),
col!("cooldown_level", i64),
col!("cooldown_until", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "proxy_debug_traces",
columns: &[
col!("id", i64),
col!("downstream_path", text),
col!("client_kind", text),
col!("session_id", text),
col!("trace_hint", text),
col!("requested_model", text),
col!("downstream_api_key_id", i64),
col!("request_headers_json", text),
col!("request_body_json", text),
col!("sticky_session_key", text),
col!("sticky_hit_channel_id", i64),
col!("selected_channel_id", i64),
col!("selected_route_id", i64),
col!("selected_account_id", i64),
col!("selected_site_id", i64),
col!("selected_site_platform", text),
col!("endpoint_candidates_json", text),
col!("endpoint_runtime_state_json", text),
col!("decision_summary_json", text),
col!("final_status", text),
col!("final_http_status", i64),
col!("final_upstream_path", text),
col!("final_response_headers_json", text),
col!("final_response_body_json", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "proxy_debug_attempts",
columns: &[
col!("id", i64),
col!("trace_id", i64),
col!("attempt_index", i64),
col!("endpoint", text),
col!("request_path", text),
col!("target_url", text),
col!("runtime_executor", text),
col!("request_headers_json", text),
col!("request_body_json", text),
col!("response_status", i64),
col!("response_headers_json", text),
col!("response_body_json", text),
col!("raw_error_text", text),
col!("recover_applied", bool),
col!("downgrade_decision", bool),
col!("downgrade_reason", text),
col!("memory_write_json", text),
col!("created_at", text),
],
},
TableSpec {
name: "admin_snapshots",
columns: &[
col!("id", i64),
col!("namespace", text),
col!("snapshot_key", text),
col!("payload", text),
col!("generated_at", text),
col!("expires_at", text),
col!("stale_until", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "analytics_projection_checkpoints",
columns: &[
col!("projector_key", text),
col!("time_zone", text),
col!("last_proxy_log_id", i64),
col!("watermark_created_at", text),
col!("recompute_from_id", i64),
col!("recompute_requested_at", text),
col!("recompute_reason", text),
col!("recompute_started_at", text),
col!("recompute_completed_at", text),
col!("last_projected_at", text),
col!("last_successful_at", text),
col!("last_error", text),
col!("lease_owner", text),
col!("lease_token", text),
col!("lease_expires_at", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "model_day_usage",
columns: &[
col!("id", i64),
col!("local_day", text),
col!("site_id", i64),
col!("model", text),
col!("total_calls", i64),
col!("success_calls", i64),
col!("failed_calls", i64),
col!("total_tokens", i64),
col!("total_spend", f64),
col!("total_latency_ms", i64),
col!("latency_count", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "site_day_usage",
columns: &[
col!("id", i64),
col!("local_day", text),
col!("site_id", i64),
col!("total_calls", i64),
col!("success_calls", i64),
col!("failed_calls", i64),
col!("total_tokens", i64),
col!("total_summary_spend", f64),
col!("total_site_spend", f64),
col!("total_latency_ms", i64),
col!("latency_count", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "site_hour_usage",
columns: &[
col!("id", i64),
col!("bucket_start_utc", text),
col!("site_id", i64),
col!("total_calls", i64),
col!("success_calls", i64),
col!("failed_calls", i64),
col!("total_tokens", i64),
col!("total_summary_spend", f64),
col!("total_site_spend", f64),
col!("total_latency_ms", i64),
col!("latency_count", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
]; ];
pub async fn migrate( pub async fn migrate(
+9
View File
@@ -66,6 +66,14 @@ impl IntoResponse for AppError {
} => Some(*retry_after_secs), } => Some(*retry_after_secs),
_ => None, _ => None,
}; };
let billing = match &self {
AppError::RateLimited | AppError::RateLimitedWithRetry { .. } => Some(json!({
"reason": "insufficient_quota",
"rechargePath": "/user/recharge",
"usagePath": "/user/usage",
})),
_ => None,
};
( (
status, status,
Json(json!({ Json(json!({
@@ -74,6 +82,7 @@ impl IntoResponse for AppError {
"type": status.canonical_reason(), "type": status.canonical_reason(),
}, },
"retry_after_secs": retry_after_secs, "retry_after_secs": retry_after_secs,
"billing": billing,
})), })),
) )
.into_response() .into_response()
+23 -2
View File
@@ -6,18 +6,21 @@ pub mod proxy;
pub mod routes; pub mod routes;
pub mod services; pub mod services;
pub mod shared; pub mod shared;
pub mod template_http;
pub mod transformers; pub mod transformers;
pub mod workflow; pub mod workflow;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use std::sync::Arc; use std::{sync::Arc, time::Duration};
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub config: config::AppConfig, pub config: config::AppConfig,
pub db: Arc<SqlitePool>, pub db: Arc<SqlitePool>,
pub http: reqwest::Client,
pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>, pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>,
pub builtin_keys: Arc<services::builtin_keys::BuiltinKeys>, pub builtin_keys: Arc<services::builtin_keys::BuiltinKeys>,
pub runtime_auth: Arc<services::runtime_auth::RuntimeAuthSettings>,
pub register_captcha: Arc<services::register_captcha::RegisterCaptchaStore>, pub register_captcha: Arc<services::register_captcha::RegisterCaptchaStore>,
pub registration_limiter: Arc<services::registration_limiter::RegistrationLimiter>, pub registration_limiter: Arc<services::registration_limiter::RegistrationLimiter>,
} }
@@ -27,6 +30,7 @@ impl AppState {
config: config::AppConfig, config: config::AppConfig,
db: SqlitePool, db: SqlitePool,
builtin_keys: services::builtin_keys::BuiltinKeys, builtin_keys: services::builtin_keys::BuiltinKeys,
runtime_auth: services::runtime_auth::RuntimeAuthSettings,
) -> Self { ) -> Self {
let boosapi_host = if config.server.host == "0.0.0.0" || config.server.host == "::" { let boosapi_host = if config.server.host == "0.0.0.0" || config.server.host == "::" {
"127.0.0.1".to_string() "127.0.0.1".to_string()
@@ -38,19 +42,36 @@ impl AppState {
let comfyui = services::comfyui_process::ComfyUIProcess::new( let comfyui = services::comfyui_process::ComfyUIProcess::new(
config.comfyui.clone(), config.comfyui.clone(),
boosapi_url.clone(), boosapi_url.clone(),
config.auth.proxy_token.clone(), runtime_auth.proxy_token(),
comfyui_agent_secret.clone(), comfyui_agent_secret.clone(),
); );
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(config.proxy.upstream_timeout_secs.max(30)))
.connect_timeout(Duration::from_secs(15))
.pool_idle_timeout(Duration::from_secs(90))
.pool_max_idle_per_host(64)
.tcp_nodelay(true)
.build()
.unwrap_or_else(|err| {
tracing::warn!(%err, "failed to build shared HTTP client; falling back to reqwest default");
reqwest::Client::new()
});
Self { Self {
config, config,
db: Arc::new(db), db: Arc::new(db),
http,
comfyui, comfyui,
builtin_keys: Arc::new(builtin_keys), builtin_keys: Arc::new(builtin_keys),
runtime_auth: Arc::new(runtime_auth),
register_captcha: Arc::new(services::register_captcha::RegisterCaptchaStore::new()), register_captcha: Arc::new(services::register_captcha::RegisterCaptchaStore::new()),
registration_limiter: Arc::new( registration_limiter: Arc::new(
services::registration_limiter::RegistrationLimiter::new(), services::registration_limiter::RegistrationLimiter::new(),
), ),
} }
} }
pub fn runtime_proxy_token(&self) -> String {
self.runtime_auth.proxy_token()
}
} }
+20 -4
View File
@@ -1,11 +1,16 @@
use anyhow::Context; use anyhow::Context;
use axum::serve; use axum::serve;
use boosapi::{config::AppConfig, db, services::builtin_keys, AppState}; use boosapi::{
config::AppConfig,
db,
services::{builtin_keys, runtime_auth},
AppState,
};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use std::ffi::OsString; use std::ffi::OsString;
use std::net::SocketAddr; use std::net::SocketAddr;
use tokio::signal; use tokio::signal;
use tracing_subscriber::EnvFilter; use tracing_subscriber::{prelude::*, EnvFilter};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command( #[command(
@@ -104,7 +109,11 @@ async fn main() -> anyhow::Result<()> {
let filter = EnvFilter::try_from_default_env() let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(&config.server.log_level)); .unwrap_or_else(|_| EnvFilter::new(&config.server.log_level));
tracing_subscriber::fmt().with_env_filter(filter).init(); tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer())
.with(boosapi::services::runtime_logs::RuntimeLogLayer)
.init();
tracing::info!("Loaded configuration from {}", args.config); tracing::info!("Loaded configuration from {}", args.config);
@@ -116,6 +125,7 @@ async fn main() -> anyhow::Result<()> {
// Run migrations // Run migrations
db::run_migrations(db_kind, Some(&pool), None).await?; db::run_migrations(db_kind, Some(&pool), None).await?;
db::schema_compat::ensure_sqlite_upstream_compat(&pool).await?;
tracing::info!("Database migrations applied"); tracing::info!("Database migrations applied");
if args.migrate_only { if args.migrate_only {
@@ -134,9 +144,15 @@ async fn main() -> anyhow::Result<()> {
let builtin = builtin_keys::BuiltinKeys { let builtin = builtin_keys::BuiltinKeys {
comfyui_agent: comfyui_agent_key, comfyui_agent: comfyui_agent_key,
}; };
let runtime_auth = runtime_auth::RuntimeAuthSettings::load(
&pool,
config.auth.proxy_token.clone(),
)
.await
.context("failed to load runtime auth settings")?;
// Build app state // Build app state
let state = AppState::new(config.clone(), pool, builtin); let state = AppState::new(config.clone(), pool, builtin, runtime_auth);
if let Err(err) = state.comfyui.start().await { if let Err(err) = state.comfyui.start().await {
tracing::warn!(%err, "ComfyUI embedded process did not start"); tracing::warn!(%err, "ComfyUI embedded process did not start");
} }
+494 -48
View File
@@ -14,9 +14,7 @@ use crate::transformers::{
canonical::CanonicalRequest, canonical::CanonicalRequest,
}; };
use crate::AppState; use crate::AppState;
use reqwest::Client; use serde_json::{json, Value};
use serde_json::Value;
use std::time::Duration;
/// Resolved upstream info for a given model request. /// Resolved upstream info for a given model request.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -26,15 +24,6 @@ pub struct UpstreamRoute {
pub model: String, pub model: String,
} }
/// Create a shared reqwest client with sensible defaults.
fn http_client() -> Client {
Client::builder()
.timeout(Duration::from_secs(300))
.pool_max_idle_per_host(32)
.build()
.expect("Failed to build HTTP client")
}
fn parse_upstream_json( fn parse_upstream_json(
text: &str, text: &str,
upstream_url: &str, upstream_url: &str,
@@ -174,11 +163,11 @@ fn aggregate_chat_sse(text: &str, fallback_model: &str) -> Option<Value> {
/// Execute a non-streaming chat completions request. /// Execute a non-streaming chat completions request.
pub async fn execute_chat_completions( pub async fn execute_chat_completions(
_state: AppState, state: AppState,
route: UpstreamRoute, route: UpstreamRoute,
body: Value, body: Value,
) -> AppResult<Value> { ) -> AppResult<Value> {
let client = http_client(); let client = state.http.clone();
let upstream_url = upstream_url(&route.base_url, "/chat/completions"); let upstream_url = upstream_url(&route.base_url, "/chat/completions");
let resp = client let resp = client
@@ -210,19 +199,29 @@ pub async fn execute_chat_completions(
/// Execute a streaming chat completions request. /// Execute a streaming chat completions request.
/// Returns a vector of SSE data chunks as strings. /// Returns a vector of SSE data chunks as strings.
pub async fn execute_chat_completions_stream( pub async fn execute_chat_completions_stream(
_state: AppState, state: AppState,
route: UpstreamRoute, route: UpstreamRoute,
body: Value, body: Value,
) -> AppResult<Vec<String>> { ) -> AppResult<Vec<String>> {
execute_streaming_request(route, "/chat/completions", body).await execute_streaming_request(state, route, "/chat/completions", body).await
}
/// Execute a streaming chat completions request and return the live upstream response.
pub async fn execute_chat_completions_stream_response(
state: AppState,
route: UpstreamRoute,
body: Value,
) -> AppResult<reqwest::Response> {
execute_streaming_response(state, route, "/chat/completions", body).await
} }
/// Execute an Anthropic Messages API request against an Anthropic-shaped upstream. /// Execute an Anthropic Messages API request against an Anthropic-shaped upstream.
pub async fn execute_anthropic_messages( pub async fn execute_anthropic_messages(
state: AppState,
route: UpstreamRoute, route: UpstreamRoute,
request: CanonicalRequest, request: CanonicalRequest,
) -> AppResult<Value> { ) -> AppResult<Value> {
let client = http_client(); let client = state.http.clone();
let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/')); let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/'));
let compatibility = AnthropicMessagesCompatibility::default(); let compatibility = AnthropicMessagesCompatibility::default();
let body = format_messages_request_with_compat(&request, &compatibility); let body = format_messages_request_with_compat(&request, &compatibility);
@@ -260,10 +259,21 @@ pub async fn execute_anthropic_messages(
/// Execute a streaming Anthropic Messages request and return Anthropic SSE frames. /// Execute a streaming Anthropic Messages request and return Anthropic SSE frames.
pub async fn execute_anthropic_messages_stream( pub async fn execute_anthropic_messages_stream(
state: AppState,
route: UpstreamRoute, route: UpstreamRoute,
request: CanonicalRequest, request: CanonicalRequest,
) -> AppResult<Vec<String>> { ) -> AppResult<Vec<String>> {
let client = http_client(); let response = execute_anthropic_messages_stream_response(state, route, request).await?;
collect_anthropic_stream_frames(response).await
}
/// Execute a streaming Anthropic Messages request and return the live upstream response.
pub async fn execute_anthropic_messages_stream_response(
state: AppState,
route: UpstreamRoute,
request: CanonicalRequest,
) -> AppResult<reqwest::Response> {
let client = state.http.clone();
let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/')); let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/'));
let compatibility = AnthropicMessagesCompatibility::default(); let compatibility = AnthropicMessagesCompatibility::default();
let body = format_messages_request_with_compat(&request, &compatibility); let body = format_messages_request_with_compat(&request, &compatibility);
@@ -292,6 +302,10 @@ pub async fn execute_anthropic_messages_stream(
))); )));
} }
Ok(resp)
}
async fn collect_anthropic_stream_frames(resp: reqwest::Response) -> AppResult<Vec<String>> {
let mut frames = Vec::new(); let mut frames = Vec::new();
let mut stream = resp.bytes_stream(); let mut stream = resp.bytes_stream();
use futures::StreamExt; use futures::StreamExt;
@@ -406,32 +420,12 @@ fn denormalize_anthropic_stop_reason(reason: &str) -> &str {
/// Execute a streaming request against any upstream path (e.g. /responses). /// Execute a streaming request against any upstream path (e.g. /responses).
/// Returns SSE `data: ...` lines verbatim. /// Returns SSE `data: ...` lines verbatim.
pub async fn execute_streaming_request( pub async fn execute_streaming_request(
state: AppState,
route: UpstreamRoute, route: UpstreamRoute,
path_suffix: &str, path_suffix: &str,
body: Value, body: Value,
) -> AppResult<Vec<String>> { ) -> AppResult<Vec<String>> {
let client = http_client(); let resp = execute_streaming_response(state, route, path_suffix, body).await?;
let upstream_url = upstream_url(&route.base_url, path_suffix);
let resp = client
.post(&upstream_url)
.header("Authorization", format!("Bearer {}", route.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("Upstream request failed: {e}")))?;
let status = resp.status();
if !status.is_success() {
let text = resp
.text()
.await
.unwrap_or_else(|_| "unknown error".to_string());
return Err(AppError::UpstreamError(format!(
"Upstream returned {status}: {text}"
)));
}
let mut chunks = Vec::new(); let mut chunks = Vec::new();
let mut stream = resp.bytes_stream(); let mut stream = resp.bytes_stream();
@@ -455,14 +449,50 @@ pub async fn execute_streaming_request(
Ok(chunks) Ok(chunks)
} }
/// Execute a streaming request against any upstream path and return the live response.
///
/// The caller owns draining `bytes_stream()` so bytes can be flushed to the
/// downstream client as soon as the upstream sends them.
pub async fn execute_streaming_response(
state: AppState,
route: UpstreamRoute,
path_suffix: &str,
body: Value,
) -> AppResult<reqwest::Response> {
let client = state.http.clone();
let upstream_url = upstream_url(&route.base_url, path_suffix);
let resp = client
.post(&upstream_url)
.header("Authorization", format!("Bearer {}", route.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("Upstream request failed: {e}")))?;
let status = resp.status();
if !status.is_success() {
let text = resp
.text()
.await
.unwrap_or_else(|_| "unknown error".to_string());
return Err(AppError::UpstreamError(format!(
"Upstream returned {status}: {text}"
)));
}
Ok(resp)
}
/// Generic non-streaming proxy request to any upstream endpoint path. /// Generic non-streaming proxy request to any upstream endpoint path.
pub async fn execute_proxy_request( pub async fn execute_proxy_request(
_state: AppState, state: AppState,
route: UpstreamRoute, route: UpstreamRoute,
path_suffix: &str, path_suffix: &str,
body: Value, body: Value,
) -> AppResult<Value> { ) -> AppResult<Value> {
let client = http_client(); let client = state.http.clone();
let upstream_url = upstream_url(&route.base_url, path_suffix); let upstream_url = upstream_url(&route.base_url, path_suffix);
let resp = client let resp = client
@@ -492,8 +522,12 @@ pub async fn execute_proxy_request(
} }
/// Generic GET proxy request to an upstream endpoint path. /// Generic GET proxy request to an upstream endpoint path.
pub async fn execute_proxy_get(route: UpstreamRoute, path_suffix: &str) -> AppResult<Value> { pub async fn execute_proxy_get(
let client = http_client(); state: AppState,
route: UpstreamRoute,
path_suffix: &str,
) -> AppResult<Value> {
let client = state.http.clone();
let upstream_url = upstream_url(&route.base_url, path_suffix); let upstream_url = upstream_url(&route.base_url, path_suffix);
let resp = client let resp = client
@@ -518,6 +552,23 @@ pub async fn execute_proxy_get(route: UpstreamRoute, path_suffix: &str) -> AppRe
parse_upstream_json(&text, &upstream_url, status) parse_upstream_json(&text, &upstream_url, status)
} }
pub async fn execute_video_task_status(
state: AppState,
route: UpstreamRoute,
id: &str,
) -> AppResult<Value> {
if comfy_video_template_id(&route).is_some() {
return comfy_history(&state, id).await;
}
let suffix = if is_volcengine_ark_base(&route.base_url) {
format!("/contents/generations/tasks/{id}")
} else {
format!("/videos/{id}")
};
execute_proxy_get(state, route, &suffix).await
}
/// Execute an OpenAI-shaped video task request, with Volcengine Ark native /// Execute an OpenAI-shaped video task request, with Volcengine Ark native
/// task endpoints as a first-class compatibility target. /// task endpoints as a first-class compatibility target.
pub async fn execute_video_generation( pub async fn execute_video_generation(
@@ -525,6 +576,10 @@ pub async fn execute_video_generation(
route: UpstreamRoute, route: UpstreamRoute,
body: Value, body: Value,
) -> AppResult<Value> { ) -> AppResult<Value> {
if let Some(template_id) = comfy_video_template_id(&route) {
return execute_comfy_video_generation(state, route, body, &template_id).await;
}
if is_volcengine_ark_base(&route.base_url) { if is_volcengine_ark_base(&route.base_url) {
let body = volcengine_video_create_body(body, &route.model)?; let body = volcengine_video_create_body(body, &route.model)?;
return execute_proxy_request(state, route, "/contents/generations/tasks", body).await; return execute_proxy_request(state, route, "/contents/generations/tasks", body).await;
@@ -533,6 +588,76 @@ pub async fn execute_video_generation(
execute_proxy_request(state, route, "/videos", body).await execute_proxy_request(state, route, "/videos", body).await
} }
fn comfy_video_template_id(route: &UpstreamRoute) -> Option<String> {
route.base_url
.strip_prefix("comfy://")
.or_else(|| route.base_url.strip_prefix("comfy-template://"))
.or_else(|| route.model.strip_prefix("comfy://"))
.or_else(|| route.model.strip_prefix("comfy:"))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
async fn execute_comfy_video_generation(
state: AppState,
route: UpstreamRoute,
body: Value,
template_id: &str,
) -> AppResult<Value> {
if !state.config.comfyui.uses_native_backend() {
return Err(AppError::BadRequest(
"Comfy 视频模型需要配置 comfyui.backend = cloud、external 或 runninghub".into(),
));
}
let workflow = crate::workflow::storage::get_workflow(&state.db, template_id, None, true)
.await
.map_err(|err| AppError::BadRequest(format!("Comfy 视频模型模板不可用: {err}")))?;
let mut prompt = workflow_prompt_from_graph(&workflow.graph)
.ok_or_else(|| AppError::BadRequest("Comfy 视频模型模板缺少 prompt/workflow JSON".into()))?;
if prompt_contains_class_type(&prompt, "BoosAPITextToVideo") {
return Err(AppError::BadRequest(
"Comfy 视频模型模板不能包含 BoosAPITextToVideo 自定义节点,请选择真实 Comfy 视频节点模板".into(),
));
}
let mut mapping = comfy_mapping_from_graph(&workflow.graph).unwrap_or_else(default_comfy_mapping);
if !mapping_has_node(&mapping, &["prompt", "positive", "text", "promptNode"]) {
mapping = infer_comfy_mapping(&prompt);
}
let video_prompt = inject_comfy_video_inputs(&mut prompt, &mapping, &body)?;
let client_id = format!("boosapi-videos-{}", uuid::Uuid::new_v4().simple());
let response = comfy_json(
&state,
reqwest::Method::POST,
"/prompt",
Some(json!({
"client_id": client_id,
"prompt": prompt,
})),
)
.await?;
let prompt_id = scalar_string(
response
.get("prompt_id")
.or_else(|| response.get("promptId"))
.or_else(|| response.pointer("/data/prompt_id"))
.or_else(|| response.pointer("/data/promptId")),
)
.ok_or_else(|| AppError::UpstreamError(format!("Comfy response missing prompt_id: {response}")))?;
Ok(json!({
"id": prompt_id,
"object": "video.generation.task",
"model": route.model,
"status": "queued",
"backend": "comfy",
"template_id": template_id,
"prompt": video_prompt,
"response": response,
}))
}
fn is_volcengine_ark_base(base_url: &str) -> bool { fn is_volcengine_ark_base(base_url: &str) -> bool {
base_url.contains("ark.cn-beijing.volces.com") || base_url.contains("volces.com/api/v3") base_url.contains("ark.cn-beijing.volces.com") || base_url.contains("volces.com/api/v3")
} }
@@ -602,13 +727,334 @@ fn scalar_to_string(value: &Value) -> Option<String> {
} }
} }
fn scalar_string(value: Option<&Value>) -> Option<String> {
scalar_to_string(value?)
}
fn workflow_prompt_from_graph(graph: &Value) -> Option<Value> {
graph.get("prompt")
.cloned()
.or_else(|| graph.get("workflow").cloned())
.or_else(|| graph.get("comfyPrompt").cloned())
.or_else(|| graph_to_comfy_prompt(graph))
.or_else(|| graph.is_object().then(|| graph.clone()))
}
fn comfy_mapping_from_graph(graph: &Value) -> Option<Value> {
graph.pointer("/metadata/moneyPrinter/nodeMapping")
.or_else(|| graph.pointer("/moneyPrinter/nodeMapping"))
.or_else(|| graph.pointer("/metadata/video/nodeMapping"))
.or_else(|| graph.pointer("/video/nodeMapping"))
.or_else(|| graph.pointer("/metadata/digitalHuman/nodeMapping"))
.or_else(|| graph.pointer("/digitalHuman/nodeMapping"))
.cloned()
}
fn graph_to_comfy_prompt(graph: &Value) -> Option<Value> {
let nodes = graph.get("nodes")?.as_array()?;
let mut prompt = serde_json::Map::new();
for node in nodes {
let id = scalar_string(node.get("id"))?;
let class_type = node
.get("type")
.or_else(|| node.get("class_type"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if class_type.trim().is_empty() {
continue;
}
let inputs = node
.get("params")
.or_else(|| node.get("inputs"))
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
prompt.insert(id, json!({ "class_type": class_type, "inputs": inputs }));
}
for edge in graph.get("edges").and_then(Value::as_array).into_iter().flatten() {
let Some(from) = edge.get("from").and_then(Value::as_str) else { continue; };
let Some(to) = edge.get("to").and_then(Value::as_str) else { continue; };
let Some((from_id, _from_port)) = from.split_once('.') else { continue; };
let Some((to_id, to_field)) = to.split_once('.') else { continue; };
if let Some(inputs) = prompt
.get_mut(to_id)
.and_then(Value::as_object_mut)
.and_then(|node| node.get_mut("inputs"))
.and_then(Value::as_object_mut)
{
inputs.insert(to_field.to_string(), json!([from_id, 0]));
}
}
Some(Value::Object(prompt))
}
fn default_comfy_mapping() -> Value {
json!({
"prompt": { "nodeId": "", "field": "prompt" },
"negativePrompt": { "nodeId": "", "field": "text" },
"ratio": { "nodeId": "", "field": "ratio" },
"resolution": { "nodeId": "", "field": "resolution" },
"duration": { "nodeId": "", "field": "duration" }
})
}
fn mapping_value<'a>(mapping: &'a Value, keys: &[&str]) -> Option<&'a Value> {
keys.iter().find_map(|key| mapping.get(*key))
}
fn mapping_node_id(mapping: &Value, keys: &[&str]) -> Option<String> {
mapping_value(mapping, keys).and_then(|value| {
if value.is_object() {
scalar_string(value.get("nodeId").or_else(|| value.get("node_id")).or(Some(value)))
} else {
scalar_string(Some(value))
}
})
}
fn mapping_field(mapping: &Value, keys: &[&str], default_field: &str) -> String {
mapping_value(mapping, keys)
.and_then(|value| {
if value.is_object() {
scalar_string(value.get("field").or_else(|| value.get("fieldName")))
} else {
None
}
})
.unwrap_or_else(|| default_field.to_string())
}
fn mapping_has_node(mapping: &Value, keys: &[&str]) -> bool {
mapping_node_id(mapping, keys).is_some_and(|value| !value.trim().is_empty())
}
fn first_node_by_class(prompt: &serde_json::Map<String, Value>, class_type: &str) -> Option<String> {
prompt
.iter()
.find(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some(class_type))
.map(|(id, _)| id.clone())
}
fn infer_comfy_mapping(prompt: &Value) -> Value {
let Some(object) = prompt.as_object() else {
return default_comfy_mapping();
};
let prompt_node = object
.iter()
.find(|(_, node)| {
let class_type = node.get("class_type").and_then(Value::as_str).unwrap_or_default().to_ascii_lowercase();
let inputs = node
.get("inputs")
.and_then(Value::as_object)
.map(|value| value.keys().cloned().collect::<Vec<_>>().join(" ").to_ascii_lowercase())
.unwrap_or_default();
class_type.contains("text")
|| class_type.contains("prompt")
|| class_type.contains("wan")
|| class_type.contains("ltx")
|| class_type.contains("video")
|| inputs.contains("prompt")
|| inputs.contains("positive")
|| inputs.contains("text")
})
.map(|(id, _)| id.clone())
.unwrap_or_default();
let video_node = first_node_by_class(object, "Wan2ImageToVideoApi")
.or_else(|| first_node_by_class(object, "Wan2TextToVideoApi"))
.or_else(|| first_node_by_class(object, "TextGenerateLTX2Prompt"))
.or_else(|| first_node_by_class(object, "KSampler"))
.unwrap_or_else(|| prompt_node.clone());
json!({
"prompt": { "nodeId": prompt_node, "field": "text" },
"ratio": { "nodeId": video_node, "field": "ratio" },
"resolution": { "nodeId": video_node, "field": "resolution" },
"duration": { "nodeId": video_node, "field": "duration" }
})
}
fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value) -> AppResult<()> {
if node_id.trim().is_empty() {
return Err(AppError::BadRequest("Comfy 视频模型节点映射包含空 nodeId".into()));
}
let Some(node) = prompt.get_mut(node_id) else {
return Err(AppError::BadRequest(format!("Comfy prompt missing node {node_id}")));
};
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
return Err(AppError::BadRequest(format!("Comfy node {node_id} has no inputs")));
};
inputs.insert(field.to_string(), value);
Ok(())
}
fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value) -> AppResult<String> {
let video_prompt = scalar_string(body.get("prompt"))
.ok_or_else(|| AppError::BadRequest("prompt is required for Comfy video models".into()))?;
let prompt_node = mapping_node_id(mapping, &["prompt", "positive", "text", "promptNode"])
.ok_or_else(|| AppError::BadRequest("Comfy 视频模型缺少提示词节点映射".into()))?;
apply_node_input(
prompt,
&prompt_node,
&mapping_field(mapping, &["prompt", "positive", "text", "promptNode"], "prompt"),
json!(video_prompt),
)?;
if let Some(negative_node) = mapping_node_id(mapping, &["negativePrompt", "negative", "negativeNode"]) {
let negative = scalar_string(body.get("negative_prompt").or_else(|| body.get("negative")))
.unwrap_or_else(|| "low quality, blurry, distorted, watermark, text artifacts".to_string());
apply_node_input(
prompt,
&negative_node,
&mapping_field(mapping, &["negativePrompt", "negative", "negativeNode"], "text"),
json!(negative),
)?;
}
for (keys, request_keys, default_field) in [
(&["ratio", "ratioNode"][..], &["ratio", "aspectRatio", "aspect_ratio"][..], "ratio"),
(&["resolution", "resolutionNode"][..], &["resolution", "size"][..], "resolution"),
(&["duration", "durationNode"][..], &["duration"][..], "duration"),
] {
if let Some(node_id) = mapping_node_id(mapping, keys) {
if let Some(value) = request_keys.iter().find_map(|key| body.get(*key).cloned()) {
apply_node_input(prompt, &node_id, &mapping_field(mapping, keys, default_field), value)?;
}
}
}
Ok(video_prompt)
}
fn prompt_contains_class_type(prompt: &Value, class_type: &str) -> bool {
match prompt {
Value::Object(object) => {
object
.get("class_type")
.and_then(Value::as_str)
.is_some_and(|value| value == class_type)
|| object.values().any(|value| prompt_contains_class_type(value, class_type))
}
Value::Array(items) => items.iter().any(|value| prompt_contains_class_type(value, class_type)),
_ => false,
}
}
fn comfy_native_path(state: &AppState, path: &str) -> String {
if state.config.comfyui.uses_cloud_backend() {
format!(
"/api{}",
path.strip_prefix('/')
.map(|value| format!("/{value}"))
.unwrap_or_else(|| path.to_string())
)
} else {
path.to_string()
}
}
fn comfy_native_url(state: &AppState, path: &str) -> String {
format!(
"{}{}",
state.config.comfyui.target_base_url(),
comfy_native_path(state, path)
)
}
async fn comfy_json(
state: &AppState,
method: reqwest::Method,
path: &str,
body: Option<Value>,
) -> AppResult<Value> {
let client = state.http.clone();
let mut request = client.request(method, comfy_native_url(state, path));
if let Some(api_key) = state
.config
.comfyui
.api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
request = request
.header("X-API-Key", api_key)
.header("Authorization", format!("Bearer {api_key}"));
}
if let Some(body) = body {
request = request.json(&body);
}
let response = request
.send()
.await
.map_err(|err| AppError::UpstreamError(format!("Comfy backend unreachable: {err}")))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
let value = serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({ "body": text }));
if !status.is_success() {
return Err(AppError::UpstreamError(value.to_string()));
}
Ok(value)
}
async fn comfy_history(state: &AppState, prompt_id: &str) -> AppResult<Value> {
let path = if state.config.comfyui.uses_cloud_backend() {
format!("/jobs/{prompt_id}")
} else {
format!("/history/{prompt_id}")
};
let history = comfy_json(state, reqwest::Method::GET, &path, None).await?;
let failed = comfy_failure_message(&history);
Ok(json!({
"id": prompt_id,
"object": "video.generation.task",
"backend": "comfy",
"status": if failed.is_some() {
"failed"
} else if comfy_output_objects(&history) {
"succeeded"
} else {
"running"
},
"error": failed,
"history": history,
}))
}
fn comfy_failure_message(value: &Value) -> Option<String> {
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
.unwrap_or_default()
.to_ascii_lowercase();
if matches!(status.as_str(), "failed" | "error" | "cancelled" | "canceled") {
return Some(
scalar_string(value.pointer("/execution_error/exception_message"))
.or_else(|| scalar_string(value.get("error")))
.or_else(|| scalar_string(value.get("message")))
.unwrap_or_else(|| format!("Comfy job {status}")),
);
}
scalar_string(value.pointer("/execution_error/exception_message"))
}
fn comfy_output_objects(value: &Value) -> bool {
match value {
Value::Object(object) => {
["videos", "gifs", "images"]
.iter()
.any(|key| object.get(*key).and_then(Value::as_array).is_some_and(|items| !items.is_empty()))
|| object.values().any(comfy_output_objects)
}
Value::Array(items) => items.iter().any(comfy_output_objects),
_ => false,
}
}
/// Execute an image generation request. /// Execute an image generation request.
pub async fn execute_image_generation( pub async fn execute_image_generation(
_state: AppState, state: AppState,
route: UpstreamRoute, route: UpstreamRoute,
body: Value, body: Value,
) -> AppResult<Value> { ) -> AppResult<Value> {
let client = http_client(); let client = state.http.clone();
let upstream_url = upstream_url(&route.base_url, "/images/generations"); let upstream_url = upstream_url(&route.base_url, "/images/generations");
let resp = client let resp = client
@@ -661,13 +1107,13 @@ pub struct MultipartField {
/// the route-resolved upstream model and skip any client-side fields /// the route-resolved upstream model and skip any client-side fields
/// that don't belong on the wire. /// that don't belong on the wire.
pub async fn execute_image_edit( pub async fn execute_image_edit(
_state: AppState, state: AppState,
route: UpstreamRoute, route: UpstreamRoute,
fields: Vec<MultipartField>, fields: Vec<MultipartField>,
) -> AppResult<Value> { ) -> AppResult<Value> {
use reqwest::multipart::{Form, Part}; use reqwest::multipart::{Form, Part};
let client = http_client(); let client = state.http.clone();
let upstream_url = upstream_url(&route.base_url, "/images/edits"); let upstream_url = upstream_url(&route.base_url, "/images/edits");
let mut form = Form::new(); let mut form = Form::new();
File diff suppressed because it is too large Load Diff
+255 -11
View File
@@ -13,6 +13,8 @@ use serde_json::{json, Value};
use std::time::Duration; use std::time::Duration;
use tracing::info; use tracing::info;
const RUNTIME_HEALTH_DEGRADED_LATENCY_MS: i64 = 5_000;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ListAccountsQuery { pub struct ListAccountsQuery {
pub site_id: Option<i64>, pub site_id: Option<i64>,
@@ -151,10 +153,23 @@ pub async fn list_accounts(
let site_by_id: std::collections::HashMap<i64, &Site> = let site_by_id: std::collections::HashMap<i64, &Site> =
sites.iter().map(|s| (s.id, s)).collect(); sites.iter().map(|s| (s.id, s)).collect();
let latest_health_rows: Vec<(i64, String, Option<i64>, Option<String>, Option<i64>, Option<String>)> = let latest_health_rows: Vec<(
i64,
String,
Option<i64>,
Option<String>,
Option<i64>,
Option<String>,
Option<String>,
Option<String>,
Option<i64>,
Option<i64>,
i64,
)> =
sqlx::query_as( sqlx::query_as(
r#" r#"
SELECT l.account_id, l.status, l.http_status, l.error_message, l.latency_ms, l.created_at SELECT l.account_id, l.status, l.http_status, l.error_message, l.latency_ms, l.created_at,
l.model_requested, l.model_actual, l.route_id, l.channel_id, l.id
FROM proxy_logs l FROM proxy_logs l
INNER JOIN ( INNER JOIN (
SELECT account_id, MAX(id) AS id SELECT account_id, MAX(id) AS id
@@ -176,14 +191,42 @@ pub async fn list_accounts(
Option<String>, Option<String>,
Option<i64>, Option<i64>,
Option<String>, Option<String>,
Option<String>,
Option<String>,
Option<i64>,
Option<i64>,
i64,
), ),
> = latest_health_rows > = latest_health_rows
.into_iter() .into_iter()
.map( .map(
|(account_id, status, http_status, error_message, latency_ms, created_at)| { |(
account_id,
status,
http_status,
error_message,
latency_ms,
created_at,
model_requested,
model_actual,
route_id,
channel_id,
log_id,
)| {
( (
account_id, account_id,
(status, http_status, error_message, latency_ms, created_at), (
status,
http_status,
error_message,
latency_ms,
created_at,
model_requested,
model_actual,
route_id,
channel_id,
log_id,
),
) )
}, },
) )
@@ -214,6 +257,11 @@ fn account_response_value(
Option<String>, Option<String>,
Option<i64>, Option<i64>,
Option<String>, Option<String>,
Option<String>,
Option<String>,
Option<i64>,
Option<i64>,
i64,
)>, )>,
) -> Value { ) -> Value {
let credential_mode = resolve_account_credential_mode(account); let credential_mode = resolve_account_credential_mode(account);
@@ -246,16 +294,46 @@ fn account_response_value(
"state": "unhealthy", "state": "unhealthy",
"reason": "连接凭证已过期,请更新凭证", "reason": "连接凭证已过期,请更新凭证",
}) })
} else if let Some((status, http_status, error_message, latency_ms, created_at)) = } else if let Some((
status,
http_status,
error_message,
latency_ms,
created_at,
model_requested,
model_actual,
route_id,
channel_id,
log_id,
)) =
runtime_health runtime_health
{ {
let ok = status == "success"; let ok = status == "success";
let is_degraded_latency =
ok && latency_ms.is_some_and(|ms| ms >= RUNTIME_HEALTH_DEGRADED_LATENCY_MS);
let model = model_actual
.as_ref()
.filter(|value| !value.trim().is_empty())
.or_else(|| model_requested.as_ref().filter(|value| !value.trim().is_empty()));
json!({ json!({
"state": if ok { "healthy" } else { "unhealthy" }, "state": if !ok {
"unhealthy"
} else if is_degraded_latency {
"degraded"
} else {
"healthy"
},
"reason": if ok { "reason": if ok {
match latency_ms { if is_degraded_latency {
Some(ms) => format!("最近代理请求成功,用时 {}ms", ms), match latency_ms {
None => "最近代理请求成功".to_string(), Some(ms) => format!("最近代理请求成功但延迟偏高,用时 {}ms", ms),
None => "最近代理请求成功但延迟偏高".to_string(),
}
} else {
match latency_ms {
Some(ms) => format!("最近代理请求成功,用时 {}ms", ms),
None => "最近代理请求成功".to_string(),
}
} }
} else { } else {
error_message error_message
@@ -265,11 +343,17 @@ fn account_response_value(
"latencyMs": latency_ms, "latencyMs": latency_ms,
"httpStatus": http_status, "httpStatus": http_status,
"checkedAt": created_at, "checkedAt": created_at,
"modelRequested": model_requested,
"modelActual": model_actual,
"recentModel": model,
"routeId": route_id,
"channelId": channel_id,
"logId": log_id,
}) })
} else if credential_mode == "apikey" || account.status == "active" { } else if credential_mode == "apikey" || account.status == "active" {
json!({ json!({
"state": "degraded", "state": "unknown",
"reason": "尚无代理请求记录,等待首次调用后更新", "reason": "尚无代理请求记录,首次调用后自动更新",
}) })
} else { } else {
json!({ json!({
@@ -1266,3 +1350,163 @@ pub async fn refresh_account_models(
} }
}))) })))
} }
#[cfg(test)]
mod tests {
use super::*;
fn account_fixture(status: &str, credential_mode: &str) -> Account {
Account {
id: 7,
site_id: 3,
username: Some("tester".to_string()),
access_token: "sk-test".to_string(),
api_token: None,
balance: Some(0.0),
balance_used: Some(0.0),
quota: None,
unit_cost: None,
value_score: None,
status: status.to_string(),
checkin_enabled: Some(false),
last_checkin_at: None,
last_balance_refresh: None,
extra_config: Some(json!({ "credentialMode": credential_mode }).to_string()),
is_pinned: Some(false),
sort_order: Some(0),
oauth_provider: None,
oauth_account_key: None,
oauth_project_id: None,
created_at: None,
updated_at: None,
}
}
fn site_fixture(status: &str) -> Site {
Site {
id: 3,
name: "Demo".to_string(),
platform: "openai".to_string(),
url: "https://example.com".to_string(),
status: status.to_string(),
api_key: None,
proxy_url: None,
use_system_proxy: None,
custom_headers: None,
external_checkin_url: None,
global_weight: None,
post_refresh_probe_enabled: None,
post_refresh_probe_model: None,
post_refresh_probe_scope: None,
post_refresh_probe_latency_threshold_ms: None,
is_pinned: Some(false),
sort_order: Some(0),
created_at: None,
updated_at: None,
}
}
#[test]
fn active_apikey_without_proxy_logs_is_pending_detection_not_degraded() {
let account = account_fixture("active", "apikey");
let site = site_fixture("active");
let value = account_response_value(&account, Some(&site), None);
let runtime_health = value
.get("runtimeHealth")
.expect("runtimeHealth should be present");
assert_eq!(
runtime_health.get("state").and_then(Value::as_str),
Some("unknown")
);
assert_eq!(
runtime_health.get("reason").and_then(Value::as_str),
Some("尚无代理请求记录,首次调用后自动更新")
);
}
#[test]
fn runtime_health_includes_recent_proxy_context_for_diagnostics() {
let account = account_fixture("active", "apikey");
let site = site_fixture("active");
let latest = (
"failed".to_string(),
Some(404),
Some("Not found".to_string()),
Some(1820),
Some("2026-06-08 10:00:00".to_string()),
Some("gpt-4o-mini".to_string()),
Some("gpt-4o-mini-2026".to_string()),
Some(77),
Some(88),
101,
);
let value = account_response_value(&account, Some(&site), Some(&latest));
let runtime_health = value
.get("runtimeHealth")
.expect("runtimeHealth should be present");
assert_eq!(
runtime_health.get("state").and_then(Value::as_str),
Some("unhealthy")
);
assert_eq!(
runtime_health.get("recentModel").and_then(Value::as_str),
Some("gpt-4o-mini-2026")
);
assert_eq!(
runtime_health.get("modelRequested").and_then(Value::as_str),
Some("gpt-4o-mini")
);
assert_eq!(
runtime_health.get("routeId").and_then(Value::as_i64),
Some(77)
);
assert_eq!(
runtime_health.get("channelId").and_then(Value::as_i64),
Some(88)
);
assert_eq!(
runtime_health.get("logId").and_then(Value::as_i64),
Some(101)
);
}
#[test]
fn successful_but_slow_proxy_log_marks_runtime_health_degraded() {
let account = account_fixture("active", "apikey");
let site = site_fixture("active");
let latest = (
"success".to_string(),
Some(200),
None,
Some(7928),
Some("2026-06-08 10:00:00".to_string()),
Some("gpt-4o-mini".to_string()),
Some("gpt-4o-mini".to_string()),
Some(77),
Some(88),
101,
);
let value = account_response_value(&account, Some(&site), Some(&latest));
let runtime_health = value
.get("runtimeHealth")
.expect("runtimeHealth should be present");
assert_eq!(
runtime_health.get("state").and_then(Value::as_str),
Some("degraded")
);
assert_eq!(
runtime_health.get("reason").and_then(Value::as_str),
Some("最近代理请求成功但延迟偏高,用时 7928ms")
);
assert_eq!(
runtime_health.get("latencyMs").and_then(Value::as_i64),
Some(7928)
);
}
}
+2436 -76
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -409,6 +409,7 @@ mod tests {
first_byte_timeout_secs: 30, first_byte_timeout_secs: 30,
max_retries: 3, max_retries: 3,
proxy_log_retention_days: 7, proxy_log_retention_days: 7,
template_proxy_url: None,
}, },
comfyui: Default::default(), comfyui: Default::default(),
}, },
@@ -416,6 +417,7 @@ mod tests {
BuiltinKeys { BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-abcdef0123456789".into()), comfyui_agent: (1, "sk-comfyui-abcdef0123456789".into()),
}, },
crate::services::runtime_auth::RuntimeAuthSettings::from_token_for_test("proxy"),
) )
} }
+116 -13
View File
@@ -1,4 +1,5 @@
use crate::{ use crate::{
auth::jwt::validate_jwt,
db::models::setting::Setting, db::models::setting::Setting,
error::{AppError, AppResult}, error::{AppError, AppResult},
AppState, AppState,
@@ -24,7 +25,8 @@ mod sites;
mod tokens; mod tokens;
mod users; mod users;
/// Middleware to require admin authentication via config token or admin JWT. /// Middleware to require admin authentication via the configured admin API token
/// or a JWT belonging to a user with role='admin'.
async fn require_admin_auth( async fn require_admin_auth(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@@ -41,16 +43,12 @@ async fn require_admin_auth(
return Ok(next.run(request).await); return Ok(next.run(request).await);
} }
match crate::auth::jwt::validate_jwt(&state.config.auth.jwt_secret, auth_header) { let claims = validate_jwt(&state.config.auth.jwt_secret, auth_header)
Ok(claims) if claims.role == "admin" => {} .map_err(|_| AppError::Unauthorized("Invalid admin token".into()))?;
Ok(_) => { if claims.role != "admin" {
return Err(AppError::Forbidden( return Err(AppError::Forbidden(
"Admin account is required for this route".into(), "Admin account is required for this route".into(),
)); ));
}
Err(_) => {
return Err(AppError::Unauthorized("Invalid admin token".into()));
}
} }
Ok(next.run(request).await) Ok(next.run(request).await)
@@ -73,6 +71,15 @@ pub fn routes(state: AppState) -> Router<AppState> {
.route( .route(
"/api/sites/{id}/disabled-models", "/api/sites/{id}/disabled-models",
get(sites::list_site_disabled_models).put(sites::update_site_disabled_models), get(sites::list_site_disabled_models).put(sites::update_site_disabled_models),
)
.route(
"/api/sites/{id}/available-models",
get(sites::list_site_available_models),
)
.route("/api/sites/{id}/probe-now", post(sites::probe_site_now))
.route(
"/api/sites/{id}/probe-stream",
get(sites::probe_site_stream),
); );
let accounts_routes = Router::new() let accounts_routes = Router::new()
@@ -180,6 +187,18 @@ pub fn routes(state: AppState) -> Router<AppState> {
.delete(users::delete_user), .delete(users::delete_user),
) )
.route("/api/admin/users/{id}/usage", get(users::get_user_usage)) .route("/api/admin/users/{id}/usage", get(users::get_user_usage))
.route(
"/api/admin/user-balances",
get(users::list_user_balances),
)
.route(
"/api/admin/users/{id}/balance/adjust",
post(users::adjust_user_balance),
)
.route(
"/api/admin/users/{id}/credits/adjust",
post(users::adjust_user_credits),
)
.route( .route(
"/api/admin/users/{id}/keys", "/api/admin/users/{id}/keys",
get(users::list_user_keys).post(users::create_user_key), get(users::list_user_keys).post(users::create_user_key),
@@ -217,6 +236,7 @@ const HIDDEN_SETTINGS_PREFIX: &str = "builtin_key.";
fn is_hidden_setting_key(key: &str) -> bool { fn is_hidden_setting_key(key: &str) -> bool {
key.starts_with(HIDDEN_SETTINGS_PREFIX) key.starts_with(HIDDEN_SETTINGS_PREFIX)
|| key == crate::services::runtime_auth::PROXY_TOKEN_SETTING_KEY
} }
/// GET /api/settings — return all settings as a key-value map. /// GET /api/settings — return all settings as a key-value map.
@@ -288,9 +308,15 @@ async fn update_setting(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::auth::jwt::generate_jwt;
use crate::config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig}; use crate::config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig};
use crate::services::builtin_keys::BuiltinKeys; use crate::services::builtin_keys::BuiltinKeys;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use sqlx::sqlite::SqlitePoolOptions; use sqlx::sqlite::SqlitePoolOptions;
use tower::ServiceExt as _;
async fn settings_state() -> AppState { async fn settings_state() -> AppState {
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
@@ -313,6 +339,12 @@ mod tests {
.execute(&pool) .execute(&pool)
.await .await
.unwrap(); .unwrap();
sqlx::query(
"INSERT INTO settings (key, value) VALUES ('runtime.auth.proxy_token', 'sk-runtime-secret')",
)
.execute(&pool)
.await
.unwrap();
AppState::new( AppState::new(
AppConfig { AppConfig {
@@ -341,18 +373,22 @@ mod tests {
first_byte_timeout_secs: 30, first_byte_timeout_secs: 30,
max_retries: 3, max_retries: 3,
proxy_log_retention_days: 7, proxy_log_retention_days: 7,
template_proxy_url: None,
}, },
comfyui: Default::default(), comfyui: Default::default(),
pixelle_video: Default::default(),
payment: Default::default(),
}, },
pool, pool,
BuiltinKeys { BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-secret".into()), comfyui_agent: (1, "sk-comfyui-secret".into()),
}, },
crate::services::runtime_auth::RuntimeAuthSettings::from_token_for_test("proxy"),
) )
} }
/// 5.2 — `GET /api/settings` returns regular settings but never any /// 5.2 — `GET /api/settings` returns regular settings but never protected
/// `builtin_key.*` rows. /// secret rows.
#[tokio::test] #[tokio::test]
async fn list_settings_filters_builtin_key_prefix() { async fn list_settings_filters_builtin_key_prefix() {
let state = settings_state().await; let state = settings_state().await;
@@ -363,6 +399,10 @@ mod tests {
!data.contains_key("builtin_key.comfyui_agent.secret"), !data.contains_key("builtin_key.comfyui_agent.secret"),
"builtin_key.* rows must not appear in /api/settings response" "builtin_key.* rows must not appear in /api/settings response"
); );
assert!(
!data.contains_key("runtime.auth.proxy_token"),
"runtime proxy token must not appear in /api/settings response"
);
} }
/// 5.3 — `PUT /api/settings` rejects writes targeting `builtin_key.*` /// 5.3 — `PUT /api/settings` rejects writes targeting `builtin_key.*`
@@ -401,4 +441,67 @@ mod tests {
.expect("update ok"); .expect("update ok");
assert_eq!(resp.0["data"]["value"], "Renamed"); assert_eq!(resp.0["data"]["value"], "Renamed");
} }
#[tokio::test]
async fn admin_routes_accept_config_token_and_admin_jwt_reject_user_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);
// Admin-role JWT is accepted: admins signed in at /user/login (which now
// mints an admin JWT) can reach admin routes without the global token.
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::OK);
// Non-admin (user-role) JWT is rejected with 403.
let user_jwt =
generate_jwt(&state.config.auth.jwt_secret, "2", "user", 1).expect("user jwt");
let user_jwt_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/settings/auth/info")
.header("Authorization", format!("Bearer {user_jwt}"))
.body(Body::empty())
.unwrap(),
)
.await
.expect("user jwt request");
assert_eq!(user_jwt_response.status(), StatusCode::FORBIDDEN);
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);
}
} }
File diff suppressed because it is too large Load Diff
+472 -3
View File
@@ -4,6 +4,7 @@ use crate::{
auth::password::hash_password, auth::password::hash_password,
db::models::{downstream_api_key::DownstreamApiKey, user::User}, db::models::{downstream_api_key::DownstreamApiKey, user::User},
error::{AppError, AppResult}, error::{AppError, AppResult},
routes::auth::DEFAULT_NEW_USER_MAX_COST_USD,
AppState, AppState,
}; };
use axum::{ use axum::{
@@ -238,17 +239,18 @@ pub async fn create_user(
let user_id = result.last_insert_rowid(); let user_id = result.last_insert_rowid();
// Auto-generate downstream API key // Auto-generate downstream API key with the default starter credit.
let key = format!("sk-user-{}", uuid::Uuid::new_v4()); let key = format!("sk-user-{}", uuid::Uuid::new_v4());
let key_name = format!("{} 的默认密钥", payload.username.trim()); let key_name = format!("{} 的默认密钥", payload.username.trim());
sqlx::query( sqlx::query(
"INSERT INTO downstream_api_keys (name, key, user_id, enabled) VALUES (?, ?, ?, ?)", "INSERT INTO downstream_api_keys (name, key, user_id, enabled, max_cost) VALUES (?, ?, ?, ?, ?)",
) )
.bind(&key_name) .bind(&key_name)
.bind(&key) .bind(&key)
.bind(user_id) .bind(user_id)
.bind(true) .bind(true)
.bind(DEFAULT_NEW_USER_MAX_COST_USD)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
@@ -324,7 +326,10 @@ pub async fn get_user_usage(
// Recent logs // Recent logs
let recent_logs: Vec<Value> = sqlx::query( let recent_logs: Vec<Value> = sqlx::query(
r#" r#"
SELECT l.* SELECT
l.*,
k.id AS downstream_api_key_id,
k.name AS downstream_key_name
FROM usage_logs l FROM usage_logs l
JOIN downstream_api_keys k ON l.downstream_key_id = k.id JOIN downstream_api_keys k ON l.downstream_key_id = k.id
WHERE k.user_id = ? WHERE k.user_id = ?
@@ -341,6 +346,11 @@ pub async fn get_user_usage(
json!({ json!({
"id": row.get::<i64, _>("id"), "id": row.get::<i64, _>("id"),
"model": row.get::<Option<String>, _>("model"), "model": row.get::<Option<String>, _>("model"),
"downstreamApiKeyId": row.get::<Option<i64>, _>("downstream_api_key_id"),
"downstreamKeyName": row.get::<Option<String>, _>("downstream_key_name"),
"status": "success",
"httpStatus": 200,
"source": "usage-logs",
"promptTokens": row.get::<Option<i64>, _>("prompt_tokens").unwrap_or(0), "promptTokens": row.get::<Option<i64>, _>("prompt_tokens").unwrap_or(0),
"completionTokens": row.get::<Option<i64>, _>("completion_tokens").unwrap_or(0), "completionTokens": row.get::<Option<i64>, _>("completion_tokens").unwrap_or(0),
"totalTokens": row.get::<Option<i64>, _>("total_tokens").unwrap_or(0), "totalTokens": row.get::<Option<i64>, _>("total_tokens").unwrap_or(0),
@@ -699,3 +709,462 @@ pub async fn update_user_key(
Ok(Json(json!({ "data": updated }))) Ok(Json(json!({ "data": updated })))
} }
// ── User balance management (admin) ────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct ListBalancesQuery {
pub page: Option<i64>,
pub page_size: Option<i64>,
pub q: Option<String>,
}
/// GET /api/admin/user-balances — list users with their credit pool
/// (summed max_cost / used_cost across their downstream keys).
pub async fn list_user_balances(
State(state): State<AppState>,
Query(query): Query<ListBalancesQuery>,
) -> AppResult<Json<Value>> {
let page = query.page.unwrap_or(1).max(1);
let page_size = query.page_size.unwrap_or(50).clamp(1, 200);
let offset = (page - 1) * page_size;
let q = query.q.unwrap_or_default().trim().to_string();
let q_like = format!("%{}%", q);
let rows = sqlx::query(
r#"
SELECT
u.id AS user_id,
u.username AS username,
u.email AS email,
u.role AS role,
u.status AS status,
COALESCE(SUM(COALESCE(k.used_cost, 0.0)), 0.0) AS used_cost,
COALESCE(SUM(CASE WHEN k.max_cost IS NULL THEN 0.0 ELSE k.max_cost END), 0.0) AS limited_max_cost,
SUM(CASE WHEN k.id IS NULL THEN 0 WHEN k.max_cost IS NULL THEN 1 ELSE 0 END) AS unlimited_key_count,
COUNT(k.id) AS key_count
FROM users u
LEFT JOIN downstream_api_keys k ON k.user_id = u.id
WHERE (? = '' OR u.username LIKE ? OR u.email LIKE ? OR CAST(u.id AS TEXT) = ?)
GROUP BY u.id
ORDER BY u.id DESC
LIMIT ? OFFSET ?
"#,
)
.bind(&q)
.bind(&q_like)
.bind(&q_like)
.bind(&q)
.bind(page_size)
.bind(offset)
.fetch_all(&*state.db)
.await?;
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM users
WHERE (? = '' OR username LIKE ? OR email LIKE ? OR CAST(id AS TEXT) = ?)",
)
.bind(&q)
.bind(&q_like)
.bind(&q_like)
.bind(&q)
.fetch_one(&*state.db)
.await?;
let items: Vec<Value> = rows
.iter()
.map(|row| {
use sqlx::Row;
let used: f64 = row.try_get("used_cost").unwrap_or(0.0);
let limited_max: f64 = row.try_get("limited_max_cost").unwrap_or(0.0);
let unlimited: i64 = row.try_get("unlimited_key_count").unwrap_or(0);
let remaining = if unlimited > 0 {
Value::Null
} else {
Value::from((limited_max - used).max(0.0))
};
json!({
"userId": row.try_get::<i64, _>("user_id").unwrap_or(0),
"username": row.try_get::<Option<String>, _>("username").ok().flatten(),
"email": row.try_get::<Option<String>, _>("email").ok().flatten(),
"role": row.try_get::<Option<String>, _>("role").ok().flatten(),
"status": row.try_get::<Option<String>, _>("status").ok().flatten(),
"keyCount": row.try_get::<i64, _>("key_count").unwrap_or(0),
"usedCost": used,
"maxCost": if unlimited > 0 { Value::Null } else { Value::from(limited_max) },
"remainingCost": remaining,
"unlimitedKeyCount": unlimited,
})
})
.collect();
Ok(Json(json!({
"data": items,
"meta": {
"page": page,
"pageSize": page_size,
"total": total,
"totalPages": ((total + page_size - 1) / page_size).max(1),
},
})))
}
#[derive(Debug, Deserialize)]
pub struct AdjustBalanceRequest {
/// Amount to add (positive) or remove (negative) from the user's credit pool.
pub delta: f64,
}
/// POST /api/admin/users/{id}/balance/adjust — add or remove credit on the
/// user's primary (lowest-id) downstream key by bumping its max_cost.
pub async fn adjust_user_balance(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(payload): Json<AdjustBalanceRequest>,
) -> AppResult<Json<Value>> {
let delta = payload.delta;
if !delta.is_finite() || delta == 0.0 {
return Err(AppError::BadRequest("delta must be a non-zero finite number".into()));
}
crate::services::billing::ledger::ensure_ledger_schema(&state.db).await?;
let key: Option<(i64, Option<f64>, Option<f64>)> = sqlx::query_as(
"SELECT id, max_cost, used_cost
FROM downstream_api_keys
WHERE user_id = ?
ORDER BY id ASC
LIMIT 1",
)
.bind(id)
.fetch_optional(&*state.db)
.await?;
let Some((key_id, max_cost, used_cost)) = key else {
return Err(AppError::BadRequest(
"该用户没有可调整额度的 API Key(请先为其创建 Key)".into(),
));
};
let used = used_cost.unwrap_or(0.0).max(0.0);
let current = max_cost.unwrap_or(used).max(used);
// Never let the limit drop below what's already been spent.
let next_max = (current + delta).max(used);
let remaining = (next_max - used).max(0.0);
let mut tx = state.db.begin().await?;
sqlx::query(
"UPDATE downstream_api_keys
SET max_cost = ?, updated_at = datetime('now')
WHERE id = ?",
)
.bind(next_max)
.bind(key_id)
.execute(&mut *tx)
.await?;
crate::services::billing::ledger::record_ledger_entry(
&mut tx,
crate::services::billing::ledger::LedgerEntry {
user_id: id,
track: "usd",
entry_type: "admin_adjust",
amount: delta,
balance_after: remaining,
ref_type: Some("manual"),
ref_id: None,
model: None,
operator: Some("admin"),
note: None,
},
)
.await?;
tx.commit().await?;
info!(
user_id = id,
key_id, delta, next_max, "admin adjusted user balance"
);
Ok(Json(json!({
"success": true,
"data": {
"userId": id,
"keyId": key_id,
"maxCost": next_max,
"usedCost": used,
"remainingCost": remaining,
}
})))
}
/// POST /api/admin/users/{id}/credits/adjust — add or remove workflow points
/// on the user's points pool (`user_credits`), writing a points/admin_adjust
/// ledger entry. Mirrors `adjust_user_balance` but for the points track.
pub async fn adjust_user_credits(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(payload): Json<AdjustBalanceRequest>,
) -> AppResult<Json<Value>> {
let delta = payload.delta;
if !delta.is_finite() || delta == 0.0 {
return Err(AppError::BadRequest("delta must be a non-zero finite number".into()));
}
crate::services::billing::ledger::ensure_ledger_schema(&state.db).await?;
let new_bal = crate::services::billing::ledger::apply_points_delta(
&state.db,
id,
delta,
"admin_adjust",
None,
Some("admin"),
Some("manual"),
None,
)
.await?;
info!(user_id = id, delta, new_bal, "admin adjusted user credits");
Ok(Json(json!({
"success": true,
"data": {
"userId": id,
"pointsBalance": new_bal,
}
})))
}
#[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(),
pixelle_video: Default::default(),
payment: 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");
}
#[tokio::test]
async fn adjust_user_balance_writes_admin_adjust_ledger() {
let state = test_state().await;
crate::services::billing::ledger::ensure_ledger_schema(&state.db)
.await
.expect("ledger schema");
let user_id = sqlx::query(
"INSERT INTO users (username, email, password_hash, role, status)
VALUES (?, ?, ?, 'user', 'active')",
)
.bind("Spender")
.bind("spender@example.test")
.bind("hash")
.execute(&*state.db)
.await
.expect("insert user")
.last_insert_rowid();
sqlx::query(
"INSERT INTO downstream_api_keys (name, key, user_id, max_cost, used_cost)
VALUES (?, ?, ?, 10.0, 0.0)",
)
.bind("primary")
.bind("sk-spender")
.bind(user_id)
.execute(&*state.db)
.await
.expect("insert key");
adjust_user_balance(
State(state.clone()),
Path(user_id),
Json(AdjustBalanceRequest { delta: 5.0 }),
)
.await
.expect("adjust balance");
let row: (String, String, f64) = sqlx::query_as(
"SELECT track, entry_type, amount FROM credit_ledger
WHERE user_id = ? ORDER BY id DESC LIMIT 1",
)
.bind(user_id)
.fetch_one(&*state.db)
.await
.expect("ledger row");
assert_eq!(row, ("usd".into(), "admin_adjust".into(), 5.0));
}
#[tokio::test]
async fn adjust_user_credits_writes_points_admin_adjust_ledger() {
let state = test_state().await;
crate::services::billing::ledger::ensure_ledger_schema(&state.db)
.await
.expect("ledger schema");
let user_id = sqlx::query(
"INSERT INTO users (username, email, password_hash, role, status)
VALUES (?, ?, ?, 'user', 'active')",
)
.bind("Pointer")
.bind("pointer@example.test")
.bind("hash")
.execute(&*state.db)
.await
.expect("insert user")
.last_insert_rowid();
let response = adjust_user_credits(
State(state.clone()),
Path(user_id),
Json(AdjustBalanceRequest { delta: 50.0 }),
)
.await
.expect("adjust credits");
assert_eq!(response.0["success"], true);
assert_eq!(response.0["data"]["pointsBalance"], 50.0);
let row: (String, String, f64) = sqlx::query_as(
"SELECT track, entry_type, amount FROM credit_ledger
WHERE user_id = ? ORDER BY id DESC LIMIT 1",
)
.bind(user_id)
.fetch_one(&*state.db)
.await
.expect("ledger row");
assert_eq!(row, ("points".into(), "admin_adjust".into(), 50.0));
}
}
+867 -13
View File
@@ -10,14 +10,14 @@ use crate::{
}; };
use axum::{ use axum::{
extract::{ConnectInfo, Path, State}, extract::{ConnectInfo, Path, State},
http::HeaderMap, http::{header, HeaderMap},
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::net::SocketAddr; use std::net::{IpAddr, SocketAddr};
use tracing::info; use tracing::{info, warn};
// ── Request / Response types ───────────────────────────────────────── // ── Request / Response types ─────────────────────────────────────────
@@ -70,7 +70,6 @@ pub fn routes() -> Router<AppState> {
.route("/api/users/register", post(register)) .route("/api/users/register", post(register))
.route("/api/auth/login", post(login)) .route("/api/auth/login", post(login))
.route("/api/users/login", post(login)) .route("/api/users/login", post(login))
.route("/api/auth/login-api-key", post(login_api_key))
.route("/api/users/login-api-key", post(login_api_key)) .route("/api/users/login-api-key", post(login_api_key))
.route("/api/auth/me", get(me)) .route("/api/auth/me", get(me))
.route("/api/users/me", get(me).patch(update_me)) .route("/api/users/me", get(me).patch(update_me))
@@ -229,24 +228,82 @@ async fn register(
/// POST /api/auth/login /// POST /api/auth/login
async fn login( async fn login(
State(state): State<AppState>, State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(payload): Json<LoginRequest>, Json(payload): Json<LoginRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
info!("Login: {}", payload.email); info!("Login: {}", payload.email);
let client_ip = client_ip_from_headers(&headers, addr);
let user_agent = user_agent_from_headers(&headers);
let email = payload.email.trim().to_string();
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?") let user: User = if let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
.bind(&payload.email) .bind(&email)
.fetch_optional(&*state.db) .fetch_optional(&*state.db)
.await? .await?
.ok_or_else(|| AppError::Unauthorized("Invalid email or password".into()))?; {
user
} else {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::Password,
success: false,
user: None,
identifier: Some(&email),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Invalid email or password"),
})
.await;
return Err(AppError::Unauthorized("Invalid email or password".into()));
};
if user.status != "active" { if user.status != "active" {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::Password,
success: false,
user: Some(&user),
identifier: Some(&email),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Account is disabled"),
})
.await;
return Err(AppError::Forbidden("Account is disabled".into())); return Err(AppError::Forbidden("Account is disabled".into()));
} }
if user.role == "admin" {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::Password,
success: false,
user: Some(&user),
identifier: Some(&email),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Admin users must sign in with the admin API token"),
})
.await;
return Err(AppError::Forbidden(
"Admin users must sign in with the admin API token".into(),
));
}
let valid = verify_password(&payload.password, &user.password_hash) let valid = verify_password(&payload.password, &user.password_hash)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?; .map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?;
if !valid { if !valid {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::Password,
success: false,
user: Some(&user),
identifier: Some(&email),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Invalid email or password"),
})
.await;
return Err(AppError::Unauthorized("Invalid email or password".into())); return Err(AppError::Unauthorized("Invalid email or password".into()));
} }
@@ -258,6 +315,18 @@ async fn login(
) )
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?; .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::Password,
success: true,
user: Some(&user),
identifier: Some(&email),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: None,
})
.await;
Ok(Json(json!({ Ok(Json(json!({
"token": token, "token": token,
"user": { "user": {
@@ -288,10 +357,25 @@ async fn login(
/// be enabled, and the owning user must be active. /// be enabled, and the owning user must be active.
async fn login_api_key( async fn login_api_key(
State(state): State<AppState>, State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(payload): Json<LoginApiKeyRequest>, Json(payload): Json<LoginApiKeyRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let client_ip = client_ip_from_headers(&headers, addr);
let user_agent = user_agent_from_headers(&headers);
let key = payload.api_key.trim(); let key = payload.api_key.trim();
if key.is_empty() { if key.is_empty() {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: None,
identifier: None,
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("api_key is required"),
})
.await;
return Err(AppError::BadRequest("api_key is required".into())); return Err(AppError::BadRequest("api_key is required".into()));
} }
@@ -302,20 +386,91 @@ async fn login_api_key(
.fetch_optional(&*state.db) .fetch_optional(&*state.db)
.await?; .await?;
let api_key = row.ok_or_else(|| AppError::Unauthorized("Invalid API key".into()))?; let api_key = if let Some(api_key) = row {
let user_id = api_key api_key
.user_id } else {
.ok_or_else(|| AppError::Unauthorized("API key is not bound to a user".into()))?; let masked_key = mask_api_key(key);
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: None,
identifier: Some(masked_key.as_str()),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Invalid API key"),
})
.await;
return Err(AppError::Unauthorized("Invalid API key".into()));
};
let user_id = if let Some(user_id) = api_key.user_id {
user_id
} else {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: None,
identifier: Some(&api_key.name),
api_key: Some(&api_key),
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("API key is not bound to a user"),
})
.await;
return Err(AppError::Unauthorized("API key is not bound to a user".into()));
};
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?") let user: User = if let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(user_id) .bind(user_id)
.fetch_optional(&*state.db) .fetch_optional(&*state.db)
.await? .await?
.ok_or_else(|| AppError::Unauthorized("User not found".into()))?; {
user
} else {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: None,
identifier: Some(&api_key.name),
api_key: Some(&api_key),
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("User not found"),
})
.await;
return Err(AppError::Unauthorized("User not found".into()));
};
if user.status != "active" { if user.status != "active" {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: Some(&user),
identifier: Some(&user.email),
api_key: Some(&api_key),
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Account is disabled"),
})
.await;
return Err(AppError::Forbidden("Account is disabled".into())); return Err(AppError::Forbidden("Account is disabled".into()));
} }
if user.role == "admin" {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: Some(&user),
identifier: Some(&user.email),
api_key: Some(&api_key),
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Admin users must sign in with the admin API token"),
})
.await;
return Err(AppError::Forbidden(
"Admin users must sign in with the admin API token".into(),
));
}
info!("Login via API key: user_id={}", user.id); info!("Login via API key: user_id={}", user.id);
@@ -327,6 +482,18 @@ async fn login_api_key(
) )
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?; .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::ApiKey,
success: true,
user: Some(&user),
identifier: Some(&user.email),
api_key: Some(&api_key),
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: None,
})
.await;
Ok(Json(json!({ Ok(Json(json!({
"token": token, "token": token,
"user": { "user": {
@@ -503,6 +670,225 @@ async fn get_user(
}))) })))
} }
// ── User login audit helpers ──────────────────────────────────────────
#[derive(Debug, Clone, Copy)]
enum LoginMethod {
Password,
ApiKey,
}
impl LoginMethod {
fn label(self) -> &'static str {
match self {
LoginMethod::Password => "password",
LoginMethod::ApiKey => "api_key",
}
}
fn title_label(self) -> &'static str {
match self {
LoginMethod::Password => "密码登录",
LoginMethod::ApiKey => "API Key 登录",
}
}
}
struct LoginAuditEvent<'a> {
method: LoginMethod,
success: bool,
user: Option<&'a User>,
identifier: Option<&'a str>,
api_key: Option<&'a DownstreamApiKey>,
client_ip: &'a str,
user_agent: Option<&'a str>,
reason: Option<&'a str>,
}
async fn audit_user_login(state: &AppState, audit: LoginAuditEvent<'_>) {
let event_type = "user_login";
let level = if audit.success { "info" } else { "warning" };
let account_label = display_user_for_audit(audit.user, audit.identifier);
let title = if audit.success {
format!("用户登录成功:{account_label}")
} else {
format!("用户登录失败:{account_label}")
};
let mut message_parts = Vec::new();
if let Some(user) = audit.user {
message_parts.push(format!("账号={}", user_account_label_for_audit(user)));
if !user.email.trim().is_empty() {
message_parts.push(format!("邮箱={}", user.email.trim()));
}
message_parts.push(format!("用户ID={}", user.id));
} else if let Some(identifier) = audit.identifier {
message_parts.push(format!("标识={}", identifier));
}
message_parts.push(format!("方式={}", audit.method.label()));
message_parts.push(format!("IP={}", audit.client_ip));
if let Some(api_key) = audit.api_key {
message_parts.push(format!("密钥={}", api_key.name));
message_parts.push(format!("密钥ID={}", api_key.id));
}
if let Some(reason) = audit.reason.filter(|value| !value.trim().is_empty()) {
message_parts.push(format!("原因={reason}"));
}
if let Some(user_agent) = audit.user_agent.filter(|value| !value.trim().is_empty()) {
message_parts.push(format!("UA={}", truncate_for_audit(user_agent, 180)));
}
if let Err(err) = sqlx::query(
"INSERT INTO events (type, title, message, level, read, related_id, related_type)
VALUES (?, ?, ?, ?, 0, ?, ?)",
)
.bind(event_type)
.bind(title)
.bind(message_parts.join(" · "))
.bind(level)
.bind(audit.user.map(|user| user.id))
.bind(if audit.user.is_some() { Some("user") } else { None::<&str> })
.execute(&*state.db)
.await
{
warn!(
%err,
method = audit.method.label(),
success = audit.success,
client_ip = audit.client_ip,
"failed to write user login audit event",
);
} else {
info!(
method = audit.method.label(),
method_title = audit.method.title_label(),
success = audit.success,
client_ip = audit.client_ip,
"user login audit recorded",
);
}
}
fn display_user_for_audit(user: Option<&User>, identifier: Option<&str>) -> String {
if let Some(user) = user {
return user_account_label_for_audit(user);
}
identifier
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("未知用户")
.to_string()
}
fn user_account_label_for_audit(user: &User) -> String {
let username = user.username.trim();
let email = user.email.trim();
match (username.is_empty(), email.is_empty(), username == email) {
(false, false, false) => format!("{username} <{email}>"),
(false, _, _) => username.to_string(),
(_, false, _) => email.to_string(),
_ => format!("用户 #{}", user.id),
}
}
fn user_agent_from_headers(headers: &HeaderMap) -> Option<String> {
headers
.get(header::USER_AGENT)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| truncate_for_audit(value, 240))
}
fn client_ip_from_headers(headers: &HeaderMap, fallback: SocketAddr) -> String {
let mut candidates = Vec::new();
for name in [
"cf-connecting-ip",
"x-real-ip",
"x-forwarded-for",
"forwarded",
] {
candidates.extend(header_ip_values(headers, name));
}
if let Some(ipv4) = candidates.iter().find(|value| is_ipv4_address(value)) {
return ipv4.clone();
}
if let Some(first) = candidates.into_iter().next() {
return first;
}
fallback.ip().to_string()
}
fn is_ipv4_address(value: &str) -> bool {
matches!(value.parse::<IpAddr>(), Ok(IpAddr::V4(_)))
}
fn header_ip_values(headers: &HeaderMap, name: &str) -> Vec<String> {
let Some(raw) = headers.get(name).and_then(|value| value.to_str().ok()) else {
return Vec::new();
};
let raw = raw.trim();
if raw.is_empty() {
return Vec::new();
}
if name.eq_ignore_ascii_case("forwarded") {
return forwarded_header_ips(raw);
}
raw.split(',').filter_map(normalize_ip_token).collect()
}
fn forwarded_header_ips(raw: &str) -> Vec<String> {
raw.split(',')
.flat_map(|entry| entry.split(';'))
.filter_map(|part| {
let (key, value) = part.split_once('=')?;
if !key.trim().eq_ignore_ascii_case("for") {
return None;
}
normalize_ip_token(value)
})
.collect()
}
fn normalize_ip_token(raw: &str) -> Option<String> {
let mut token = raw.trim().trim_matches('"').trim_matches('\'').trim();
if token.is_empty() || token.eq_ignore_ascii_case("unknown") {
return None;
}
if token.starts_with('[') {
if let Some(end) = token.find(']') {
token = &token[1..end];
}
} else if let Some((host, port)) = token.rsplit_once(':') {
if host.matches(':').count() == 0 && port.parse::<u16>().is_ok() {
token = host;
}
}
token
.parse::<IpAddr>()
.map(|ip| ip.to_string())
.ok()
}
fn truncate_for_audit(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
return value.to_string();
}
let mut truncated = value.chars().take(max_chars.saturating_sub(1)).collect::<String>();
truncated.push('…');
truncated
}
fn mask_api_key(key: &str) -> String {
let trimmed = key.trim();
let tail = trimmed.chars().rev().take(4).collect::<Vec<_>>();
if tail.is_empty() {
return "sk-****".to_string();
}
let suffix = tail.into_iter().rev().collect::<String>();
format!("••••{suffix}")
}
// ── Inline JWT extract helper for this module ────────────────────────── // ── Inline JWT extract helper for this module ──────────────────────────
fn extract_jwt(state: &AppState, headers: &HeaderMap) -> AppResult<(i64, String)> { fn extract_jwt(state: &AppState, headers: &HeaderMap) -> AppResult<(i64, String)> {
@@ -522,3 +908,471 @@ fn extract_jwt(state: &AppState, headers: &HeaderMap) -> AppResult<(i64, String)
Ok((uid, claims.role)) Ok((uid, claims.role))
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::{
auth::{jwt::validate_jwt, password::hash_password},
config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig},
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
};
use sqlx::sqlite::SqlitePoolOptions;
async fn test_state() -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("in-memory database");
sqlx::query(
"CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
username TEXT NOT NULL,
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(&pool)
.await
.expect("users table");
sqlx::query(
"CREATE TABLE downstream_api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
key TEXT NOT NULL,
description TEXT,
group_name TEXT,
tags TEXT,
enabled INTEGER DEFAULT 1,
expires_at TEXT,
max_cost REAL,
used_cost REAL DEFAULT 0,
max_requests INTEGER,
used_requests INTEGER DEFAULT 0,
supported_models TEXT,
allowed_route_ids TEXT,
site_weight_multipliers TEXT,
excluded_site_ids TEXT,
excluded_credential_refs TEXT,
last_used_at TEXT,
user_id INTEGER,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(&pool)
.await
.expect("downstream keys table");
sqlx::query(
"CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT,
level TEXT NOT NULL DEFAULT 'info',
read INTEGER DEFAULT 0,
related_id INTEGER,
related_type TEXT,
created_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(&pool)
.await
.expect("events table");
AppState::new(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".into(),
port: 0,
log_level: "info".into(),
cors_origins: vec![],
},
db: DbConfig {
url: "sqlite::memory:".into(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "test-secret".into(),
jwt_expiry_hours: 1,
auth_token: "auth".into(),
proxy_token: "proxy".into(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 3,
proxy_log_retention_days: 7,
template_proxy_url: None,
},
comfyui: Default::default(),
payment: Default::default(),
},
pool,
BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-test".into()),
},
RuntimeAuthSettings::from_token_for_test("proxy"),
)
}
fn test_addr() -> ConnectInfo<SocketAddr> {
ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345)))
}
fn empty_headers() -> HeaderMap {
HeaderMap::new()
}
fn headers_with(name: &'static str, value: &'static str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(name, value.parse().expect("valid test header"));
headers
}
async fn latest_audit_event(state: &AppState) -> (String, String, String, i64, String) {
sqlx::query_as::<_, (String, String, String, i64, String)>(
"SELECT type, title, COALESCE(message, ''), COALESCE(related_id, 0), COALESCE(related_type, '')
FROM events
ORDER BY id DESC
LIMIT 1",
)
.fetch_one(&*state.db)
.await
.expect("latest audit event")
}
#[tokio::test]
async fn password_login_rejects_admin_users() {
let state = test_state().await;
let password_hash = hash_password("admin-password").expect("password hash");
sqlx::query(
"INSERT INTO users (username, email, password_hash, role, status)
VALUES (?, ?, ?, 'admin', 'active')",
)
.bind("Admin")
.bind("admin@example.test")
.bind(password_hash)
.execute(&*state.db)
.await
.expect("insert admin user");
let err = login(
State(state),
test_addr(),
empty_headers(),
Json(LoginRequest {
email: "admin@example.test".into(),
password: "admin-password".into(),
}),
)
.await
.expect_err("admin password login must be rejected");
assert!(matches!(err, AppError::Forbidden(message) if message.contains("admin API token")));
}
#[tokio::test]
async fn password_login_allows_regular_users() {
let state = test_state().await;
let password_hash = hash_password("user-password").expect("password hash");
let user_id = sqlx::query(
"INSERT INTO users (username, email, password_hash, role, status)
VALUES (?, ?, ?, 'user', 'active')",
)
.bind("Regular User")
.bind("user@example.test")
.bind(password_hash)
.execute(&*state.db)
.await
.expect("insert user")
.last_insert_rowid();
let Json(value) = login(
State(state.clone()),
test_addr(),
empty_headers(),
Json(LoginRequest {
email: "user@example.test".into(),
password: "user-password".into(),
}),
)
.await
.expect("regular user password login succeeds");
assert_eq!(value["user"]["role"], "user");
let token = value["token"].as_str().expect("token");
let claims = validate_jwt(&state.config.auth.jwt_secret, token).expect("valid jwt");
assert_eq!(claims.sub, user_id.to_string());
assert_eq!(claims.role, "user");
}
#[tokio::test]
async fn password_login_audits_forwarded_client_ip() {
let state = test_state().await;
let password_hash = hash_password("user-password").expect("password hash");
let user_id = sqlx::query(
"INSERT INTO users (username, email, password_hash, role, status)
VALUES (?, ?, ?, 'user', 'active')",
)
.bind("Regular User")
.bind("audit@example.test")
.bind(password_hash)
.execute(&*state.db)
.await
.expect("insert user")
.last_insert_rowid();
let Json(_) = login(
State(state.clone()),
test_addr(),
headers_with("x-forwarded-for", "203.0.113.8, 10.0.0.1"),
Json(LoginRequest {
email: "audit@example.test".into(),
password: "user-password".into(),
}),
)
.await
.expect("regular user password login succeeds");
let (event_type, title, message, related_id, related_type) = latest_audit_event(&state).await;
assert_eq!(event_type, "user_login");
assert!(title.contains("用户登录成功"));
assert!(title.contains("Regular User <audit@example.test>"));
assert!(message.starts_with("账号=Regular User <audit@example.test>"));
assert!(message.contains("邮箱=audit@example.test"));
assert!(message.contains("方式=password"));
assert!(message.contains("IP=203.0.113.8"));
assert_eq!(related_id, user_id);
assert_eq!(related_type, "user");
}
#[tokio::test]
async fn api_key_login_rejects_admin_role() {
let state = test_state().await;
let password_hash = hash_password("unused-password").expect("password hash");
let user_id = sqlx::query(
"INSERT INTO users (username, email, password_hash, role, status)
VALUES (?, ?, ?, 'admin', 'active')",
)
.bind("Admin")
.bind("admin@example.test")
.bind(password_hash)
.execute(&*state.db)
.await
.expect("insert admin user")
.last_insert_rowid();
sqlx::query(
"INSERT INTO downstream_api_keys (name, key, user_id, enabled)
VALUES (?, ?, ?, 1)",
)
.bind("Admin default")
.bind("sk-admin-bound")
.bind(user_id)
.execute(&*state.db)
.await
.expect("insert bound api key");
let err = login_api_key(
State(state),
test_addr(),
empty_headers(),
Json(LoginApiKeyRequest {
api_key: "sk-admin-bound".into(),
}),
)
.await
.expect_err("admin api key login must be rejected");
assert!(matches!(err, AppError::Forbidden(message) if message.contains("admin API token")));
assert!(user_id > 0);
}
#[tokio::test]
async fn api_key_login_rejects_latest_admin_role_after_promotion() {
let state = test_state().await;
let password_hash = hash_password("unused-password").expect("password hash");
let user_id = sqlx::query(
"INSERT INTO users (username, email, password_hash, role, status)
VALUES (?, ?, ?, 'user', 'active')",
)
.bind("Promoted User")
.bind("promoted@example.test")
.bind(password_hash)
.execute(&*state.db)
.await
.expect("insert regular user")
.last_insert_rowid();
sqlx::query(
"INSERT INTO downstream_api_keys (name, key, user_id, enabled)
VALUES (?, ?, ?, 1)",
)
.bind("Existing user key")
.bind("sk-promoted-bound")
.bind(user_id)
.execute(&*state.db)
.await
.expect("insert bound api key");
sqlx::query("UPDATE users SET role = 'admin' WHERE id = ?")
.bind(user_id)
.execute(&*state.db)
.await
.expect("promote user to admin");
let err = login_api_key(
State(state),
test_addr(),
empty_headers(),
Json(LoginApiKeyRequest {
api_key: "sk-promoted-bound".into(),
}),
)
.await
.expect_err("api key login must use latest role and reject admin");
assert!(matches!(err, AppError::Forbidden(message) if message.contains("admin API token")));
assert!(user_id > 0);
}
#[tokio::test]
async fn api_key_login_allows_regular_user_role() {
let state = test_state().await;
let password_hash = hash_password("unused-password").expect("password hash");
let user_id = sqlx::query(
"INSERT INTO users (username, email, password_hash, role, status)
VALUES (?, ?, ?, 'user', 'active')",
)
.bind("Regular User")
.bind("regular@example.test")
.bind(password_hash)
.execute(&*state.db)
.await
.expect("insert regular user")
.last_insert_rowid();
sqlx::query(
"INSERT INTO downstream_api_keys (name, key, user_id, enabled)
VALUES (?, ?, ?, 1)",
)
.bind("Regular default")
.bind("sk-user-bound")
.bind(user_id)
.execute(&*state.db)
.await
.expect("insert bound api key");
let Json(value) = login_api_key(
State(state.clone()),
test_addr(),
empty_headers(),
Json(LoginApiKeyRequest {
api_key: "sk-user-bound".into(),
}),
)
.await
.expect("regular api key login succeeds");
assert_eq!(value["user"]["role"], "user");
assert_eq!(value["data"]["user"]["role"], "user");
let token = value["token"].as_str().expect("token");
let claims = validate_jwt(&state.config.auth.jwt_secret, token).expect("valid jwt");
assert_eq!(claims.sub, user_id.to_string());
assert_eq!(claims.role, "user");
}
#[tokio::test]
async fn api_key_login_audits_forwarded_header_ip_and_key_name() {
let state = test_state().await;
let password_hash = hash_password("unused-password").expect("password hash");
let user_id = sqlx::query(
"INSERT INTO users (username, email, password_hash, role, status)
VALUES (?, ?, ?, 'user', 'active')",
)
.bind("Regular User")
.bind("key-audit@example.test")
.bind(password_hash)
.execute(&*state.db)
.await
.expect("insert regular user")
.last_insert_rowid();
sqlx::query(
"INSERT INTO downstream_api_keys (name, key, user_id, enabled)
VALUES (?, ?, ?, 1)",
)
.bind("Mobile Login Key")
.bind("sk-user-audit-bound")
.bind(user_id)
.execute(&*state.db)
.await
.expect("insert bound api key");
let Json(_) = login_api_key(
State(state.clone()),
test_addr(),
headers_with("forwarded", "for=\"198.51.100.24:443\";proto=https"),
Json(LoginApiKeyRequest {
api_key: "sk-user-audit-bound".into(),
}),
)
.await
.expect("regular api key login succeeds");
let (event_type, title, message, related_id, related_type) = latest_audit_event(&state).await;
assert_eq!(event_type, "user_login");
assert!(title.contains("用户登录成功"));
assert!(title.contains("Regular User <key-audit@example.test>"));
assert!(message.starts_with("账号=Regular User <key-audit@example.test>"));
assert!(message.contains("方式=api_key"));
assert!(message.contains("IP=198.51.100.24"));
assert!(message.contains("密钥=Mobile Login Key"));
assert_eq!(related_id, user_id);
assert_eq!(related_type, "user");
}
#[tokio::test]
async fn login_audit_prefers_ipv4_from_proxy_headers() {
let state = test_state().await;
let password_hash = hash_password("user-password").expect("password hash");
sqlx::query(
"INSERT INTO users (username, email, password_hash, role, status)
VALUES (?, ?, ?, 'user', 'active')",
)
.bind("IPv4 User")
.bind("ipv4@example.test")
.bind(password_hash)
.execute(&*state.db)
.await
.expect("insert user");
let Json(_) = login(
State(state.clone()),
test_addr(),
headers_with("x-forwarded-for", "2a09:bac2:3918:16dc::247:4d, 203.0.113.9"),
Json(LoginRequest {
email: "ipv4@example.test".into(),
password: "user-password".into(),
}),
)
.await
.expect("regular user password login succeeds");
let (_, _, message, _, _) = latest_audit_event(&state).await;
assert!(message.contains("IP=203.0.113.9"), "{message}");
}
}
+111 -1
View File
@@ -6,6 +6,7 @@
use crate::{ use crate::{
auth::api_key::{authorize_downstream_token, DownstreamAuthResult}, auth::api_key::{authorize_downstream_token, DownstreamAuthResult},
auth::jwt::validate_jwt,
error::{AppError, AppResult}, error::{AppError, AppResult},
AppState, AppState,
}; };
@@ -47,10 +48,20 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
return Ok(()); return Ok(());
} }
if let Ok(claims) = validate_jwt(&state.config.auth.jwt_secret, auth) {
if claims.role == "admin" {
return Ok(());
}
return Err(AppError::Forbidden(
"Admin account is required for this route".into(),
));
}
// Downstream API key // Downstream API key
if auth.starts_with("sk-") { if auth.starts_with("sk-") {
let proxy_token = state.runtime_proxy_token();
let result = let result =
authorize_downstream_token(auth, &state.db, Some(&state.config.auth.proxy_token)) authorize_downstream_token(auth, &state.db, Some(&proxy_token))
.await .await
.map_err(AppError::Unauthorized)?; .map_err(AppError::Unauthorized)?;
@@ -68,6 +79,105 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::{
auth::jwt::generate_jwt,
config::{AppConfig, AuthConfig, ComfyUIConfig, DbConfig, ProxyConfig, ServerConfig},
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
};
use axum::http::HeaderValue;
use sqlx::sqlite::SqlitePoolOptions;
async fn test_state() -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool");
AppState::new(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 4000,
log_level: "info".to_string(),
cors_origins: Vec::new(),
},
db: DbConfig {
url: "sqlite::memory:".to_string(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "test-jwt-secret".to_string(),
jwt_expiry_hours: 24,
auth_token: "test-admin-token".to_string(),
proxy_token: "sk-test-proxy".to_string(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 1,
proxy_log_retention_days: 7,
template_proxy_url: None,
},
comfyui: ComfyUIConfig::default(),
},
pool,
BuiltinKeys {
comfyui_agent: (0, "sk-comfyui-test".to_string()),
},
RuntimeAuthSettings::from_token_for_test("sk-test-proxy"),
)
}
fn bearer_headers(token: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
"Authorization",
HeaderValue::from_str(&format!("Bearer {token}")).expect("valid bearer header"),
);
headers
}
#[tokio::test]
async fn comfyui_auth_accepts_admin_jwt() {
let state = test_state().await;
let token = generate_jwt(
&state.config.auth.jwt_secret,
"42",
"admin",
state.config.auth.jwt_expiry_hours,
)
.expect("admin jwt");
check_auth(&state, &bearer_headers(&token))
.await
.expect("admin jwt can access comfyui proxy routes");
}
#[tokio::test]
async fn comfyui_auth_rejects_user_jwt() {
let state = test_state().await;
let token = generate_jwt(
&state.config.auth.jwt_secret,
"43",
"user",
state.config.auth.jwt_expiry_hours,
)
.expect("user jwt");
let result = check_auth(&state, &bearer_headers(&token)).await;
assert!(matches!(result, Err(AppError::Forbidden(_))));
}
}
/// Get ComfyUI base URL from embedded/external runtime configuration. /// Get ComfyUI base URL from embedded/external runtime configuration.
fn comfyui_base(state: &AppState) -> String { fn comfyui_base(state: &AppState) -> String {
state.comfyui.internal_base_url() state.comfyui.internal_base_url()
+131 -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 { return Ok(AgentAuthContext {
user_id: None, user_id: None,
role: "admin".to_string(), role: "admin".to_string(),
outbound_key: state.config.auth.proxy_token.clone(), outbound_key: proxy_token,
key_name: "global-proxy".to_string(), key_name: "global-proxy".to_string(),
}); });
} }
@@ -282,7 +283,7 @@ async fn resolve_agent_auth(
Err(_) if claims.role == "admin" => Ok(AgentAuthContext { Err(_) if claims.role == "admin" => Ok(AgentAuthContext {
user_id: Some(user_id), user_id: Some(user_id),
role: claims.role, role: claims.role,
outbound_key: state.config.auth.proxy_token.clone(), outbound_key: state.runtime_proxy_token(),
key_name: "global-proxy".to_string(), key_name: "global-proxy".to_string(),
}), }),
Err(message) => Err(( Err(message) => Err((
@@ -368,6 +369,8 @@ async fn status_handler(
"keyName": key_name, "keyName": key_name,
"userId": auth.user_id, "userId": auth.user_id,
"role": auth.role, "role": auth.role,
"apiBase": upstream_base,
"upstreamBase": upstream_base,
"upstreamLabel": upstream_label, "upstreamLabel": upstream_label,
"comfyui": state.comfyui.status_response().await, "comfyui": state.comfyui.status_response().await,
}))) })))
@@ -680,6 +683,77 @@ fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) ->
.is_ok() .is_ok()
} }
fn string_field(value: &Value, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| value.get(*key).and_then(Value::as_str))
.map(ToOwned::to_owned)
.filter(|text| !text.is_empty())
}
fn extract_reasoning_delta(parsed: &Value) -> Option<String> {
if let Some(delta) = parsed
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(|choice| choice.get("delta"))
{
if let Some(text) = string_field(
delta,
&[
"reasoning_content",
"reasoning",
"thinking",
"thinking_delta",
"reasoning_delta",
],
) {
return Some(text);
}
if let Some(text) = delta
.get("reasoning")
.and_then(|value| value.get("content").or_else(|| value.get("text")))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.filter(|text| !text.is_empty())
{
return Some(text);
}
}
let event_type = parsed
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
match event_type {
"response.reasoning.delta"
| "response.reasoning_text.delta"
| "response.reasoning_summary_text.delta" => {
string_field(parsed, &["delta", "text", "content"])
}
"content_block_delta" => {
let delta = parsed.get("delta")?;
match delta
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"thinking_delta" => string_field(delta, &["thinking", "text", "delta"]),
"signature_delta" => None,
other if other.contains("thinking") || other.contains("reasoning") => {
string_field(delta, &["thinking", "text", "content", "delta"])
}
_ => None,
}
}
other if other.contains("reasoning") || other.contains("thinking") => string_field(
parsed,
&["delta", "text", "content", "thinking", "reasoning"],
),
_ => None,
}
}
fn attachment_summary(attachment: &AgentAttachment) -> String { fn attachment_summary(attachment: &AgentAttachment) -> String {
let mime = attachment let mime = attachment
.mime_type .mime_type
@@ -936,22 +1010,7 @@ async fn chat_handler(
continue; continue;
}; };
let delta = parsed if let Some(reasoning) = extract_reasoning_delta(&parsed) {
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(|choice| choice.get("delta"))
.cloned()
.unwrap_or(Value::Null);
let reasoning = delta
.get("reasoning_content")
.or_else(|| delta.get("reasoning"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if !reasoning.is_empty() {
if !send_sse( if !send_sse(
&tx, &tx,
json!({ json!({
@@ -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 let content = delta
.get("content") .get("content")
.and_then(Value::as_str) .and_then(Value::as_str)
@@ -1235,6 +1302,7 @@ mod tests {
first_byte_timeout_secs: 30, first_byte_timeout_secs: 30,
max_retries: 3, max_retries: 3,
proxy_log_retention_days: 7, proxy_log_retention_days: 7,
template_proxy_url: None,
}, },
comfyui: Default::default(), comfyui: Default::default(),
}, },
@@ -1242,6 +1310,7 @@ mod tests {
BuiltinKeys { BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-test".into()), comfyui_agent: (1, "sk-comfyui-test".into()),
}, },
crate::services::runtime_auth::RuntimeAuthSettings::from_token_for_test("proxy"),
) )
} }
@@ -1261,6 +1330,49 @@ mod tests {
result.expect("status_handler returned error").0 result.expect("status_handler returned error").0
} }
#[test]
fn extracts_chat_reasoning_delta() {
let parsed = json!({
"choices": [{
"delta": {
"reasoning_content": "正在拆解镜头..."
}
}]
});
assert_eq!(
extract_reasoning_delta(&parsed).as_deref(),
Some("正在拆解镜头...")
);
}
#[test]
fn extracts_responses_reasoning_delta() {
let parsed = json!({
"type": "response.reasoning_summary_text.delta",
"delta": "先分析角色和场景。"
});
assert_eq!(
extract_reasoning_delta(&parsed).as_deref(),
Some("先分析角色和场景。")
);
}
#[test]
fn extracts_anthropic_thinking_delta() {
let parsed = json!({
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "thinking_delta",
"thinking": "用户需要一个视频分镜。"
}
});
assert_eq!(
extract_reasoning_delta(&parsed).as_deref(),
Some("用户需要一个视频分镜。")
);
}
/// 3.5 — `/api/comfyui-agent/status` reflects the env state in one shot: /// 3.5 — `/api/comfyui-agent/status` reflects the env state in one shot:
/// no env → local; whitespace/empty env → still local (treated as unset); /// no env → local; whitespace/empty env → still local (treated as unset);
/// real env → fallback_remote with trimmed `upstreamBase`. /// real env → fallback_remote with trimmed `upstreamBase`.
File diff suppressed because it is too large Load Diff
+671 -13
View File
@@ -1,14 +1,23 @@
// Events routes — system event notifications // Events routes — system event notifications
use crate::{db::models::event::Event, error::AppResult, AppState}; use crate::{
db::models::event::Event,
error::{AppError, AppResult},
services::runtime_logs,
AppState,
};
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
http::HeaderMap,
middleware,
response::Response,
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use tracing::info; use tracing::{debug, info};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ListEventsQuery { pub struct ListEventsQuery {
@@ -19,12 +28,35 @@ pub struct ListEventsQuery {
read: Option<String>, // "true" | "false" read: Option<String>, // "true" | "false"
} }
pub fn routes() -> Router<AppState> { pub fn routes(state: AppState) -> Router<AppState> {
Router::new() Router::new()
.route("/api/events", get(list_events).delete(clear_events)) .route("/api/events", get(list_events).delete(clear_events))
.route("/api/events/count", get(unread_count)) .route("/api/events/count", get(unread_count))
.route("/api/events/{id}/read", post(mark_read)) .route("/api/events/{id}/read", post(mark_read))
.route("/api/events/read-all", post(mark_all_read)) .route("/api/events/read-all", post(mark_all_read))
.route_layer(middleware::from_fn_with_state(
state,
require_events_admin_auth,
))
}
async fn require_events_admin_auth(
State(state): State<AppState>,
headers: HeaderMap,
request: axum::http::Request<axum::body::Body>,
next: axum::middleware::Next,
) -> Result<Response, AppError> {
let auth_header = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
if auth_header != state.config.auth.auth_token {
return Err(AppError::Unauthorized("Invalid admin token".into()));
}
Ok(next.run(request).await)
} }
/// GET /api/events /// GET /api/events
@@ -32,20 +64,29 @@ async fn list_events(
State(state): State<AppState>, State(state): State<AppState>,
Query(q): Query<ListEventsQuery>, Query(q): Query<ListEventsQuery>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
info!("Listing events"); debug!("Listing events");
let limit = q.limit.unwrap_or(30).clamp(1, 500); let limit = q.limit.unwrap_or(30).clamp(1, 500);
let offset = q.offset.unwrap_or(0).max(0); let offset = q.offset.unwrap_or(0).max(0);
let filter_type = q
.filter_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let read_filter = q.read.as_deref();
let source_limit = offset
.saturating_add(limit)
.saturating_add(200)
.clamp(50, 1000);
let mut sql = "SELECT * FROM events WHERE 1=1".to_string(); let mut sql = "SELECT id, type, title, message, level, read, related_id, related_type, created_at FROM events WHERE 1=1".to_string();
let mut bind_idx = 0;
let mut params: Vec<String> = vec![]; let mut params: Vec<String> = vec![];
if let Some(ref t) = q.filter_type { if let Some(ref t) = filter_type {
bind_idx += 1; sql.push_str(" AND type = ?");
sql.push_str(&format!(" AND type = ?{}", bind_idx));
params.push(t.clone()); params.push(t.clone());
} }
if let Some(ref r) = q.read { if let Some(r) = read_filter {
if r == "true" { if r == "true" {
sql.push_str(" AND read = 1"); sql.push_str(" AND read = 1");
} else if r == "false" { } else if r == "false" {
@@ -53,16 +94,304 @@ async fn list_events(
} }
} }
sql.push_str(" ORDER BY created_at DESC"); sql.push_str(" ORDER BY created_at DESC LIMIT 1000");
sql.push_str(&format!(" LIMIT {} OFFSET {}", limit, offset));
let mut query = sqlx::query_as::<_, Event>(&sql); let mut query = sqlx::query_as::<_, Event>(&sql);
for p in &params { for p in &params {
query = query.bind(p); query = query.bind(p);
} }
let events: Vec<Event> = query.fetch_all(&*state.db).await?; let events: Vec<Event> = query.fetch_all(&*state.db).await?;
let mut rows: Vec<EventListItem> = events.into_iter().map(EventListItem::from).collect();
Ok(Json(json!({ "data": events }))) if should_include_source(filter_type.as_deref(), "runtime") {
rows.extend(
runtime_logs::list()
.into_iter()
.filter(|entry| read_filter != Some("false") || !entry.is_read)
.map(EventListItem::from),
);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "checkin") {
rows.extend(load_checkin_log_events(&state, source_limit).await?);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "proxy") {
rows.extend(load_proxy_log_events(&state, source_limit).await?);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "balance") {
rows.extend(load_balance_log_events(&state, source_limit).await?);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "token") {
rows.extend(load_token_log_events(&state, source_limit).await?);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "site_notice") {
rows.extend(load_site_notice_events(&state, source_limit).await?);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "status") {
rows.extend(load_status_log_events(&state, source_limit).await?);
}
rows.sort_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then_with(|| a.id.cmp(&b.id))
});
let start = offset as usize;
let end = start.saturating_add(limit as usize).min(rows.len());
let page = if start < rows.len() {
rows[start..end].to_vec()
} else {
Vec::new()
};
Ok(Json(json!({ "data": page })))
}
fn should_include_source(filter_type: Option<&str>, source: &str) -> bool {
filter_type.is_none_or(|value| value == source)
}
fn should_include_read_only_source(
filter_type: Option<&str>,
read_filter: Option<&str>,
source: &str,
) -> bool {
should_include_source(filter_type, source) && read_filter != Some("false")
}
async fn load_checkin_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-10000000000 - c.id) AS id,
'checkin' AS event_type,
CASE
WHEN lower(COALESCE(c.status, '')) IN ('success', 'ok', 'completed') THEN '签到成功'
WHEN lower(COALESCE(c.status, '')) IN ('skipped', 'skip') THEN '签到跳过'
ELSE '签到失败'
END || ':' || COALESCE(NULLIF(s.name, ''), '账号 #' || c.account_id) AS title,
COALESCE(NULLIF(a.username, ''), '账号 #' || c.account_id)
|| ' · 状态=' || COALESCE(NULLIF(c.status, ''), '-')
|| CASE WHEN c.reward IS NOT NULL AND trim(c.reward) != '' THEN ' · 奖励=' || c.reward ELSE '' END
|| CASE WHEN c.message IS NOT NULL AND trim(c.message) != '' THEN ' · ' || c.message ELSE '' END AS message,
CASE
WHEN lower(COALESCE(c.status, '')) IN ('success', 'ok', 'completed') THEN 'info'
WHEN lower(COALESCE(c.status, '')) IN ('skipped', 'skip') THEN 'warning'
ELSE 'error'
END AS level,
1 AS is_read,
c.id AS related_id,
'checkin_log' AS related_type,
c.created_at AS created_at
FROM checkin_logs c
LEFT JOIN accounts a ON a.id = c.account_id
LEFT JOIN sites s ON s.id = a.site_id
ORDER BY c.created_at DESC
LIMIT ?
"#,
)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(rows)
}
async fn load_proxy_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-20000000000 - l.id) AS id,
'proxy' AS event_type,
CASE
WHEN lower(COALESCE(l.status, '')) = 'success' THEN '代理请求成功'
ELSE '代理请求失败'
END || ':' || COALESCE(NULLIF(l.model_requested, ''), NULLIF(l.model_actual, ''), '未知模型') AS title,
'站点=' || COALESCE(NULLIF(s.name, ''), '-')
|| ' · 账号=' || COALESCE(NULLIF(a.username, ''), CASE WHEN l.account_id IS NOT NULL THEN '账号 #' || l.account_id ELSE '-' END)
|| ' · 状态=' || COALESCE(NULLIF(l.status, ''), '-')
|| CASE WHEN l.http_status IS NOT NULL THEN ' · HTTP ' || l.http_status ELSE '' END
|| CASE WHEN l.latency_ms IS NOT NULL THEN ' · ' || l.latency_ms || 'ms' ELSE '' END
|| CASE WHEN l.total_tokens IS NOT NULL THEN ' · Tokens=' || l.total_tokens ELSE '' END
|| CASE WHEN l.estimated_cost IS NOT NULL THEN ' · 成本=' || printf('$%.6f', l.estimated_cost) ELSE '' END
|| CASE WHEN l.error_message IS NOT NULL AND trim(l.error_message) != '' THEN ' · ' || l.error_message ELSE '' END AS message,
CASE WHEN lower(COALESCE(l.status, '')) = 'success' THEN 'info' ELSE 'error' END AS level,
1 AS is_read,
l.id AS related_id,
'proxy_log' AS related_type,
l.created_at AS created_at
FROM proxy_logs l
LEFT JOIN accounts a ON a.id = l.account_id
LEFT JOIN sites s ON s.id = a.site_id
ORDER BY l.created_at DESC
LIMIT ?
"#,
)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(rows)
}
async fn load_balance_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-30000000000 - a.id) AS id,
'balance' AS event_type,
'余额已刷新:' || COALESCE(NULLIF(s.name, ''), '账号 #' || a.id) AS title,
COALESCE(NULLIF(a.username, ''), '账号 #' || a.id)
|| ' · 当前余额=' || printf('%.6f', COALESCE(a.balance, 0))
|| ' · 已用=' || printf('%.6f', COALESCE(a.balance_used, 0))
|| ' · 额度=' || printf('%.6f', COALESCE(a.quota, 0)) AS message,
'info' AS level,
1 AS is_read,
a.id AS related_id,
'account' AS related_type,
a.last_balance_refresh AS created_at
FROM accounts a
LEFT JOIN sites s ON s.id = a.site_id
WHERE a.last_balance_refresh IS NOT NULL
AND trim(a.last_balance_refresh) != ''
ORDER BY a.last_balance_refresh DESC
LIMIT ?
"#,
)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(rows)
}
async fn load_token_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-40000000000 - t.id) AS id,
'token' AS event_type,
CASE
WHEN COALESCE(t.updated_at, t.created_at, '') != COALESCE(t.created_at, '') THEN '令牌已更新'
ELSE '令牌已创建'
END || ':' || COALESCE(NULLIF(t.name, ''), 'Token #' || t.id) AS title,
COALESCE(NULLIF(s.name, ''), '-')
|| ' · ' || COALESCE(NULLIF(a.username, ''), '账号 #' || t.account_id)
|| ' · 状态=' || COALESCE(NULLIF(t.value_status, ''), '-')
|| CASE WHEN COALESCE(t.enabled, 1) = 1 THEN ' · 已启用' ELSE ' · 已禁用' END
|| CASE WHEN t.token_group IS NOT NULL AND trim(t.token_group) != '' THEN ' · 分组=' || t.token_group ELSE '' END
|| CASE WHEN t.source IS NOT NULL AND trim(t.source) != '' THEN ' · 来源=' || t.source ELSE '' END AS message,
CASE
WHEN COALESCE(t.enabled, 1) = 1
AND lower(COALESCE(t.value_status, 'ready')) IN ('ready', 'valid') THEN 'info'
ELSE 'warning'
END AS level,
1 AS is_read,
t.id AS related_id,
'account_token' AS related_type,
COALESCE(t.updated_at, t.created_at) AS created_at
FROM account_tokens t
LEFT JOIN accounts a ON a.id = t.account_id
LEFT JOIN sites s ON s.id = a.site_id
WHERE COALESCE(t.updated_at, t.created_at) IS NOT NULL
AND trim(COALESCE(t.updated_at, t.created_at)) != ''
ORDER BY COALESCE(t.updated_at, t.created_at) DESC
LIMIT ?
"#,
)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(rows)
}
async fn load_site_notice_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-50000000000 - n.id) AS id,
'site_notice' AS event_type,
'站点公告:' || COALESCE(NULLIF(n.title, ''), '未命名公告') AS title,
COALESCE(NULLIF(s.name, ''), '站点 #' || n.site_id)
|| CASE WHEN n.content IS NOT NULL AND trim(n.content) != '' THEN ' · ' || substr(n.content, 1, 420) ELSE '' END AS message,
COALESCE(NULLIF(n.level, ''), 'info') AS level,
1 AS is_read,
n.id AS related_id,
'site_announcement' AS related_type,
COALESCE(n.upstream_created_at, n.first_seen_at) AS created_at
FROM site_announcements n
LEFT JOIN sites s ON s.id = n.site_id
ORDER BY COALESCE(n.upstream_created_at, n.first_seen_at) DESC
LIMIT ?
"#,
)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(rows)
}
async fn load_status_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let per_source_limit = limit.clamp(50, 1000);
let mut rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-60000000000 - s.id) AS id,
'status' AS event_type,
'站点状态:' || COALESCE(NULLIF(s.name, ''), '站点 #' || s.id) AS title,
'平台=' || COALESCE(NULLIF(s.platform, ''), '-')
|| ' · 状态=' || COALESCE(NULLIF(s.status, ''), '-')
|| CASE WHEN s.url IS NOT NULL AND trim(s.url) != '' THEN ' · ' || s.url ELSE '' END AS message,
CASE
WHEN lower(COALESCE(s.status, 'active')) IN ('active', 'enabled', 'healthy') THEN 'info'
WHEN lower(COALESCE(s.status, '')) IN ('disabled', 'inactive') THEN 'warning'
ELSE 'error'
END AS level,
1 AS is_read,
s.id AS related_id,
'site' AS related_type,
COALESCE(s.updated_at, s.created_at) AS created_at
FROM sites s
WHERE COALESCE(s.updated_at, s.created_at) IS NOT NULL
AND trim(COALESCE(s.updated_at, s.created_at)) != ''
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
LIMIT ?
"#,
)
.bind(per_source_limit)
.fetch_all(&*state.db)
.await?;
rows.extend(
sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-70000000000 - a.id) AS id,
'status' AS event_type,
'账号状态:' || COALESCE(NULLIF(s.name, ''), '站点 #' || a.site_id) AS title,
COALESCE(NULLIF(a.username, ''), '账号 #' || a.id)
|| ' · 状态=' || COALESCE(NULLIF(a.status, ''), '-')
|| CASE WHEN a.oauth_provider IS NOT NULL AND trim(a.oauth_provider) != '' THEN ' · OAuth=' || a.oauth_provider ELSE '' END AS message,
CASE
WHEN lower(COALESCE(a.status, 'active')) IN ('active', 'enabled', 'healthy') THEN 'info'
WHEN lower(COALESCE(a.status, '')) IN ('disabled', 'inactive') THEN 'warning'
ELSE 'error'
END AS level,
1 AS is_read,
a.id AS related_id,
'account' AS related_type,
COALESCE(a.updated_at, a.created_at) AS created_at
FROM accounts a
LEFT JOIN sites s ON s.id = a.site_id
WHERE COALESCE(a.updated_at, a.created_at) IS NOT NULL
AND trim(COALESCE(a.updated_at, a.created_at)) != ''
ORDER BY COALESCE(a.updated_at, a.created_at) DESC
LIMIT ?
"#,
)
.bind(per_source_limit)
.fetch_all(&*state.db)
.await?,
);
Ok(rows)
} }
/// GET /api/events/count — unread count /// GET /api/events/count — unread count
@@ -98,5 +427,334 @@ async fn clear_events(State(state): State<AppState>) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM events") sqlx::query("DELETE FROM events")
.execute(&*state.db) .execute(&*state.db)
.await?; .await?;
runtime_logs::clear();
Ok(Json(json!({ "success": true }))) Ok(Json(json!({ "success": true })))
} }
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
#[serde(rename_all = "camelCase")]
struct EventListItem {
id: i64,
#[serde(rename = "type")]
event_type: String,
title: String,
message: Option<String>,
level: String,
#[serde(rename = "read")]
is_read: bool,
related_id: Option<i64>,
related_type: Option<String>,
created_at: Option<String>,
}
impl From<Event> for EventListItem {
fn from(event: Event) -> Self {
Self {
id: event.id,
event_type: event.event_type,
title: event.title,
message: event.message,
level: event.level.unwrap_or_else(|| "info".to_string()),
is_read: event.is_read.unwrap_or(false),
related_id: event.related_id,
related_type: event.related_type,
created_at: event.created_at,
}
}
}
impl From<runtime_logs::RuntimeLogEntry> for EventListItem {
fn from(entry: runtime_logs::RuntimeLogEntry) -> Self {
Self {
id: entry.id,
event_type: entry.event_type,
title: entry.title,
message: entry.message,
level: entry.level,
is_read: entry.is_read,
related_id: entry.related_id,
related_type: entry.related_type,
created_at: Some(entry.created_at),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
auth::jwt::generate_jwt,
config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig},
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
};
use axum::{
body::Body,
http::{Request, StatusCode},
};
use axum::extract::{Query, State};
use sqlx::sqlite::SqlitePoolOptions;
use tower::ServiceExt as _;
async fn test_state() -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("memory sqlite");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("run migrations");
AppState::new(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".into(),
port: 0,
log_level: "info".into(),
cors_origins: vec![],
},
db: DbConfig {
url: "sqlite::memory:".into(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "secret".into(),
jwt_expiry_hours: 1,
auth_token: "auth".into(),
proxy_token: "proxy".into(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 3,
proxy_log_retention_days: 7,
template_proxy_url: None,
},
comfyui: Default::default(),
},
pool,
BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-test".into()),
},
RuntimeAuthSettings::from_token_for_test("proxy"),
)
}
async fn seed_event_sources(state: &AppState) {
sqlx::query(
"INSERT INTO sites (id, name, url, platform, status)
VALUES (1, '主站', 'https://example.test', 'new-api', 'active')",
)
.execute(&*state.db)
.await
.unwrap();
sqlx::query(
"INSERT INTO accounts
(id, site_id, username, access_token, balance, balance_used, quota, last_balance_refresh, status)
VALUES
(10, 1, 'alice', 'access-token', 12.5, 1.25, 20.0, '2026-06-08T09:30:00Z', 'active')",
)
.execute(&*state.db)
.await
.unwrap();
sqlx::query(
"INSERT INTO account_tokens
(id, account_id, name, token, source, token_group, value_status, enabled, created_at, updated_at)
VALUES
(20, 10, '默认 Key', 'sk-account-token', 'manual', 'prod', 'ready', 1, '2026-06-08T09:00:00Z', '2026-06-08T09:40:00Z')",
)
.execute(&*state.db)
.await
.unwrap();
sqlx::query(
"INSERT INTO checkin_logs (id, account_id, status, message, reward, created_at)
VALUES (30, 10, 'success', 'Daily checkin completed', '+$0.0100', '2026-06-08T09:50:00Z')",
)
.execute(&*state.db)
.await
.unwrap();
sqlx::query(
"INSERT INTO proxy_logs
(id, account_id, model_requested, model_actual, status, http_status, latency_ms, total_tokens, estimated_cost, error_message, created_at)
VALUES
(40, 10, 'gpt-test', 'gpt-test', 'failed', 404, 812, 33, 0.0012, 'model not found', '2026-06-08T10:00:00Z')",
)
.execute(&*state.db)
.await
.unwrap();
sqlx::query(
"INSERT INTO site_announcements
(id, site_id, platform, source_key, title, content, level, first_seen_at, read_at)
VALUES
(50, 1, 'new-api', 'notice-1', '维护通知', '上游将维护 30 分钟', 'warning', '2026-06-08T10:10:00Z', NULL)",
)
.execute(&*state.db)
.await
.unwrap();
}
async fn list_for_type(state: AppState, filter_type: Option<&str>) -> Value {
let Json(body) = list_events(
State(state),
Query(ListEventsQuery {
limit: Some(50),
offset: Some(0),
filter_type: filter_type.map(str::to_string),
read: None,
}),
)
.await
.unwrap();
body
}
#[tokio::test]
async fn events_router_requires_admin_api_token() {
let state = test_state().await;
let app = routes(state.clone()).with_state(state.clone());
let ok_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/events")
.header("Authorization", "Bearer auth")
.body(Body::empty())
.unwrap(),
)
.await
.expect("admin token request");
assert_eq!(ok_response.status(), StatusCode::OK);
let user_jwt = generate_jwt(&state.config.auth.jwt_secret, "1", "user", 1)
.expect("user jwt");
let user_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/events")
.header("Authorization", format!("Bearer {user_jwt}"))
.body(Body::empty())
.unwrap(),
)
.await
.expect("user jwt request");
assert_eq!(user_response.status(), StatusCode::UNAUTHORIZED);
let missing_response = app
.oneshot(
Request::builder()
.uri("/api/events")
.body(Body::empty())
.unwrap(),
)
.await
.expect("missing auth request");
assert_eq!(missing_response.status(), StatusCode::UNAUTHORIZED);
}
fn data_types(body: &Value) -> Vec<String> {
body["data"]
.as_array()
.unwrap()
.iter()
.filter_map(|row| row.get("type").and_then(Value::as_str).map(str::to_string))
.collect()
}
#[tokio::test]
async fn list_events_aggregates_business_log_sources() {
let state = test_state().await;
seed_event_sources(&state).await;
let all = list_for_type(state.clone(), None).await;
let all_types = data_types(&all);
assert!(all_types.contains(&"checkin".to_string()));
assert!(all_types.contains(&"proxy".to_string()));
assert!(all_types.contains(&"balance".to_string()));
assert!(all_types.contains(&"token".to_string()));
assert!(all_types.contains(&"site_notice".to_string()));
assert!(all_types.contains(&"status".to_string()));
for (filter_type, expected_title) in [
("checkin", "签到成功"),
("proxy", "代理请求失败"),
("balance", "余额已刷新"),
("token", "令牌已更新"),
("site_notice", "站点公告"),
("status", "站点状态"),
] {
let body = list_for_type(state.clone(), Some(filter_type)).await;
let rows = body["data"].as_array().unwrap();
assert!(!rows.is_empty(), "{filter_type} should return rows");
assert!(rows
.iter()
.all(|row| row.get("type").and_then(Value::as_str) == Some(filter_type)));
assert!(rows.iter().any(|row| row
.get("title")
.and_then(Value::as_str)
.unwrap_or_default()
.contains(expected_title)));
}
}
#[tokio::test]
async fn unread_filter_excludes_read_only_virtual_logs() {
let state = test_state().await;
seed_event_sources(&state).await;
let Json(checkin_body) = list_events(
State(state.clone()),
Query(ListEventsQuery {
limit: Some(50),
offset: Some(0),
filter_type: Some("checkin".into()),
read: Some("false".into()),
}),
)
.await
.unwrap();
assert!(checkin_body["data"].as_array().unwrap().is_empty());
let Json(notice_body) = list_events(
State(state),
Query(ListEventsQuery {
limit: Some(50),
offset: Some(0),
filter_type: Some("site_notice".into()),
read: Some("false".into()),
}),
)
.await
.unwrap();
assert!(notice_body["data"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn status_snapshots_are_available_in_all_logs_and_status_filter() {
let state = test_state().await;
seed_event_sources(&state).await;
let all = list_for_type(state.clone(), None).await;
assert!(data_types(&all).contains(&"status".to_string()));
let status = list_for_type(state, Some("status")).await;
let rows = status["data"].as_array().unwrap();
assert!(rows.iter().any(|row| row["title"]
.as_str()
.unwrap_or_default()
.contains("站点状态")));
assert!(rows.iter().any(|row| row["title"]
.as_str()
.unwrap_or_default()
.contains("账号状态")));
assert!(rows
.iter()
.all(|row| row.get("type").and_then(Value::as_str) == Some("status")));
}
}
+133 -32
View File
@@ -10,9 +10,14 @@ use include_dir::{include_dir, Dir};
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::convert::Infallible; use std::convert::Infallible;
use tower::service_fn; use tower::service_fn;
use tower_http::compression::CompressionLayer;
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
static EMBEDDED_WEB: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/web"); static EMBEDDED_WEB: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/web");
static EMBEDDED_WEBSITE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/website");
const _: &[u8] = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/dist/web/index.html"));
const _: &[u8] =
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/dist/website/index.html"));
mod admin; mod admin;
mod auth; mod auth;
@@ -22,7 +27,7 @@ mod comfyui_agent;
mod digital_human; mod digital_human;
mod events; mod events;
mod health; mod health;
mod monitor; mod money_printer;
mod oauth; mod oauth;
mod organizations; mod organizations;
mod picture_agent; mod picture_agent;
@@ -31,7 +36,6 @@ mod search;
mod site_announcements; mod site_announcements;
pub(crate) mod stats; pub(crate) mod stats;
mod tasks; mod tasks;
mod update_center;
mod user; mod user;
mod user_api_keys; mod user_api_keys;
#[path = "video_agent_legacy.rs"] #[path = "video_agent_legacy.rs"]
@@ -46,6 +50,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(admin::routes(state.clone())) .merge(admin::routes(state.clone()))
.merge(proxy::routes()) .merge(proxy::routes())
.merge(picture_agent::routes()) .merge(picture_agent::routes())
.merge(money_printer::routes())
.merge(comfyui_agent::routes()) .merge(comfyui_agent::routes())
.merge(digital_human::routes()) .merge(digital_human::routes())
.merge(comfyui::routes()) .merge(comfyui::routes())
@@ -56,9 +61,8 @@ pub fn build_router(state: AppState) -> Router {
let api_routes = api_routes let api_routes = api_routes
.merge(auth::routes()) .merge(auth::routes())
.merge(events::routes()) .merge(events::routes(state.clone()))
.merge(checkin::routes()) .merge(checkin::routes())
.merge(monitor::routes())
.merge(oauth::routes()) .merge(oauth::routes())
.merge(organizations::routes()) .merge(organizations::routes())
.merge(search::routes()) .merge(search::routes())
@@ -66,7 +70,6 @@ pub fn build_router(state: AppState) -> Router {
.merge(workflow_ui::routes()) .merge(workflow_ui::routes())
.merge(stats::routes()) .merge(stats::routes())
.merge(tasks::routes()) .merge(tasks::routes())
.merge(update_center::routes())
.merge(user::routes()) .merge(user::routes())
.merge(user_api_keys::routes()) .merge(user_api_keys::routes())
.merge(workspace::routes()) .merge(workspace::routes())
@@ -74,40 +77,42 @@ pub fn build_router(state: AppState) -> Router {
.with_state(state); .with_state(state);
let router = Router::new().merge(api_routes); let router = Router::new().merge(api_routes);
match std::env::var("BOOSAPI_WEB_DIR") let router = match std::env::var("BOOSAPI_WEB_DIR")
.ok() .ok()
.map(|value| value.trim().to_string()) .map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
{ {
Some(web_dir) => { Some(web_dir) => {
let web_dir_owned = web_dir.clone(); let website_dir = std::env::var("BOOSAPI_WEBSITE_DIR")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "dist/website".to_string());
let static_service = ServeDir::new(&web_dir) let static_service = ServeDir::new(&web_dir)
.append_index_html_on_directories(true) .append_index_html_on_directories(true)
.fallback(service_fn(move |req: Request<Body>| { .fallback(service_fn(move |req: Request<Body>| {
let web_dir = web_dir_owned.clone(); let web_dir = web_dir.clone();
let website_dir = website_dir.clone();
async move { async move {
let path = req.uri().path(); let path = req.uri().path();
if is_api_path(path) { if is_api_path(path) {
return Ok::<_, Infallible>(api_not_found_response(path)); return Ok::<_, Infallible>(api_not_found_response(path));
} }
let index_path = format!("{}/index.html", web_dir); Ok::<_, Infallible>(filesystem_static_response(
match tokio::fs::read_to_string(&index_path).await { path,
Ok(html) => Ok::<_, Infallible>(html_response(html)), &web_dir,
Err(_) => Ok::<_, Infallible>( &website_dir,
Response::builder() )
.status(StatusCode::NOT_FOUND) .await)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from(format!("404: {} not found", path)))
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response()),
),
}
} }
})); }));
router.fallback_service(static_service) router.fallback_service(static_service)
} }
None => router.fallback(embedded_static_handler), None => router.fallback(embedded_static_handler),
} };
router.layer(CompressionLayer::new())
} }
fn api_not_found_response(path: &str) -> Response { fn api_not_found_response(path: &str) -> Response {
@@ -120,6 +125,49 @@ fn api_not_found_response(path: &str) -> Response {
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response()) .unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
} }
async fn filesystem_static_response(path: &str, web_dir: &str, website_dir: &str) -> Response {
let root_dir = if should_serve_website(path) {
website_dir
} else {
web_dir
};
let asset_path = embedded_asset_path(path);
let candidate = format!("{}/{}", root_dir.trim_end_matches('/'), asset_path);
match tokio::fs::read(&candidate).await {
Ok(bytes) => return embedded_file_response(&asset_path, &bytes),
Err(_) => {
if candidate.ends_with("/index.html") {
let without_index = candidate.trim_end_matches("index.html");
if let Ok(bytes) = tokio::fs::read(without_index).await {
return embedded_file_response(&asset_path, &bytes);
}
}
}
}
let index_path = format!("{}/index.html", root_dir.trim_end_matches('/'));
match tokio::fs::read(&index_path).await {
Ok(bytes) => embedded_file_response("index.html", &bytes),
Err(_) if should_serve_website(path) => {
let fallback_index = format!("{}/index.html", web_dir.trim_end_matches('/'));
match tokio::fs::read(&fallback_index).await {
Ok(bytes) => embedded_file_response("index.html", &bytes),
Err(_) => not_found_response(path),
}
}
Err(_) => not_found_response(path),
}
}
fn not_found_response(path: &str) -> Response {
Response::builder()
.status(StatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from(format!("404: {} not found", path)))
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
}
async fn embedded_static_handler(req: Request<Body>) -> Response { async fn embedded_static_handler(req: Request<Body>) -> Response {
let path = req.uri().path(); let path = req.uri().path();
if is_api_path(path) { if is_api_path(path) {
@@ -127,27 +175,64 @@ async fn embedded_static_handler(req: Request<Body>) -> Response {
} }
let asset_path = embedded_asset_path(path); let asset_path = embedded_asset_path(path);
if let Some(file) = EMBEDDED_WEB.get_file(&asset_path) { let static_dir = if should_serve_website(path) {
&EMBEDDED_WEBSITE
} else {
&EMBEDDED_WEB
};
if let Some(file) = static_dir.get_file(&asset_path) {
return embedded_file_response(&asset_path, file.contents()); return embedded_file_response(&asset_path, file.contents());
} }
if let Some(file) = EMBEDDED_WEB.get_file("index.html") { if let Some(file) = static_dir.get_file("index.html") {
return embedded_file_response("index.html", file.contents()); 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() Response::builder()
.status(StatusCode::NOT_FOUND) .status(StatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8") .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from("embedded dist/web/index.html not found")) .body(Body::from(
"embedded frontend index.html not found; rebuild with ./build-and-run.sh --production or set BOOSAPI_WEB_DIR/BOOSAPI_WEBSITE_DIR",
))
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response()) .unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
} }
fn is_api_path(path: &str) -> bool { fn is_api_path(path: &str) -> bool {
path.starts_with("/api/") || path.starts_with("/v1/") path.starts_with("/api/")
|| path.starts_with("/v1/")
|| path == "/health"
|| path == "/ready"
|| path == "/metrics"
}
fn should_serve_website(path: &str) -> bool {
path == "/"
|| path == "/new"
|| path.starts_with("/new/")
|| path.starts_with("/_next/")
|| path == "/favicon.ico"
|| path == "/favicon.png"
|| path == "/favicon-64.png"
|| path == "/logo.png"
} }
fn embedded_asset_path(path: &str) -> String { fn embedded_asset_path(path: &str) -> String {
let trimmed = path.trim_start_matches('/'); let trimmed = path.trim_start_matches('/');
if trimmed == "new" || trimmed == "new/" || trimmed == "new/index.html" {
return "new.html".to_string();
}
if trimmed.is_empty() || trimmed.ends_with('/') { if trimmed.is_empty() || trimmed.ends_with('/') {
format!("{trimmed}index.html") format!("{trimmed}index.html")
} else { } else {
@@ -155,14 +240,6 @@ fn embedded_asset_path(path: &str) -> String {
} }
} }
fn html_response(html: String) -> Response {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(html))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
fn embedded_file_response(path: &str, bytes: &[u8]) -> Response { fn embedded_file_response(path: &str, bytes: &[u8]) -> Response {
let content_type = mime_guess::from_path(path) let content_type = mime_guess::from_path(path)
.first_or_octet_stream() .first_or_octet_stream()
@@ -195,3 +272,27 @@ async fn removed_comfy_workbench() -> (StatusCode, Json<Value>) {
})), })),
) )
} }
#[cfg(test)]
mod tests {
use super::{embedded_asset_path, should_serve_website};
#[test]
fn experimental_website_route_is_served_from_website_export() {
assert!(should_serve_website("/new"));
assert!(should_serve_website("/new/"));
assert!(should_serve_website("/new/index.html"));
assert_eq!(embedded_asset_path("/new"), "new.html");
assert_eq!(embedded_asset_path("/new/"), "new.html");
assert_eq!(embedded_asset_path("/new/index.html"), "new.html");
}
#[test]
fn application_routes_still_use_web_spa() {
assert!(!should_serve_website("/boos"));
assert!(!should_serve_website("/dashboard"));
assert!(!should_serve_website("/user/recharge"));
assert!(!should_serve_website("/logs"));
assert!(!should_serve_website("/events"));
}
}
File diff suppressed because it is too large Load Diff
-297
View File
@@ -1,297 +0,0 @@
// Monitor routes — LDOH incident dashboard proxy with cookie auth
use crate::{
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, State},
http::{header, HeaderMap},
response::Response,
routing::{get, post},
Json, Router,
};
use serde_json::{json, Value};
const MONITOR_AUTH_COOKIE: &str = "meta_monitor_auth";
const LDOH_BASE_URL: &str = "https://ldoh.105117.xyz";
const LDOH_COOKIE_SETTING_KEY: &str = "monitor_ldoh_cookie";
pub fn routes() -> Router<AppState> {
Router::new()
.route(
"/api/monitor/config",
get(get_monitor_config).put(update_monitor_config),
)
.route("/api/monitor/session", post(create_session))
.route("/monitor-proxy/ldoh", get(ldoh_proxy_handler))
.route("/monitor-proxy/ldoh/", get(ldoh_proxy_handler))
.route("/monitor-proxy/ldoh/{*path}", get(ldoh_proxy_handler))
}
/// GET /api/monitor/config — returns whether LDOH cookie is configured
async fn get_monitor_config(State(state): State<AppState>) -> AppResult<Json<Value>> {
let cookie: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
.bind(LDOH_COOKIE_SETTING_KEY)
.fetch_optional(&*state.db)
.await?;
let raw = cookie
.and_then(|(v,)| {
serde_json::from_str::<serde_json::Value>(&v)
.ok()
.and_then(|parsed| parsed.as_str().map(String::from))
})
.unwrap_or_default();
let configured = !raw.is_empty();
let masked = if configured {
mask_cookie_value(&raw)
} else {
String::new()
};
Ok(Json(json!({
"ldohCookieConfigured": configured,
"ldohCookieMasked": masked,
})))
}
/// PUT /api/monitor/config — save LDOH cookie
async fn update_monitor_config(
State(state): State<AppState>,
Json(body): Json<serde_json::Value>,
) -> AppResult<Json<Value>> {
let cookie = body
.get("ldohCookie")
.and_then(|v| v.as_str())
.unwrap_or("");
if cookie.is_empty() {
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, '')")
.bind(LDOH_COOKIE_SETTING_KEY)
.execute(&*state.db)
.await?;
return Ok(Json(json!({
"success": true,
"message": "LDOH Cookie 已清空",
"ldohCookieConfigured": false,
})));
}
let normalized = normalize_ldoh_cookie(cookie);
if !normalized.starts_with("ld_auth_session=") || normalized.len() < 24 {
return Err(AppError::BadRequest("Cookie 格式无效".into()));
}
let escaped = serde_json::to_string(&normalized).unwrap_or_default();
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.bind(LDOH_COOKIE_SETTING_KEY)
.bind(&escaped)
.execute(&*state.db)
.await?;
Ok(Json(json!({
"success": true,
"message": "LDOH Cookie 已保存",
"ldohCookieConfigured": true,
"ldohCookieMasked": mask_cookie_value(&normalized),
})))
}
/// POST /api/monitor/session — returns cookie value for client to set
async fn create_session(State(state): State<AppState>) -> Json<Value> {
let token = &state.config.auth.auth_token;
Json(json!({
"success": true,
"_cookie": format!("{}={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=7200", MONITOR_AUTH_COOKIE, token),
}))
}
/// GET /monitor-proxy/ldoh/{*path} — reverse proxy to ldoh.105117.xyz
async fn ldoh_proxy_handler(
State(state): State<AppState>,
headers: HeaderMap,
Path(path): Path<String>,
) -> Result<Response, AppError> {
// Auth check via cookie
let cookies = parse_cookies(
headers
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or(""),
);
if cookies.get(MONITOR_AUTH_COOKIE).map(|s| s.as_str()) != Some(&state.config.auth.auth_token) {
return Err(AppError::Unauthorized(
"Missing or invalid monitor session".into(),
));
}
// Get stored LDOH cookie
let stored: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
.bind(LDOH_COOKIE_SETTING_KEY)
.fetch_optional(&*state.db)
.await?;
let ldoh_cookie = stored
.and_then(|(v,)| {
serde_json::from_str::<serde_json::Value>(&v)
.ok()
.and_then(|parsed| parsed.as_str().map(String::from))
})
.unwrap_or_default();
if ldoh_cookie.is_empty() {
return Err(AppError::BadRequest("LDOH cookie not configured".into()));
}
// Build upstream URL
let clean_path = if path.is_empty() || path == "/" {
String::new()
} else {
format!("/{}", path)
};
let target_url = format!("{}{}", LDOH_BASE_URL, clean_path);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to build client: {e}")))?;
let mut req = client
.get(&target_url)
.header("cookie", &ldoh_cookie)
.header(
"accept",
headers
.get("accept")
.and_then(|v| v.to_str().ok())
.unwrap_or("*/*"),
)
.header(
"accept-language",
headers
.get("accept-language")
.and_then(|v| v.to_str().ok())
.unwrap_or("zh-CN,zh;q=0.9,en;q=0.8"),
)
.header(
"user-agent",
headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.unwrap_or("boosapiMonitorProxy/1.0"),
);
if let Some(referer) = headers.get("referer").and_then(|v| v.to_str().ok()) {
req = req.header("referer", referer.replace("/monitor-proxy/ldoh", ""));
}
let resp = req
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("LDOH unreachable: {e}")))?;
let status = resp.status();
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let mut builder = axum::response::Response::builder().status(status);
if let Some(ctl) = resp
.headers()
.get("cache-control")
.and_then(|v| v.to_str().ok())
{
builder = builder.header("cache-control", ctl);
}
// Rewrite text content
if content_type.contains("text/html")
|| content_type.contains("javascript")
|| content_type.contains("text/css")
|| content_type.contains("application/json")
{
let text = resp
.text()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
return builder
.header(header::CONTENT_TYPE, &content_type)
.body(axum::body::Body::from(rewrite_proxy_text(&text)))
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")));
}
let bytes = resp
.bytes()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
builder
.header(header::CONTENT_TYPE, &content_type)
.body(axum::body::Body::from(bytes))
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))
}
// ── Helpers ────────────────────────────────────────────────────────────
fn parse_cookies(raw: &str) -> std::collections::HashMap<String, String> {
let mut map = std::collections::HashMap::new();
for part in raw.split(';') {
let part = part.trim();
if let Some(idx) = part.find('=') {
let key = part[..idx].trim().to_string();
let val = part[idx + 1..].trim().to_string();
if !key.is_empty() {
map.insert(key, val);
}
}
}
map
}
fn mask_cookie_value(cookie_text: &str) -> String {
let raw = cookie_text.trim();
if raw.is_empty() {
return String::new();
}
let value = raw.split('=').nth(1).unwrap_or(raw);
if value.len() <= 10 {
format!("{}****", &value[..2.min(value.len())])
} else {
format!("{}****{}", &value[..6], &value[value.len() - 4..])
}
}
fn normalize_ldoh_cookie(raw: &str) -> String {
let trimmed = raw.trim().to_string();
if trimmed.contains("ld_auth_session=") {
if let Some(first) = trimmed.split(';').next() {
if first.trim().starts_with("ld_auth_session=") {
return first.trim().to_string();
}
}
}
format!("ld_auth_session={}", trimmed)
}
fn rewrite_proxy_text(text: &str) -> String {
text.replace("https://ldoh.105117.xyz/", "/monitor-proxy/ldoh/")
.replace(
"https:\\/\\/ldoh.105117.xyz\\/",
"\\/monitor-proxy\\/ldoh\\/",
)
.replace("src=\"/", "src=\"/monitor-proxy/ldoh/")
.replace("src='/", "src='/monitor-proxy/ldoh/")
.replace("href=\"/", "href=\"/monitor-proxy/ldoh/")
.replace("href='/", "href='/monitor-proxy/ldoh/")
.replace("action=\"/", "action=\"/monitor-proxy/ldoh/")
.replace("action='/", "action='/monitor-proxy/ldoh/")
.replace("\"/_next/", "\"/monitor-proxy/ldoh/_next/")
.replace("'/_next/", "'/monitor-proxy/ldoh/_next/")
.replace("\"\\\\/api/", "\"\\\\/monitor-proxy\\\\/ldoh\\\\/api/")
.replace("'/api/", "'/monitor-proxy/ldoh/api/")
.replace("\"/api/", "\"/monitor-proxy/ldoh/api/")
}
+4206 -117
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+44 -36
View File
@@ -1,27 +1,28 @@
use crate::{ use crate::{
AppState,
auth::jwt::validate_jwt, auth::jwt::validate_jwt,
db::models::background_task::BackgroundTask, db::models::background_task::BackgroundTask,
db::models::downstream_api_key::DownstreamApiKey, db::models::downstream_api_key::DownstreamApiKey,
error::{AppError, AppResult}, error::{AppError, AppResult},
AppState,
}; };
use axum::{ use axum::{
Json, Router,
body::Body, body::Body,
extract::{Multipart, Path, Query, State}, extract::{Multipart, Path, Query, State},
http::{header, HeaderMap, Response, StatusCode}, http::{HeaderMap, Response, StatusCode, header},
response::IntoResponse, response::IntoResponse,
routing::{get, post}, routing::{get, post},
Json, Router,
}; };
use reqwest::Client; use reqwest::Client;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
use std::time::Duration; use std::time::Duration;
const TEMPLATE_GALLERY_URL: &str = const TEMPLATE_GALLERY_PAGE_URL: &str =
"https://weiyyin.github.io/linux-do-gpt-image/linux_do_2036927_prompts_images.filtered.json"; "https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html";
const LINUX_DO_TEMPLATE_JSON: &str =
include_str!("../../assets/picture-agent/linux_do_2036927_prompts_images.with_tags.json");
const TASK_TYPE_PICTURE_GENERATION: &str = "picture-agent-generation"; const TASK_TYPE_PICTURE_GENERATION: &str = "picture-agent-generation";
const TEMPLATE_GALLERY_TIMEOUT_SECS: u64 = 30;
const PICTURE_GENERATION_TIMEOUT_SECS: u64 = 300; const PICTURE_GENERATION_TIMEOUT_SECS: u64 = 300;
const PICTURE_GENERATION_RETRY_SECS: u64 = 180; const PICTURE_GENERATION_RETRY_SECS: u64 = 180;
const STALE_RUNNING_JOB_MINUTES: i64 = 12; const STALE_RUNNING_JOB_MINUTES: i64 = 12;
@@ -31,6 +32,7 @@ struct PictureAgentAuth {
user_id: Option<i64>, user_id: Option<i64>,
role: String, role: String,
outbound_key: String, outbound_key: String,
key_id: Option<i64>,
key_name: String, key_name: String,
} }
@@ -122,11 +124,13 @@ async fn resolve_picture_agent_auth(
let token = extract_bearer_token(headers) let token = extract_bearer_token(headers)
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?; .ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token { let proxy_token = state.runtime_proxy_token();
if token == state.config.auth.auth_token || token == proxy_token {
return Ok(PictureAgentAuth { return Ok(PictureAgentAuth {
user_id: None, user_id: None,
role: "admin".to_string(), role: "admin".to_string(),
outbound_key: state.config.auth.proxy_token.clone(), outbound_key: proxy_token,
key_id: None,
key_name: "global-proxy".to_string(), key_name: "global-proxy".to_string(),
}); });
} }
@@ -147,12 +151,14 @@ async fn resolve_picture_agent_auth(
user_id: Some(user_id), user_id: Some(user_id),
role: claims.role, role: claims.role,
outbound_key: key.key, outbound_key: key.key,
key_id: Some(key.id),
key_name: key.name, key_name: key.name,
}), }),
Err(_) if claims.role == "admin" => Ok(PictureAgentAuth { Err(_) if claims.role == "admin" => Ok(PictureAgentAuth {
user_id: Some(user_id), user_id: Some(user_id),
role: claims.role, role: claims.role,
outbound_key: state.config.auth.proxy_token.clone(), outbound_key: state.runtime_proxy_token(),
key_id: None,
key_name: "global-proxy".to_string(), key_name: "global-proxy".to_string(),
}), }),
Err(message) => Err(AppError::Forbidden(message)), Err(message) => Err(AppError::Forbidden(message)),
@@ -285,6 +291,7 @@ mod tests {
assert!(!is_picture_model_pattern("deepseek-chat")); assert!(!is_picture_model_pattern("deepseek-chat"));
assert!(!is_picture_model_pattern("qwen-plus")); assert!(!is_picture_model_pattern("qwen-plus"));
} }
} }
fn prompt_title(prompt: &str, post_number: Option<i64>) -> String { fn prompt_title(prompt: &str, post_number: Option<i64>) -> String {
@@ -344,18 +351,30 @@ async fn templates_handler(
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let _auth = resolve_picture_agent_auth(&headers, &state).await?; let _auth = resolve_picture_agent_auth(&headers, &state).await?;
let limit = query.limit.unwrap_or(144).clamp(1, 240); let limit = query.limit.unwrap_or(144).clamp(1, 240);
let items = template_client() let (templates, local_status) = match parse_gallery_templates(LINUX_DO_TEMPLATE_JSON, limit) {
.get(TEMPLATE_GALLERY_URL) Ok(templates) => (templates, json!({ "ok": true, "source": "local-json" })),
.send() Err(err) => {
.await tracing::warn!(%err, "Picture Agent local Linux Do template gallery unavailable");
.map_err(|err| AppError::UpstreamError(format!("failed to fetch templates: {err}")))? (Vec::new(), json!({ "ok": false, "message": err }))
.error_for_status() }
.map_err(|err| AppError::UpstreamError(format!("template gallery returned {err}")))? };
.json::<Vec<GalleryItem>>()
.await
.map_err(|err| AppError::UpstreamError(format!("failed to parse templates: {err}")))?;
let templates: Vec<Value> = items Ok(Json(json!({
"success": true,
"sourceUrl": TEMPLATE_GALLERY_PAGE_URL,
"source": local_status,
"templates": templates,
})))
}
fn parse_gallery_templates(raw: &str, limit: usize) -> Result<Vec<Value>, String> {
if limit == 0 {
return Ok(Vec::new());
}
let items = serde_json::from_str::<Vec<GalleryItem>>(raw)
.map_err(|err| format!("failed to parse local Linux Do templates: {err}"))?;
Ok(items
.into_iter() .into_iter()
.filter_map(|item| { .filter_map(|item| {
let prompt = item.prompt?.trim().to_string(); let prompt = item.prompt?.trim().to_string();
@@ -383,27 +402,14 @@ async fn templates_handler(
"prompt": prompt, "prompt": prompt,
"source": { "source": {
"name": "Linux Do GPT Image Gallery", "name": "Linux Do GPT Image Gallery",
"url": "https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html", "url": TEMPLATE_GALLERY_PAGE_URL,
"postNumber": post_number, "postNumber": post_number,
"username": item.username, "username": item.username,
}, },
})) }))
}) })
.take(limit) .take(limit)
.collect(); .collect())
Ok(Json(json!({
"success": true,
"sourceUrl": "https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html",
"templates": templates,
})))
}
fn template_client() -> Client {
Client::builder()
.timeout(Duration::from_secs(TEMPLATE_GALLERY_TIMEOUT_SECS))
.build()
.unwrap_or_else(|_| Client::new())
} }
fn picture_generation_client() -> Client { fn picture_generation_client() -> Client {
@@ -654,6 +660,7 @@ async fn create_generation_job(
let initial_result = json!({ let initial_result = json!({
"userId": auth.user_id, "userId": auth.user_id,
"role": auth.role, "role": auth.role,
"keyId": auth.key_id,
"keyName": auth.key_name, "keyName": auth.key_name,
"prompt": prompt, "prompt": prompt,
"model": model, "model": model,
@@ -735,6 +742,7 @@ async fn create_generation_job(
let final_result = json!({ let final_result = json!({
"userId": auth_for_task.user_id, "userId": auth_for_task.user_id,
"role": auth_for_task.role, "role": auth_for_task.role,
"keyId": auth_for_task.key_id,
"keyName": auth_for_task.key_name, "keyName": auth_for_task.key_name,
"prompt": prompt, "prompt": prompt,
"model": model, "model": model,
+793 -361
View File
File diff suppressed because it is too large Load Diff
+946 -92
View File
File diff suppressed because it is too large Load Diff
-121
View File
@@ -1,18 +1,11 @@
// Background tasks routes — list and inspect async operations // Background tasks routes — list and inspect async operations
use std::convert::Infallible;
use crate::{db::models::background_task::BackgroundTask, error::AppResult, AppState}; use crate::{db::models::background_task::BackgroundTask, error::AppResult, AppState};
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
response::{
sse::{Event, Sse},
IntoResponse,
},
routing::get, routing::get,
Json, Router, Json, Router,
}; };
use futures::stream::Stream;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use tracing::info; use tracing::info;
@@ -26,10 +19,6 @@ pub fn routes() -> Router<AppState> {
Router::new() Router::new()
.route("/api/tasks", get(list_tasks)) .route("/api/tasks", get(list_tasks))
.route("/api/tasks/{id}", get(get_task)) .route("/api/tasks/{id}", get(get_task))
.route(
"/api/update-center/tasks/{id}/stream",
get(stream_task_logs),
)
} }
/// GET /api/tasks — list background tasks /// GET /api/tasks — list background tasks
@@ -67,113 +56,3 @@ async fn get_task(State(state): State<AppState>, Path(id): Path<String>) -> AppR
)), )),
} }
} }
/// GET /api/update-center/tasks/{id}/stream — SSE stream of task progress
async fn stream_task_logs(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, axum::response::Response> {
info!("Streaming task logs for: {}", id);
// Check task exists
let task: Option<BackgroundTask> =
sqlx::query_as::<_, BackgroundTask>("SELECT * FROM background_tasks WHERE id = ?")
.bind(&id)
.fetch_optional(&*state.db)
.await
.map_err(|e| {
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!("DB error: {e}"),
)
.into_response()
})?;
let task = match task {
Some(t) => t,
None => {
return Err((
axum::http::StatusCode::NOT_FOUND,
json!({ "success": false, "message": "task not found" }).to_string(),
)
.into_response());
}
};
// Stream task state with polling
let initial_status = task.status.clone();
let is_done = initial_status != "pending" && initial_status != "running";
let state_clone = state.clone();
let stream = async_stream::stream! {
// Yield current task status
yield Ok::<_, Infallible>(Event::default()
.event("status")
.data(json!({ "status": initial_status }).to_string()));
yield Ok::<_, Infallible>(Event::default()
.event("log")
.data(json!({
"seq": 1,
"message": format!("Task {}: {}", initial_status, task.message.as_deref().unwrap_or("")),
"createdAt": task.created_at.as_deref().unwrap_or(""),
}).to_string()));
if is_done {
yield Ok::<_, Infallible>(Event::default()
.event("done")
.data(json!({ "status": initial_status }).to_string()));
return;
}
// Poll for updates
let poll_id = id.clone();
for i in 0..120 { // Poll up to 120 times (2 minutes)
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
let current: Option<BackgroundTask> = sqlx::query_as(
"SELECT * FROM background_tasks WHERE id = ?"
)
.bind(&poll_id)
.fetch_optional(&*state_clone.db)
.await
.ok()
.flatten();
match current {
Some(t) => {
let status = t.status;
let updated_at = t.updated_at.unwrap_or_default();
yield Ok::<_, Infallible>(Event::default()
.event("log")
.data(json!({
"seq": i + 2,
"message": format!("Status: {} — {}", status, t.message.as_deref().unwrap_or("")),
"createdAt": updated_at,
}).to_string()));
if status != "pending" && status != "running" {
yield Ok::<_, Infallible>(Event::default()
.event("done")
.data(json!({ "status": status }).to_string()));
return;
}
}
None => {
yield Ok::<_, Infallible>(Event::default()
.event("done")
.data(json!({ "status": "unknown", "message": "task removed" }).to_string()));
return;
}
}
}
// Timeout
yield Ok::<_, Infallible>(Event::default()
.event("done")
.data(json!({ "status": "timeout" }).to_string()));
};
Ok(Sse::new(stream))
}
-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,
})
},
))
}
+2502 -93
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 tracing::info;
use crate::{ use crate::{
auth::jwt::validate_jwt,
error::{AppError, AppResult}, error::{AppError, AppResult},
AppState, AppState,
}; };
@@ -200,15 +201,28 @@ async fn external_comfyui_status_value(state: &AppState) -> Value {
} }
fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> { fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
let expected = format!("Bearer {}", state.config.auth.auth_token); let token = headers
let actual = headers
.get(header::AUTHORIZATION) .get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.unwrap_or(""); .and_then(|value| {
value
.strip_prefix("Bearer ")
.or_else(|| value.strip_prefix("bearer "))
})
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".to_string()))?;
if actual != expected { if token == state.config.auth.auth_token {
return Err(AppError::BadRequest("unauthorized".to_string())); return Ok(());
}
let claims = validate_jwt(&state.config.auth.jwt_secret, token)
.map_err(|_| AppError::Unauthorized("Invalid admin token".to_string()))?;
if claims.role != "admin" {
return Err(AppError::Forbidden(
"Admin account is required for this route".to_string(),
));
} }
Ok(()) Ok(())
@@ -273,7 +287,7 @@ async fn call_llm(state: &AppState, prompt: &str, system: &str) -> AppResult<Str
let resp = client let resp = client
.post(url) .post(url)
.bearer_auth(&state.config.auth.proxy_token) .bearer_auth(state.runtime_proxy_token())
.json(&json!({ .json(&json!({
"model": &state.config.comfyui.agent_model, "model": &state.config.comfyui.agent_model,
"messages": [ "messages": [
@@ -319,7 +333,7 @@ async fn call_image_gen(state: &AppState, prompt: &str) -> AppResult<String> {
let resp = client let resp = client
.post(url) .post(url)
.bearer_auth(&state.config.auth.proxy_token) .bearer_auth(state.runtime_proxy_token())
.json(&json!({ .json(&json!({
"model": "gpt-image-2", "model": "gpt-image-2",
"prompt": prompt, "prompt": prompt,
@@ -362,7 +376,7 @@ async fn call_tts(state: &AppState, text: &str, voice: &str) -> AppResult<Vec<u8
let resp = client let resp = client
.post(url) .post(url)
.bearer_auth(&state.config.auth.proxy_token) .bearer_auth(state.runtime_proxy_token())
.json(&json!({ .json(&json!({
"model": "tts-1", "model": "tts-1",
"input": text, "input": text,
@@ -633,3 +647,101 @@ async fn full_workflow(
} }
}))) })))
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::{
auth::jwt::generate_jwt,
config::{AppConfig, AuthConfig, ComfyUIConfig, DbConfig, ProxyConfig, ServerConfig},
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
};
use axum::http::HeaderValue;
use sqlx::sqlite::SqlitePoolOptions;
async fn test_state() -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool");
AppState::new(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 4000,
log_level: "info".to_string(),
cors_origins: Vec::new(),
},
db: DbConfig {
url: "sqlite::memory:".to_string(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "test-jwt-secret".to_string(),
jwt_expiry_hours: 24,
auth_token: "test-admin-token".to_string(),
proxy_token: "sk-test-proxy".to_string(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 1,
proxy_log_retention_days: 7,
template_proxy_url: None,
},
comfyui: ComfyUIConfig::default(),
},
pool,
BuiltinKeys {
comfyui_agent: (0, "sk-comfyui-test".to_string()),
},
RuntimeAuthSettings::from_token_for_test("sk-test-proxy"),
)
}
fn bearer_headers(token: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {token}")).expect("valid bearer header"),
);
headers
}
#[tokio::test]
async fn legacy_video_agent_auth_accepts_admin_jwt() {
let state = test_state().await;
let token = generate_jwt(
&state.config.auth.jwt_secret,
"42",
"admin",
state.config.auth.jwt_expiry_hours,
)
.expect("admin jwt");
check_auth(&bearer_headers(&token), &state)
.expect("admin jwt can access legacy video agent routes");
}
#[tokio::test]
async fn legacy_video_agent_auth_rejects_user_jwt() {
let state = test_state().await;
let token = generate_jwt(
&state.config.auth.jwt_secret,
"43",
"user",
state.config.auth.jwt_expiry_hours,
)
.expect("user jwt");
let result = check_auth(&bearer_headers(&token), &state);
assert!(matches!(result, Err(AppError::Forbidden(_))));
}
}
+2292 -118
View File
File diff suppressed because it is too large Load Diff
+124 -11
View File
@@ -7,12 +7,12 @@ use axum::{
body::Body, body::Body,
extract::{Path, Query, State}, extract::{Path, Query, State},
http::{header, HeaderMap, Response, StatusCode}, http::{header, HeaderMap, Response, StatusCode},
routing::get, routing::{get, post},
Json, Router, Json, Router,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::path::PathBuf; use std::{collections::HashSet, path::PathBuf};
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -34,9 +34,19 @@ struct AssetQuery {
page_size: Option<i64>, page_size: Option<i64>,
} }
#[derive(Debug, Deserialize)]
struct BatchDeleteAssetsRequest {
#[serde(default, alias = "publicIds")]
public_ids: Vec<String>,
}
pub fn routes() -> Router<AppState> { pub fn routes() -> Router<AppState> {
Router::new() Router::new()
.route("/api/workspace/assets", get(list_admin_assets)) .route("/api/workspace/assets", get(list_admin_assets))
.route(
"/api/workspace/assets/batch-delete",
post(batch_delete_admin_assets),
)
.route( .route(
"/api/workspace/assets/{public_id}", "/api/workspace/assets/{public_id}",
get(get_admin_asset).delete(delete_admin_asset), get(get_admin_asset).delete(delete_admin_asset),
@@ -46,6 +56,10 @@ pub fn routes() -> Router<AppState> {
get(get_admin_asset_file), get(get_admin_asset_file),
) )
.route("/api/user/workspace/assets", get(list_user_assets)) .route("/api/user/workspace/assets", get(list_user_assets))
.route(
"/api/user/workspace/assets/batch-delete",
post(batch_delete_user_assets),
)
.route( .route(
"/api/user/workspace/assets/{public_id}", "/api/user/workspace/assets/{public_id}",
get(get_user_asset).delete(delete_user_asset), get(get_user_asset).delete(delete_user_asset),
@@ -77,7 +91,8 @@ fn resolve_caller(
let token = bearer(headers) let token = bearer(headers)
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?; .ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token { let proxy_token = state.runtime_proxy_token();
if token == state.config.auth.auth_token || token == proxy_token {
return Ok(WorkspaceCaller::Admin); return Ok(WorkspaceCaller::Admin);
} }
@@ -158,6 +173,20 @@ fn row_to_json(row: sqlx::sqlite::SqliteRow) -> Value {
}) })
} }
const WORKSPACE_OUTPUT_FILTER_SQL: &str = r#"
AND status = 'completed'
AND (
json_extract(metadata, '$.workspaceAsset.role') = 'output'
OR json_extract(metadata, '$.workflowUi.type') = 'output'
OR (
source IN ('picture-agent', 'workflow-studio')
AND json_type(metadata, '$.responseItem') IS NOT NULL
)
OR json_type(metadata, '$.moneyPrinter') IS NOT NULL
OR json_type(metadata, '$.digitalHuman') IS NOT NULL
)
"#;
async fn list_assets( async fn list_assets(
state: AppState, state: AppState,
caller: WorkspaceCaller, caller: WorkspaceCaller,
@@ -170,11 +199,12 @@ async fn list_assets(
}; };
let is_admin = matches!(caller, WorkspaceCaller::Admin); let is_admin = matches!(caller, WorkspaceCaller::Admin);
let items = sqlx::query( let list_sql = format!(
"SELECT * "SELECT *
FROM workspace_assets FROM workspace_assets
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?)) AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
{WORKSPACE_OUTPUT_FILTER_SQL}
AND (? IS NULL OR source = ?) AND (? IS NULL OR source = ?)
AND (? IS NULL OR kind = ?) AND (? IS NULL OR kind = ?)
AND ( AND (
@@ -185,8 +215,9 @@ async fn list_assets(
OR source LIKE ? OR source LIKE ?
) )
ORDER BY datetime(created_at) DESC, id DESC ORDER BY datetime(created_at) DESC, id DESC
LIMIT ? OFFSET ?", LIMIT ? OFFSET ?"
) );
let items = sqlx::query(&list_sql)
.bind(is_admin) .bind(is_admin)
.bind(scoped_user_id) .bind(scoped_user_id)
.bind(scoped_user_id) .bind(scoped_user_id)
@@ -207,11 +238,12 @@ async fn list_assets(
.fetch_all(&*state.db) .fetch_all(&*state.db)
.await?; .await?;
let total: i64 = sqlx::query_scalar( let count_sql = format!(
"SELECT COUNT(*) "SELECT COUNT(*)
FROM workspace_assets FROM workspace_assets
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?)) AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
{WORKSPACE_OUTPUT_FILTER_SQL}
AND (? IS NULL OR source = ?) AND (? IS NULL OR source = ?)
AND (? IS NULL OR kind = ?) AND (? IS NULL OR kind = ?)
AND ( AND (
@@ -220,8 +252,9 @@ async fn list_assets(
OR prompt LIKE ? OR prompt LIKE ?
OR model LIKE ? OR model LIKE ?
OR source LIKE ? OR source LIKE ?
)", )"
) );
let total: i64 = sqlx::query_scalar(&count_sql)
.bind(is_admin) .bind(is_admin)
.bind(scoped_user_id) .bind(scoped_user_id)
.bind(scoped_user_id) .bind(scoped_user_id)
@@ -266,7 +299,7 @@ async fn get_asset_row(
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?)) AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
LIMIT 1", LIMIT 1",
) )
.bind(public_id) .bind(&public_id)
.bind(is_admin) .bind(is_admin)
.bind(scoped_user_id) .bind(scoped_user_id)
.bind(scoped_user_id) .bind(scoped_user_id)
@@ -306,7 +339,7 @@ async fn delete_asset(
AND deleted_at IS NULL AND deleted_at IS NULL
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))", AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))",
) )
.bind(public_id) .bind(&public_id)
.bind(is_admin) .bind(is_admin)
.bind(scoped_user_id) .bind(scoped_user_id)
.bind(scoped_user_id) .bind(scoped_user_id)
@@ -317,9 +350,71 @@ async fn delete_asset(
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
return Err(AppError::NotFound("Workspace asset not found".into())); return Err(AppError::NotFound("Workspace asset not found".into()));
} }
if let Err(err) = crate::services::workspace::project_deleted_asset(&state, &public_id).await {
tracing::warn!(%err, %public_id, "failed to update workspace asset delete projection");
}
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
async fn delete_assets_batch(
state: AppState,
caller: WorkspaceCaller,
public_ids: Vec<String>,
) -> AppResult<Json<Value>> {
let mut seen = HashSet::new();
let public_ids = public_ids
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.filter(|value| seen.insert(value.clone()))
.take(200)
.collect::<Vec<_>>();
if public_ids.is_empty() {
return Err(AppError::BadRequest("No workspace assets selected".into()));
}
let scoped_user_id = match caller {
WorkspaceCaller::Admin => None,
WorkspaceCaller::User { user_id } => Some(user_id),
};
let is_admin = matches!(caller, WorkspaceCaller::Admin);
let mut deleted_ids = Vec::new();
let mut failed_ids = Vec::new();
for public_id in public_ids {
let result = sqlx::query(
"UPDATE workspace_assets
SET deleted_at = datetime('now'), updated_at = datetime('now')
WHERE public_id = ?
AND deleted_at IS NULL
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))",
)
.bind(&public_id)
.bind(is_admin)
.bind(scoped_user_id)
.bind(scoped_user_id)
.bind(is_admin)
.bind(scoped_user_id)
.execute(&*state.db)
.await?;
if result.rows_affected() > 0 {
if let Err(err) = crate::services::workspace::project_deleted_asset(&state, &public_id).await {
tracing::warn!(%err, %public_id, "failed to update workspace asset batch delete projection");
}
deleted_ids.push(public_id);
} else {
failed_ids.push(public_id);
}
}
Ok(Json(json!({
"success": true,
"deletedCount": deleted_ids.len(),
"deletedIds": deleted_ids,
"failedIds": failed_ids,
})))
}
async fn get_asset_file( async fn get_asset_file(
state: AppState, state: AppState,
caller: WorkspaceCaller, caller: WorkspaceCaller,
@@ -400,6 +495,15 @@ async fn delete_admin_asset(
delete_asset(state, caller, public_id).await delete_asset(state, caller, public_id).await
} }
async fn batch_delete_admin_assets(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<BatchDeleteAssetsRequest>,
) -> AppResult<Json<Value>> {
let caller = resolve_caller(&headers, &state, true)?;
delete_assets_batch(state, caller, payload.public_ids).await
}
async fn list_user_assets( async fn list_user_assets(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@@ -435,3 +539,12 @@ async fn delete_user_asset(
let caller = resolve_caller(&headers, &state, false)?; let caller = resolve_caller(&headers, &state, false)?;
delete_asset(state, caller, public_id).await delete_asset(state, caller, public_id).await
} }
async fn batch_delete_user_assets(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<BatchDeleteAssetsRequest>,
) -> AppResult<Json<Value>> {
let caller = resolve_caller(&headers, &state, false)?;
delete_assets_batch(state, caller, payload.public_ids).await
}
+295
View File
@@ -0,0 +1,295 @@
use crate::error::AppResult;
use serde_json::{json, Value};
use sqlx::{Row, SqlitePool, Transaction};
/// One balance-changing event. Written to `credit_ledger` in the same
/// transaction that updates the cached balance, so ledger and cache never diverge.
pub struct LedgerEntry<'a> {
pub user_id: i64,
pub track: &'a str, // "usd" | "points"
pub entry_type: &'a str, // "recharge" | "admin_adjust" | "consume" | "refund" | "grant"
pub amount: f64, // +credit / -debit
pub balance_after: f64,
pub ref_type: Option<&'a str>,
pub ref_id: Option<&'a str>,
pub model: Option<&'a str>,
pub operator: Option<&'a str>,
pub note: Option<&'a str>,
}
pub async fn ensure_ledger_schema(db: &SqlitePool) -> AppResult<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS credit_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
user_id INTEGER NOT NULL,
track TEXT NOT NULL,
entry_type TEXT NOT NULL,
amount REAL NOT NULL,
balance_after REAL NOT NULL,
ref_type TEXT,
ref_id TEXT,
model TEXT,
operator TEXT,
note TEXT,
created_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(db)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS credit_ledger_user_created_idx
ON credit_ledger (user_id, created_at DESC)",
)
.execute(db)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS credit_ledger_track_type_idx
ON credit_ledger (track, entry_type)",
)
.execute(db)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS user_credits (
user_id INTEGER PRIMARY KEY NOT NULL,
points_balance REAL NOT NULL DEFAULT 0,
points_total_recharged REAL NOT NULL DEFAULT 0,
updated_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(db)
.await?;
Ok(())
}
/// Append one ledger row inside an existing transaction. Caller is responsible
/// for also updating the cached balance in the same `tx`.
pub async fn record_ledger_entry(
tx: &mut Transaction<'_, sqlx::Sqlite>,
entry: LedgerEntry<'_>,
) -> AppResult<()> {
sqlx::query(
"INSERT INTO credit_ledger
(user_id, track, entry_type, amount, balance_after,
ref_type, ref_id, model, operator, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(entry.user_id)
.bind(entry.track)
.bind(entry.entry_type)
.bind(entry.amount)
.bind(entry.balance_after)
.bind(entry.ref_type)
.bind(entry.ref_id)
.bind(entry.model)
.bind(entry.operator)
.bind(entry.note)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Current points balance for a user (0 if no row yet).
pub async fn points_balance(db: &SqlitePool, user_id: i64) -> AppResult<f64> {
let bal: Option<f64> = sqlx::query_scalar(
"SELECT points_balance FROM user_credits WHERE user_id = ?",
)
.bind(user_id)
.fetch_optional(db)
.await?;
Ok(bal.unwrap_or(0.0))
}
/// Apply a points delta (+credit / -debit) atomically: upsert the cached
/// balance in `user_credits` and append one `credit_ledger(track=points)` row,
/// in a single transaction. Returns the new balance.
#[allow(clippy::too_many_arguments)]
pub async fn apply_points_delta(
db: &SqlitePool,
user_id: i64,
delta: f64,
entry_type: &str,
model: Option<&str>,
operator: Option<&str>,
ref_type: Option<&str>,
ref_id: Option<&str>,
) -> AppResult<f64> {
ensure_ledger_schema(db).await?;
let mut tx = db.begin().await?;
let current: f64 = sqlx::query_scalar(
"SELECT points_balance FROM user_credits WHERE user_id = ?",
)
.bind(user_id)
.fetch_optional(&mut *tx)
.await?
.unwrap_or(0.0);
let next = current + delta;
let total_recharged_inc = if delta > 0.0 && entry_type == "recharge" { delta } else { 0.0 };
sqlx::query(
"INSERT INTO user_credits (user_id, points_balance, points_total_recharged, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
points_balance = ?,
points_total_recharged = points_total_recharged + ?,
updated_at = datetime('now')",
)
.bind(user_id).bind(next).bind(total_recharged_inc)
.bind(next).bind(total_recharged_inc)
.execute(&mut *tx).await?;
record_ledger_entry(&mut tx, LedgerEntry {
user_id, track: "points", entry_type,
amount: delta, balance_after: next,
ref_type, ref_id, model, operator, note: None,
}).await?;
tx.commit().await?;
Ok(next)
}
/// List a user's ledger rows, newest first, optional track/entry_type filter + paging.
pub async fn list_user_ledger(
db: &SqlitePool,
user_id: i64,
track: Option<&str>,
entry_type: Option<&str>,
limit: i64,
offset: i64,
) -> AppResult<Vec<Value>> {
ensure_ledger_schema(db).await?;
let limit = limit.clamp(1, 500);
let offset = offset.max(0);
let rows = sqlx::query(
"SELECT track, entry_type, amount, balance_after, model, operator, ref_type, ref_id, note, created_at
FROM credit_ledger
WHERE user_id = ?
AND (? IS NULL OR track = ?)
AND (? IS NULL OR entry_type = ?)
ORDER BY id DESC LIMIT ? OFFSET ?",
)
.bind(user_id)
.bind(track)
.bind(track)
.bind(entry_type)
.bind(entry_type)
.bind(limit)
.bind(offset)
.fetch_all(db)
.await?;
Ok(rows
.iter()
.map(|r| {
json!({
"track": r.try_get::<String, _>("track").unwrap_or_default(),
"entryType": r.try_get::<String, _>("entry_type").unwrap_or_default(),
"amount": r.try_get::<f64, _>("amount").unwrap_or(0.0),
"balanceAfter": r.try_get::<f64, _>("balance_after").unwrap_or(0.0),
"model": r.try_get::<Option<String>, _>("model").ok().flatten(),
"operator": r.try_get::<Option<String>, _>("operator").ok().flatten(),
"refType": r.try_get::<Option<String>, _>("ref_type").ok().flatten(),
"note": r.try_get::<Option<String>, _>("note").ok().flatten(),
"createdAt": r.try_get::<Option<String>, _>("created_at").ok().flatten(),
})
})
.collect())
}
/// Aggregate a user's ledger by (track, entry_type): sum amount + count.
pub async fn summarize_user_ledger(db: &SqlitePool, user_id: i64) -> AppResult<Vec<Value>> {
ensure_ledger_schema(db).await?;
let rows = sqlx::query(
"SELECT track, entry_type, COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt
FROM credit_ledger WHERE user_id = ?
GROUP BY track, entry_type ORDER BY track, entry_type",
)
.bind(user_id)
.fetch_all(db)
.await?;
Ok(rows
.iter()
.map(|r| {
json!({
"track": r.try_get::<String, _>("track").unwrap_or_default(),
"entryType": r.try_get::<String, _>("entry_type").unwrap_or_default(),
"total": r.try_get::<f64, _>("total").unwrap_or(0.0),
"count": r.try_get::<i64, _>("cnt").unwrap_or(0),
})
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
async fn mem_pool() -> SqlitePool {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
ensure_ledger_schema(&pool).await.unwrap();
pool
}
#[tokio::test]
async fn record_ledger_entry_writes_row_with_balance_snapshot() {
let pool = mem_pool().await;
let mut tx = pool.begin().await.unwrap();
record_ledger_entry(
&mut tx,
LedgerEntry {
user_id: 7,
track: "usd",
entry_type: "recharge",
amount: 10.0,
balance_after: 10.0,
ref_type: Some("stripe"),
ref_id: Some("cs_1"),
model: None,
operator: Some("system"),
note: None,
},
)
.await
.unwrap();
tx.commit().await.unwrap();
let row: (i64, String, String, f64, f64) = sqlx::query_as(
"SELECT user_id, track, entry_type, amount, balance_after
FROM credit_ledger WHERE user_id = 7",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row, (7, "usd".into(), "recharge".into(), 10.0, 10.0));
}
#[tokio::test]
async fn apply_points_delta_upserts_balance_and_writes_ledger() {
let pool = mem_pool().await;
// 首次 +100(充值)
let bal = apply_points_delta(&pool, 9, 100.0, "recharge", None, Some("system"), Some("recharge_record"), None).await.unwrap();
assert_eq!(bal, 100.0);
// 再 -30(消费)
let bal2 = apply_points_delta(&pool, 9, -30.0, "consume", Some("wf/video"), Some("system"), Some("workflow"), None).await.unwrap();
assert_eq!(bal2, 70.0);
// user_credits 缓存 = 70
let cached: f64 = sqlx::query_scalar("SELECT points_balance FROM user_credits WHERE user_id = 9").fetch_one(&pool).await.unwrap();
assert_eq!(cached, 70.0);
// ledger 两行
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM credit_ledger WHERE user_id=9 AND track='points'").fetch_one(&pool).await.unwrap();
assert_eq!(n, 2);
}
#[tokio::test]
async fn list_and_summarize_user_ledger() {
let pool = mem_pool().await;
apply_points_delta(&pool, 9, 100.0, "recharge", None, Some("system"), Some("rr"), None).await.unwrap();
apply_points_delta(&pool, 9, -10.0, "consume", Some("wf:1"), Some("system"), Some("run"), None).await.unwrap();
// 明细:user 9 两行,track=points
let rows = list_user_ledger(&pool, 9, None, None, 50, 0).await.unwrap();
assert_eq!(rows.len(), 2);
// 聚合:points 轨 consume 合计 -10
let sum = summarize_user_ledger(&pool, 9).await.unwrap();
assert!(sum.iter().any(|v| v["track"]=="points" && v["entryType"]=="consume"));
}
}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -94,6 +94,10 @@ impl ComfyUIProcess {
let base_url = self.internal_base_url(); let base_url = self.internal_base_url();
let mode = if self.config.uses_docker_backend() { let mode = if self.config.uses_docker_backend() {
"docker" "docker"
} else if self.config.uses_cloud_backend() {
"cloud"
} else if self.config.uses_runninghub_backend() {
"runninghub"
} else if self.config.enabled { } else if self.config.enabled {
"embedded" "embedded"
} else { } else {
+5
View File
@@ -79,6 +79,10 @@ impl CronScheduler {
async fn aggregate_usage(db: Arc<SqlitePool>) -> AppResult<()> { async fn aggregate_usage(db: Arc<SqlitePool>) -> AppResult<()> {
UsageTracker::create_daily_usage_snapshot_for_db(&db).await?; UsageTracker::create_daily_usage_snapshot_for_db(&db).await?;
let projected = UsageTracker::project_proxy_usage_for_db(&db).await?;
if projected > 0 {
tracing::debug!(projected, "usage aggregates projected");
}
sqlx::query( sqlx::query(
"UPDATE downstream_api_keys "UPDATE downstream_api_keys
SET updated_at = datetime('now') SET updated_at = datetime('now')
@@ -123,6 +127,7 @@ impl CronScheduler {
) )
.execute(&*db) .execute(&*db)
.await?; .await?;
crate::proxy::token_router::router::invalidate_route_cache();
Ok(()) Ok(())
} }
} }
+3 -1
View File
@@ -5,9 +5,11 @@ pub mod comfyui_process;
pub mod cron; pub mod cron;
pub mod notification; pub mod notification;
pub mod oauth; pub mod oauth;
pub mod payment;
pub mod register_captcha; pub mod register_captcha;
pub mod registration_limiter; pub mod registration_limiter;
pub mod route_rebuild; pub mod route_rebuild;
pub mod runtime_auth;
pub mod runtime_logs;
pub mod shared; pub mod shared;
pub mod update_center;
pub mod workspace; pub mod workspace;
+72 -9
View File
@@ -1,4 +1,4 @@
use crate::error::AppResult; use crate::error::{AppError, AppResult};
use chrono::{FixedOffset, SecondsFormat, TimeZone, Utc}; use chrono::{FixedOffset, SecondsFormat, TimeZone, Utc};
use lettre::{ use lettre::{
message::Mailbox, transport::smtp::authentication::Credentials, Message, SmtpTransport, message::Mailbox, transport::smtp::authentication::Credentials, Message, SmtpTransport,
@@ -36,13 +36,19 @@ pub struct Notification {
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
struct NotificationConfig { struct NotificationConfig {
telegram_enabled: bool,
telegram_bot_token: Option<String>, telegram_bot_token: Option<String>,
telegram_chat_id: Option<String>, telegram_chat_id: Option<String>,
telegram_thread_id: Option<i64>, telegram_thread_id: Option<i64>,
wecom_enabled: bool,
wecom_webhook: Option<String>, wecom_webhook: Option<String>,
feishu_enabled: bool,
feishu_webhook: Option<String>, feishu_webhook: Option<String>,
webhook_enabled: bool,
webhook_url: Option<String>, webhook_url: Option<String>,
bark_enabled: bool,
bark_url: Option<String>, bark_url: Option<String>,
serverchan_enabled: bool,
serverchan_key: Option<String>, serverchan_key: Option<String>,
smtp: SmtpConfig, smtp: SmtpConfig,
cooldown_sec: i64, cooldown_sec: i64,
@@ -50,8 +56,10 @@ struct NotificationConfig {
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
struct SmtpConfig { struct SmtpConfig {
enabled: bool,
host: Option<String>, host: Option<String>,
port: Option<u16>, port: Option<u16>,
secure: Option<bool>,
user: Option<String>, user: Option<String>,
pass: Option<String>, pass: Option<String>,
from: Option<String>, from: Option<String>,
@@ -100,6 +108,33 @@ impl NotificationService {
Ok(()) Ok(())
} }
pub async fn send_test(&self) -> AppResult<Vec<String>> {
Self::ensure_schema(&self.db).await?;
let config = self.load_config().await?;
let channels = self.enabled_channels(&config);
if channels.is_empty() {
return Err(AppError::BadRequest(
"请至少配置并启用一个通知通道".into(),
));
}
let notif = Notification {
title: "BossAI 通知测试".to_string(),
message: "通知通道配置正常。这条消息来自管理后台的测试发送。".to_string(),
level: NotificationLevel::Info,
channel: None,
signature: None,
};
for channel in &channels {
self.send_channel(channel, &notif, &config)
.await
.map_err(|err| AppError::BadRequest(format!("{channel} 通知测试失败: {err}")))?;
}
Ok(channels)
}
async fn load_config(&self) -> AppResult<NotificationConfig> { async fn load_config(&self) -> AppResult<NotificationConfig> {
let rows: Vec<(String, Option<String>)> = let rows: Vec<(String, Option<String>)> =
sqlx::query_as("SELECT key, value FROM settings WHERE key LIKE 'notify_%'") sqlx::query_as("SELECT key, value FROM settings WHERE key LIKE 'notify_%'")
@@ -112,18 +147,26 @@ impl NotificationService {
.collect(); .collect();
Ok(NotificationConfig { Ok(NotificationConfig {
telegram_enabled: setting_bool(&map, "notify_telegram_enabled", true),
telegram_bot_token: setting(&map, "notify_telegram_bot_token"), telegram_bot_token: setting(&map, "notify_telegram_bot_token"),
telegram_chat_id: setting(&map, "notify_telegram_chat_id"), telegram_chat_id: setting(&map, "notify_telegram_chat_id"),
telegram_thread_id: setting(&map, "notify_telegram_thread_id") telegram_thread_id: setting(&map, "notify_telegram_thread_id")
.and_then(|value| value.parse().ok()), .and_then(|value| value.parse().ok()),
wecom_enabled: setting_bool(&map, "notify_wecom_enabled", true),
wecom_webhook: setting(&map, "notify_wecom_webhook"), wecom_webhook: setting(&map, "notify_wecom_webhook"),
feishu_enabled: setting_bool(&map, "notify_feishu_enabled", true),
feishu_webhook: setting(&map, "notify_feishu_webhook"), feishu_webhook: setting(&map, "notify_feishu_webhook"),
webhook_enabled: setting_bool(&map, "notify_webhook_enabled", true),
webhook_url: setting(&map, "notify_webhook_url"), webhook_url: setting(&map, "notify_webhook_url"),
bark_enabled: setting_bool(&map, "notify_bark_enabled", true),
bark_url: setting(&map, "notify_bark_url"), bark_url: setting(&map, "notify_bark_url"),
serverchan_enabled: setting_bool(&map, "notify_serverchan_enabled", true),
serverchan_key: setting(&map, "notify_serverchan_key"), serverchan_key: setting(&map, "notify_serverchan_key"),
smtp: SmtpConfig { smtp: SmtpConfig {
enabled: setting_bool(&map, "notify_smtp_enabled", true),
host: setting(&map, "notify_smtp_host"), host: setting(&map, "notify_smtp_host"),
port: setting(&map, "notify_smtp_port").and_then(|value| value.parse().ok()), port: setting(&map, "notify_smtp_port").and_then(|value| value.parse().ok()),
secure: setting(&map, "notify_smtp_secure").map(|value| parse_bool(&value)),
user: setting(&map, "notify_smtp_user"), user: setting(&map, "notify_smtp_user"),
pass: setting(&map, "notify_smtp_pass"), pass: setting(&map, "notify_smtp_pass"),
from: setting(&map, "notify_smtp_from"), from: setting(&map, "notify_smtp_from"),
@@ -188,25 +231,28 @@ impl NotificationService {
fn enabled_channels(&self, config: &NotificationConfig) -> Vec<String> { fn enabled_channels(&self, config: &NotificationConfig) -> Vec<String> {
let mut channels = Vec::new(); let mut channels = Vec::new();
if config.telegram_bot_token.is_some() && config.telegram_chat_id.is_some() { if config.telegram_enabled
&& config.telegram_bot_token.is_some()
&& config.telegram_chat_id.is_some()
{
channels.push("telegram".to_string()); channels.push("telegram".to_string());
} }
if config.wecom_webhook.is_some() { if config.wecom_enabled && config.wecom_webhook.is_some() {
channels.push("wecom".to_string()); channels.push("wecom".to_string());
} }
if config.feishu_webhook.is_some() { if config.feishu_enabled && config.feishu_webhook.is_some() {
channels.push("feishu".to_string()); channels.push("feishu".to_string());
} }
if config.webhook_url.is_some() { if config.webhook_enabled && config.webhook_url.is_some() {
channels.push("webhook".to_string()); channels.push("webhook".to_string());
} }
if config.bark_url.is_some() { if config.bark_enabled && config.bark_url.is_some() {
channels.push("bark".to_string()); channels.push("bark".to_string());
} }
if config.serverchan_key.is_some() { if config.serverchan_enabled && config.serverchan_key.is_some() {
channels.push("serverchan".to_string()); channels.push("serverchan".to_string());
} }
if config.smtp.is_configured() { if config.smtp.enabled && config.smtp.is_configured() {
channels.push("smtp".to_string()); channels.push("smtp".to_string());
} }
channels channels
@@ -385,6 +431,7 @@ impl SmtpConfig {
fn send(self, notif: &Notification) -> anyhow::Result<()> { fn send(self, notif: &Notification) -> anyhow::Result<()> {
let host = required(self.host.as_deref(), "smtp host")?; let host = required(self.host.as_deref(), "smtp host")?;
let port = self.port.unwrap_or(587); let port = self.port.unwrap_or(587);
let secure = self.secure.unwrap_or(true);
let user = required(self.user.as_deref(), "smtp user")?; let user = required(self.user.as_deref(), "smtp user")?;
let pass = required(self.pass.as_deref(), "smtp pass")?; let pass = required(self.pass.as_deref(), "smtp pass")?;
let from = required(self.from.as_deref(), "smtp from")?; let from = required(self.from.as_deref(), "smtp from")?;
@@ -400,7 +447,12 @@ impl SmtpConfig {
)) ))
.body(format_notification_text(notif))?; .body(format_notification_text(notif))?;
let mailer = SmtpTransport::relay(host)? let builder = if secure {
SmtpTransport::relay(host)?
} else {
SmtpTransport::builder_dangerous(host)
};
let mailer = builder
.port(port) .port(port)
.credentials(Credentials::new(user.to_string(), pass.to_string())) .credentials(Credentials::new(user.to_string(), pass.to_string()))
.build(); .build();
@@ -413,6 +465,17 @@ fn setting(map: &HashMap<String, String>, key: &str) -> Option<String> {
map.get(key).map(|value| value.trim().to_string()) map.get(key).map(|value| value.trim().to_string())
} }
fn setting_bool(map: &HashMap<String, String>, key: &str, default: bool) -> bool {
map.get(key).map(|value| parse_bool(value)).unwrap_or(default)
}
fn parse_bool(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on" | "enabled"
)
}
fn required<'a>(value: Option<&'a str>, name: &str) -> anyhow::Result<&'a str> { fn required<'a>(value: Option<&'a str>, name: &str) -> anyhow::Result<&'a str> {
value.ok_or_else(|| anyhow::anyhow!("{name} is not configured")) value.ok_or_else(|| anyhow::anyhow!("{name} is not configured"))
} }
+523 -50
View File
@@ -1,13 +1,16 @@
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::{json, Map, Value};
use sqlx::{FromRow, SqlitePool}; use sqlx::{FromRow, SqlitePool};
use std::sync::Arc; use std::sync::Arc;
pub const SYSTEM_PROXY_URL_SETTING_KEY: &str = "runtime.system_proxy_url";
#[derive(Clone)] #[derive(Clone)]
pub struct OAuthService { pub struct OAuthService {
db: Arc<SqlitePool>, db: Arc<SqlitePool>,
client: reqwest::Client, #[cfg(test)]
client_override: Option<reqwest::Client>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
@@ -26,6 +29,7 @@ pub struct OAuthRouteUnit {
pub struct RefreshedToken { pub struct RefreshedToken {
pub account_id: i64, pub account_id: i64,
pub access_token: String, pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: Option<String>, pub expires_at: Option<String>,
} }
@@ -33,7 +37,16 @@ impl OAuthService {
pub fn new(db: Arc<SqlitePool>) -> Self { pub fn new(db: Arc<SqlitePool>) -> Self {
Self { Self {
db, db,
client: reqwest::Client::new(), #[cfg(test)]
client_override: None,
}
}
#[cfg(test)]
fn with_client(db: Arc<SqlitePool>, client: reqwest::Client) -> Self {
Self {
db,
client_override: Some(client),
} }
} }
@@ -55,6 +68,7 @@ impl OAuthService {
return Ok(RefreshedToken { return Ok(RefreshedToken {
account_id, account_id,
access_token: current_access_token, access_token: current_access_token,
refresh_token,
expires_at: token_expiry(extra_config.as_deref()), expires_at: token_expiry(extra_config.as_deref()),
}); });
} }
@@ -63,21 +77,24 @@ impl OAuthService {
.filter(|token| !token.trim().is_empty()) .filter(|token| !token.trim().is_empty())
.ok_or_else(|| AppError::BadRequest("OAuth account has no refresh token".into()))?; .ok_or_else(|| AppError::BadRequest("OAuth account has no refresh token".into()))?;
let mut refreshed = self.perform_refresh(site_id, &refresh_token).await?; let (proxy_url, use_system_proxy) = oauth_proxy_settings(extra_config.as_deref());
let client = self
.configured_http_client(proxy_url.as_deref(), use_system_proxy)
.await?;
let mut refreshed = self
.perform_refresh(&client, site_id, &refresh_token)
.await?;
refreshed.account_id = account_id; refreshed.account_id = account_id;
let next_extra_config =
merge_refreshed_extra_config(extra_config.as_deref(), &refreshed, &refresh_token)?;
sqlx::query( sqlx::query(
"UPDATE accounts "UPDATE accounts
SET access_token = ?, extra_config = ?, updated_at = datetime('now') SET access_token = ?, api_token = ?, extra_config = ?, updated_at = datetime('now')
WHERE id = ? AND site_id = ?", WHERE id = ? AND site_id = ?",
) )
.bind(&refreshed.access_token) .bind(&refreshed.access_token)
.bind( .bind(refreshed.refresh_token.as_deref().unwrap_or(&refresh_token))
serde_json::to_string(&serde_json::json!({ .bind(next_extra_config)
"expires_at": refreshed.expires_at,
"refreshed_at": chrono::Utc::now().to_rfc3339(),
}))
.map_err(|err| AppError::Internal(err.into()))?,
)
.bind(account_id) .bind(account_id)
.bind(site_id) .bind(site_id)
.execute(&*self.db) .execute(&*self.db)
@@ -90,6 +107,19 @@ impl OAuthService {
token_expired(extra_config) token_expired(extra_config)
} }
async fn configured_http_client(
&self,
proxy_url: Option<&str>,
use_system_proxy: bool,
) -> AppResult<reqwest::Client> {
#[cfg(test)]
if let Some(client) = &self.client_override {
return Ok(client.clone());
}
oauth_http_client(&*self.db, proxy_url, use_system_proxy).await
}
pub async fn list_route_units(&self, site_id: Option<i64>) -> AppResult<Vec<OAuthRouteUnit>> { pub async fn list_route_units(&self, site_id: Option<i64>) -> AppResult<Vec<OAuthRouteUnit>> {
if let Some(site_id) = site_id { if let Some(site_id) = site_id {
sqlx::query_as("SELECT * FROM oauth_route_units WHERE site_id = ? ORDER BY id") sqlx::query_as("SELECT * FROM oauth_route_units WHERE site_id = ? ORDER BY id")
@@ -132,29 +162,37 @@ impl OAuthService {
async fn perform_refresh( async fn perform_refresh(
&self, &self,
client: &reqwest::Client,
site_id: i64, site_id: i64,
refresh_token: &str, refresh_token: &str,
) -> AppResult<RefreshedToken> { ) -> AppResult<RefreshedToken> {
let token_endpoint: Option<String> = sqlx::query_scalar( let raw_custom_headers: Option<String> =
"SELECT json_extract(custom_headers, '$.oauth_token_url') FROM sites WHERE id = ?", sqlx::query_scalar("SELECT custom_headers FROM sites WHERE id = ?")
) .bind(site_id)
.bind(site_id) .fetch_optional(&*self.db)
.fetch_optional(&*self.db) .await?;
.await?;
let Some(token_endpoint) = token_endpoint.filter(|url| !url.trim().is_empty()) else { let token_config = oauth_token_config(raw_custom_headers.as_deref())?;
let Some(token_endpoint) = token_config.token_url.filter(|url| !url.trim().is_empty())
else {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"OAuth token endpoint is not configured for this site".into(), "OAuth token endpoint is not configured for this site".into(),
)); ));
}; };
let mut form = vec![
("grant_type".to_string(), "refresh_token".to_string()),
("refresh_token".to_string(), refresh_token.to_string()),
];
if let Some(client_id) = token_config.client_id {
form.push(("client_id".to_string(), client_id));
}
if let Some(client_secret) = token_config.client_secret {
form.push(("client_secret".to_string(), client_secret));
}
let response: Value = self let response: Value = client
.client
.post(token_endpoint) .post(token_endpoint)
.form(&[ .form(&form)
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
])
.send() .send()
.await .await
.map_err(|err| AppError::UpstreamError(format!("OAuth refresh failed: {err}")))? .map_err(|err| AppError::UpstreamError(format!("OAuth refresh failed: {err}")))?
@@ -162,46 +200,481 @@ impl OAuthService {
.await .await
.map_err(|err| AppError::UpstreamError(format!("Invalid OAuth response: {err}")))?; .map_err(|err| AppError::UpstreamError(format!("Invalid OAuth response: {err}")))?;
let access_token = response let access_token = string_field(&response, &["access_token", "accessToken"])
.get("access_token")
.and_then(Value::as_str)
.ok_or_else(|| AppError::UpstreamError("OAuth response missing access_token".into()))? .ok_or_else(|| AppError::UpstreamError("OAuth response missing access_token".into()))?
.to_string(); .to_string();
let expires_at = response let refresh_token =
.get("expires_at") string_field(&response, &["refresh_token", "refreshToken"]).map(str::to_string);
.and_then(Value::as_str) let expires_at = token_expiry_from_response(&response);
.map(str::to_string);
Ok(RefreshedToken { Ok(RefreshedToken {
account_id: 0, account_id: 0,
access_token, access_token,
refresh_token,
expires_at, expires_at,
}) })
} }
} }
pub async fn system_proxy_url(db: &SqlitePool) -> AppResult<Option<String>> {
let raw: Option<String> = sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
.bind(SYSTEM_PROXY_URL_SETTING_KEY)
.fetch_optional(db)
.await?;
Ok(raw
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()))
}
pub async fn oauth_http_client(
db: &SqlitePool,
proxy_url: Option<&str>,
use_system_proxy: bool,
) -> AppResult<reqwest::Client> {
let resolved_proxy = proxy_url
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let resolved_proxy = match resolved_proxy {
Some(proxy_url) => Some(proxy_url),
None if use_system_proxy => system_proxy_url(db).await?,
None => None,
};
let mut builder = reqwest::Client::builder();
if let Some(proxy_url) = resolved_proxy {
let proxy = reqwest::Proxy::all(&proxy_url)
.map_err(|err| AppError::BadRequest(format!("Invalid OAuth proxy URL: {err}")))?;
builder = builder.proxy(proxy);
}
builder
.build()
.map_err(|err| AppError::Internal(err.into()))
}
#[derive(Debug, Clone, Default)]
struct OAuthTokenConfig {
token_url: Option<String>,
client_id: Option<String>,
client_secret: Option<String>,
}
fn string_field<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a str> {
keys.iter()
.find_map(|key| value.get(*key))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn oauth_token_config(raw_custom_headers: Option<&str>) -> AppResult<OAuthTokenConfig> {
let Some(raw) = raw_custom_headers
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(OAuthTokenConfig::default());
};
let value = serde_json::from_str::<Value>(raw)
.map_err(|err| AppError::BadRequest(format!("Invalid site custom_headers JSON: {err}")))?;
let object = value
.as_object()
.ok_or_else(|| AppError::BadRequest("site custom_headers must be a JSON object".into()))?;
Ok(OAuthTokenConfig {
token_url: [
"oauth_token_url",
"oauth_token_endpoint",
"token_url",
"tokenUrl",
"tokenEndpoint",
]
.iter()
.find_map(|key| object.get(*key))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
client_id: ["oauth_client_id", "client_id", "clientId", "oauthClientId"]
.iter()
.find_map(|key| object.get(*key))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
client_secret: [
"oauth_client_secret",
"client_secret",
"clientSecret",
"oauthClientSecret",
]
.iter()
.find_map(|key| object.get(*key))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
})
}
fn token_expiry_from_response(response: &Value) -> Option<String> {
for key in [
"expires_at",
"expiresAt",
"tokenExpiresAt",
"token_expires_at",
] {
if let Some(expires_at) = response.get(key).and_then(expiry_datetime_from_value) {
return Some(expires_at.to_rfc3339());
}
}
let expires_in = response
.get("expires_in")
.or_else(|| response.get("expiresIn"))
.and_then(|value| {
value
.as_i64()
.or_else(|| value.as_str()?.trim().parse::<i64>().ok())
})
.filter(|value| *value > 0)?;
Some((chrono::Utc::now() + chrono::Duration::seconds(expires_in)).to_rfc3339())
}
fn expiry_datetime_from_value(value: &Value) -> Option<chrono::DateTime<chrono::Utc>> {
if let Some(number) = value.as_i64() {
return expiry_datetime_from_unixish(number);
}
let text = value.as_str()?.trim();
if text.is_empty() {
return None;
}
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(text) {
return Some(parsed.with_timezone(&chrono::Utc));
}
text.parse::<i64>()
.ok()
.and_then(expiry_datetime_from_unixish)
}
fn expiry_datetime_from_unixish(value: i64) -> Option<chrono::DateTime<chrono::Utc>> {
if value <= 0 {
return None;
}
let millis = if value < 10_000_000_000 {
value.checked_mul(1000)?
} else {
value
};
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(millis)
}
fn parse_extra_config_object(raw_extra_config: Option<&str>) -> AppResult<Map<String, Value>> {
let Some(raw) = raw_extra_config
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(Map::new());
};
let value = serde_json::from_str::<Value>(raw)
.map_err(|err| AppError::BadRequest(format!("Invalid extra_config JSON: {err}")))?;
value
.as_object()
.cloned()
.ok_or_else(|| AppError::BadRequest("extra_config must be a JSON object".into()))
}
fn oauth_proxy_settings(raw_extra_config: Option<&str>) -> (Option<String>, bool) {
let Ok(config) = parse_extra_config_object(raw_extra_config) else {
return (None, false);
};
let proxy_url = config
.get("proxyUrl")
.or_else(|| config.get("proxy_url"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let use_system_proxy = config
.get("useSystemProxy")
.and_then(Value::as_bool)
.or_else(|| config.get("use_system_proxy").and_then(Value::as_bool))
.unwrap_or(false);
(proxy_url, use_system_proxy)
}
fn merge_refreshed_extra_config(
raw_extra_config: Option<&str>,
refreshed: &RefreshedToken,
previous_refresh_token: &str,
) -> AppResult<Option<String>> {
let mut config = parse_extra_config_object(raw_extra_config)?;
config.insert(
"refreshed_at".to_string(),
json!(chrono::Utc::now().to_rfc3339()),
);
if let Some(expires_at) = refreshed.expires_at.as_deref() {
config.insert("expires_at".to_string(), json!(expires_at));
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(expires_at) {
config.insert(
"tokenExpiresAt".to_string(),
json!(parsed.timestamp_millis()),
);
}
}
config.insert(
"refreshToken".to_string(),
json!(refreshed
.refresh_token
.as_deref()
.unwrap_or(previous_refresh_token)),
);
if config.is_empty() {
Ok(None)
} else {
serde_json::to_string(&Value::Object(config))
.map(Some)
.map_err(|err| AppError::Internal(err.into()))
}
}
fn token_expired(extra_config: Option<&str>) -> bool { fn token_expired(extra_config: Option<&str>) -> bool {
let Some(extra_config) = extra_config else { token_expiry_datetime(extra_config)
return true;
};
let Ok(value) = serde_json::from_str::<Value>(extra_config) else {
return true;
};
let Some(expires_at) = value.get("expires_at").and_then(Value::as_str) else {
return true;
};
chrono::DateTime::parse_from_rfc3339(expires_at)
.map(|expires_at| expires_at <= chrono::Utc::now() + chrono::Duration::minutes(5)) .map(|expires_at| expires_at <= chrono::Utc::now() + chrono::Duration::minutes(5))
.unwrap_or(true) .unwrap_or(true)
} }
fn token_expiry(extra_config: Option<&str>) -> Option<String> { fn token_expiry(extra_config: Option<&str>) -> Option<String> {
extra_config token_expiry_datetime(extra_config).map(|expires_at| expires_at.to_rfc3339())
.and_then(|config| serde_json::from_str::<Value>(config).ok()) }
.and_then(|value| {
value fn token_expiry_datetime(extra_config: Option<&str>) -> Option<chrono::DateTime<chrono::Utc>> {
.get("expires_at") let value = extra_config.and_then(|config| serde_json::from_str::<Value>(config).ok())?;
.and_then(Value::as_str) if let Some(expires_at) = value
.map(str::to_string) .get("expires_at")
}) .or_else(|| value.get("expiresAt"))
.and_then(Value::as_str)
{
return chrono::DateTime::parse_from_rfc3339(expires_at)
.map(|value| value.with_timezone(&chrono::Utc))
.ok();
}
let millis = value
.get("tokenExpiresAt")
.or_else(|| value.get("token_expires_at"))
.and_then(expiry_datetime_from_value)?;
Some(millis)
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn test_pool() -> SqlitePool {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool");
sqlx::query(
"CREATE TABLE settings (
key TEXT PRIMARY KEY NOT NULL,
value TEXT
)",
)
.execute(&pool)
.await
.expect("create settings");
sqlx::query(
"CREATE TABLE sites (
id INTEGER PRIMARY KEY,
custom_headers TEXT
)",
)
.execute(&pool)
.await
.expect("create sites");
sqlx::query(
"CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
site_id INTEGER NOT NULL,
access_token TEXT NOT NULL,
api_token TEXT,
extra_config TEXT,
oauth_provider TEXT,
updated_at TEXT
)",
)
.execute(&pool)
.await
.expect("create accounts");
pool
}
#[tokio::test]
async fn refresh_token_preserves_extra_config_and_updates_refresh_token() {
let pool = test_pool().await;
let mock = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.and(body_string_contains("grant_type=refresh_token"))
.and(body_string_contains("refresh_token=refresh-old"))
.and(body_string_contains("client_id=client-a"))
.and(body_string_contains("client_secret=secret-a"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"access_token": "access-new",
"refresh_token": "refresh-new",
"expires_at": 1893456000_i64
})))
.mount(&mock)
.await;
sqlx::query("INSERT INTO sites (id, custom_headers) VALUES (1, ?)")
.bind(
json!({
"oauth_token_url": format!("{}/oauth/token", mock.uri()),
"oauth_client_id": "client-a",
"oauth_client_secret": "secret-a"
})
.to_string(),
)
.execute(&pool)
.await
.expect("insert site");
sqlx::query(
"INSERT INTO accounts (id, site_id, access_token, api_token, extra_config, oauth_provider)
VALUES (7, 1, 'access-old', 'refresh-old', ?, 'codex')",
)
.bind(
json!({
"credentialMode": "oauth",
"proxyUrl": "socks5://127.0.0.1:1080",
"expires_at": "2000-01-01T00:00:00Z"
})
.to_string(),
)
.execute(&pool)
.await
.expect("insert account");
let service = OAuthService::with_client(Arc::new(pool.clone()), reqwest::Client::new());
let refreshed = service.refresh_token(7, 1).await.expect("refresh token");
assert_eq!(refreshed.access_token, "access-new");
assert_eq!(refreshed.refresh_token.as_deref(), Some("refresh-new"));
assert!(refreshed.expires_at.is_some());
let row: (String, Option<String>, Option<String>) = sqlx::query_as(
"SELECT access_token, api_token, extra_config FROM accounts WHERE id = 7",
)
.fetch_one(&pool)
.await
.expect("account row");
assert_eq!(row.0, "access-new");
assert_eq!(row.1.as_deref(), Some("refresh-new"));
let extra: Value =
serde_json::from_str(row.2.as_deref().expect("extra config")).expect("extra json");
assert_eq!(
extra.get("credentialMode").and_then(Value::as_str),
Some("oauth")
);
assert_eq!(
extra.get("proxyUrl").and_then(Value::as_str),
Some("socks5://127.0.0.1:1080")
);
assert_eq!(
extra.get("refreshToken").and_then(Value::as_str),
Some("refresh-new")
);
assert_eq!(
extra.get("expires_at").and_then(Value::as_str),
Some("2030-01-01T00:00:00+00:00")
);
assert_eq!(
extra.get("tokenExpiresAt").and_then(Value::as_i64),
Some(1893456000000_i64)
);
}
#[test]
fn token_expiry_from_response_accepts_string_token_expires_at() {
assert_eq!(
token_expiry_from_response(&json!({ "tokenExpiresAt": "1893456000" })).as_deref(),
Some("2030-01-01T00:00:00+00:00")
);
assert_eq!(
token_expiry_from_response(&json!({ "token_expires_at": 1893456000000_i64 }))
.as_deref(),
Some("2030-01-01T00:00:00+00:00")
);
}
#[tokio::test]
async fn refresh_token_keeps_current_token_when_not_expired() {
let pool = test_pool().await;
sqlx::query("INSERT INTO sites (id, custom_headers) VALUES (1, '{}')")
.execute(&pool)
.await
.expect("insert site");
let future = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
sqlx::query(
"INSERT INTO accounts (id, site_id, access_token, api_token, extra_config, oauth_provider)
VALUES (7, 1, 'access-current', 'refresh-current', ?, 'codex')",
)
.bind(json!({ "expires_at": future }).to_string())
.execute(&pool)
.await
.expect("insert account");
let service = OAuthService::with_client(Arc::new(pool), reqwest::Client::new());
let refreshed = service.refresh_token(7, 1).await.expect("current token");
assert_eq!(refreshed.access_token, "access-current");
assert_eq!(refreshed.refresh_token.as_deref(), Some("refresh-current"));
}
#[tokio::test]
async fn refresh_token_keeps_current_token_when_token_expires_at_is_future() {
let pool = test_pool().await;
sqlx::query("INSERT INTO sites (id, custom_headers) VALUES (1, '{}')")
.execute(&pool)
.await
.expect("insert site");
let future_millis = (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp_millis();
sqlx::query(
"INSERT INTO accounts (id, site_id, access_token, api_token, extra_config, oauth_provider)
VALUES (7, 1, 'access-current', 'refresh-current', ?, 'codex')",
)
.bind(json!({ "tokenExpiresAt": future_millis }).to_string())
.execute(&pool)
.await
.expect("insert account");
let service = OAuthService::with_client(Arc::new(pool), reqwest::Client::new());
let refreshed = service.refresh_token(7, 1).await.expect("current token");
assert_eq!(refreshed.access_token, "access-current");
assert_eq!(refreshed.refresh_token.as_deref(), Some("refresh-current"));
assert!(refreshed
.expires_at
.as_deref()
.is_some_and(|value| value.contains('T')));
}
#[tokio::test]
async fn system_proxy_url_reads_trimmed_runtime_setting() {
let pool = test_pool().await;
assert_eq!(system_proxy_url(&pool).await.unwrap(), None);
sqlx::query("INSERT INTO settings (key, value) VALUES (?, ?)")
.bind(SYSTEM_PROXY_URL_SETTING_KEY)
.bind(" http://127.0.0.1:7890 ")
.execute(&pool)
.await
.expect("insert system proxy");
assert_eq!(
system_proxy_url(&pool).await.unwrap().as_deref(),
Some("http://127.0.0.1:7890")
);
}
} }
File diff suppressed because it is too large Load Diff
+114 -6
View File
@@ -51,13 +51,16 @@ pub struct RebuildSummary {
struct AvailRow { struct AvailRow {
account_id: i64, account_id: i64,
model_name: String, model_name: String,
oauth_route_unit_id: Option<i64>,
} }
/// Run the rebuild against the live database. The whole thing runs inside a /// Run the rebuild against the live database. The whole thing runs inside a
/// single transaction so a partial failure doesn't leave `token_routes` and /// single transaction so a partial failure doesn't leave `token_routes` and
/// `route_channels` out of sync. /// `route_channels` out of sync.
pub async fn rebuild_routes(state: &AppState) -> AppResult<RebuildSummary> { pub async fn rebuild_routes(state: &AppState) -> AppResult<RebuildSummary> {
rebuild_routes_inner(&state.db).await let summary = rebuild_routes_inner(&state.db).await?;
crate::proxy::token_router::router::invalidate_route_cache();
Ok(summary)
} }
/// Pool-only entry point. Identical to `rebuild_routes`, just decoupled from /// Pool-only entry point. Identical to `rebuild_routes`, just decoupled from
@@ -73,10 +76,11 @@ pub(crate) async fn rebuild_routes_inner(
// lets us drop inactive accounts and apply site_disabled_models in one // lets us drop inactive accounts and apply site_disabled_models in one
// query — keeping the in-memory set small for installs with hundreds of // query — keeping the in-memory set small for installs with hundreds of
// accounts. // accounts.
let rows: Vec<(i64, String)> = sqlx::query_as( let rows: Vec<(i64, String, Option<i64>)> = sqlx::query_as(
"SELECT ma.account_id, ma.model_name "SELECT ma.account_id, ma.model_name, rum.unit_id
FROM model_availability ma FROM model_availability ma
JOIN accounts a ON a.id = ma.account_id JOIN accounts a ON a.id = ma.account_id
LEFT JOIN oauth_route_unit_members rum ON rum.account_id = a.id
WHERE COALESCE(ma.available, 0) = 1 WHERE COALESCE(ma.available, 0) = 1
AND a.status = 'active' AND a.status = 'active'
AND NOT EXISTS ( AND NOT EXISTS (
@@ -91,9 +95,10 @@ pub(crate) async fn rebuild_routes_inner(
let avail: Vec<AvailRow> = rows let avail: Vec<AvailRow> = rows
.into_iter() .into_iter()
.map(|(account_id, model_name)| AvailRow { .map(|(account_id, model_name, oauth_route_unit_id)| AvailRow {
account_id, account_id,
model_name, model_name,
oauth_route_unit_id,
}) })
.collect(); .collect();
@@ -110,11 +115,16 @@ pub(crate) async fn rebuild_routes_inner(
// 2. Group by model name. Each group becomes one token_routes row. // 2. Group by model name. Each group becomes one token_routes row.
let mut by_model: HashMap<String, Vec<i64>> = HashMap::new(); let mut by_model: HashMap<String, Vec<i64>> = HashMap::new();
let mut route_unit_by_key: HashMap<(i64, String), Option<i64>> = HashMap::new();
for row in &avail { for row in &avail {
by_model by_model
.entry(row.model_name.clone()) .entry(row.model_name.clone())
.or_default() .or_default()
.push(row.account_id); .push(row.account_id);
route_unit_by_key.insert(
(row.account_id, row.model_name.clone()),
row.oauth_route_unit_id,
);
} }
// 3. Pre-load existing routes by model_pattern so we can reuse rows. The // 3. Pre-load existing routes by model_pattern so we can reuse rows. The
@@ -209,21 +219,41 @@ pub(crate) async fn rebuild_routes_inner(
.fetch_optional(&mut *tx) .fetch_optional(&mut *tx)
.await?; .await?;
let oauth_route_unit_id = route_unit_by_key.get(key).copied().flatten();
sqlx::query( sqlx::query(
"INSERT INTO route_channels "INSERT INTO route_channels
(route_id, account_id, token_id, source_model, (route_id, account_id, token_id, source_model,
priority, weight, enabled, manual_override) oauth_route_unit_id, priority, weight, enabled, manual_override)
VALUES (?, ?, ?, ?, 0, 10, 1, 0)", VALUES (?, ?, ?, ?, ?, 0, 10, 1, 0)",
) )
.bind(route_id) .bind(route_id)
.bind(key.0) .bind(key.0)
.bind(token_id) .bind(token_id)
.bind(&key.1) .bind(&key.1)
.bind(oauth_route_unit_id)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
summary.created_channels += 1; summary.created_channels += 1;
} }
// Keep OAuth route-unit annotations in sync for existing auto-managed channels.
for (key, ch_id) in &auto_by_key {
if !desired.contains(key) {
continue;
}
let oauth_route_unit_id = route_unit_by_key.get(key).copied().flatten();
sqlx::query(
"UPDATE route_channels
SET oauth_route_unit_id = ?
WHERE id = ?
AND COALESCE(manual_override, 0) = 0",
)
.bind(oauth_route_unit_id)
.bind(ch_id)
.execute(&mut *tx)
.await?;
}
// Delete auto channels that are no longer desired (account dropped the // Delete auto channels that are no longer desired (account dropped the
// model, or the model is now in site_disabled_models). // model, or the model is now in site_disabled_models).
for (key, ch_id) in &auto_by_key { for (key, ch_id) in &auto_by_key {
@@ -377,6 +407,7 @@ mod tests {
route_id INTEGER NOT NULL, route_id INTEGER NOT NULL,
account_id INTEGER NOT NULL, account_id INTEGER NOT NULL,
token_id INTEGER, token_id INTEGER,
oauth_route_unit_id INTEGER,
source_model TEXT, source_model TEXT,
priority INTEGER DEFAULT 0, priority INTEGER DEFAULT 0,
weight INTEGER DEFAULT 10, weight INTEGER DEFAULT 10,
@@ -389,6 +420,20 @@ mod tests {
enabled INTEGER DEFAULT 1, enabled INTEGER DEFAULT 1,
is_default INTEGER DEFAULT 0 is_default INTEGER DEFAULT 0
)", )",
"CREATE TABLE oauth_route_units (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL,
provider TEXT NOT NULL,
name TEXT NOT NULL,
strategy TEXT NOT NULL DEFAULT 'round_robin',
enabled INTEGER DEFAULT 1
)",
"CREATE TABLE oauth_route_unit_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
unit_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
sort_order INTEGER DEFAULT 0
)",
] { ] {
sqlx::query(ddl) sqlx::query(ddl)
.execute(&pool) .execute(&pool)
@@ -522,6 +567,69 @@ mod tests {
assert_eq!(route_count, 1); assert_eq!(route_count, 1);
} }
#[tokio::test]
async fn rebuild_marks_auto_channels_with_oauth_route_unit_id() {
let pool = fixture_pool().await;
sqlx::query(
"INSERT INTO accounts (id, site_id, status) VALUES
(1, 1, 'active'),
(2, 1, 'active')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO model_availability (account_id, model_name, available) VALUES
(1, 'gpt-oauth', 1),
(2, 'gpt-oauth', 1)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO oauth_route_units (id, site_id, provider, name, strategy)
VALUES (9, 1, 'codex', 'Codex Pool', 'round_robin')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO oauth_route_unit_members (unit_id, account_id, sort_order) VALUES
(9, 1, 0),
(9, 2, 1)",
)
.execute(&pool)
.await
.unwrap();
let summary = rebuild_routes_inner(&pool).await.expect("rebuild");
assert_eq!(summary.created_channels, 2);
let unit_ids: Vec<Option<i64>> = sqlx::query_scalar(
"SELECT oauth_route_unit_id FROM route_channels ORDER BY account_id",
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(unit_ids, vec![Some(9), Some(9)]);
sqlx::query("DELETE FROM oauth_route_unit_members WHERE unit_id = 9")
.execute(&pool)
.await
.unwrap();
let summary = rebuild_routes_inner(&pool).await.expect("second rebuild");
assert_eq!(summary.created_channels, 0);
let unit_ids: Vec<Option<i64>> = sqlx::query_scalar(
"SELECT oauth_route_unit_id FROM route_channels ORDER BY account_id",
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(unit_ids, vec![None, None]);
}
/// User-reported bug: empty DB still shows hundreds of routes on the /// User-reported bug: empty DB still shows hundreds of routes on the
/// `/routes` page, and clicking "rebuild" does nothing. Reproduces by /// `/routes` page, and clicking "rebuild" does nothing. Reproduces by
/// pre-seeding `token_routes` rows that no account / availability row /// pre-seeding `token_routes` rows that no account / availability row
+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"));
}
}
+10 -1
View File
@@ -21,8 +21,17 @@ pub async fn enforce_key_request_limit(
return Err(AppError::Forbidden("API key is disabled".into())); return Err(AppError::Forbidden("API key is disabled".into()));
} }
if let Some(max_requests) = max_requests { if let Some(max_requests) = max_requests {
if max_requests <= 0 {
return Err(AppError::RateLimitedWithRetry {
message: "下游密钥请求额度已用完".into(),
retry_after_secs: 60,
});
}
if used_requests.unwrap_or(0) + increment > max_requests { if used_requests.unwrap_or(0) + increment > max_requests {
return Err(AppError::RateLimited); return Err(AppError::RateLimitedWithRetry {
message: "下游密钥请求额度已用完".into(),
retry_after_secs: 60,
});
} }
} }
Ok(()) Ok(())
-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) .execute(&*state.db)
.await?; .await?;
if should_project_workspace_asset(status, &metadata) {
if let Err(err) = upsert_workspace_asset_daily(
state,
None,
input.user_id,
&input.source,
&input.kind,
size_bytes,
file_path.is_some(),
1,
)
.await
{
tracing::warn!(%err, "failed to update workspace asset projection");
}
}
Ok(StoredWorkspaceAsset { Ok(StoredWorkspaceAsset {
public_id: public_id.to_string(), public_id: public_id.to_string(),
file_name, file_name,
@@ -169,6 +186,51 @@ async fn insert_asset(
}) })
} }
fn should_project_workspace_asset(status: &str, metadata: &Value) -> bool {
status == "completed"
&& (metadata.pointer("/workspaceAsset/role").and_then(Value::as_str) == Some("output")
|| metadata.pointer("/workflowUi/type").and_then(Value::as_str) == Some("output")
|| metadata.get("responseItem").is_some()
|| metadata.get("moneyPrinter").is_some()
|| metadata.get("digitalHuman").is_some())
}
async fn upsert_workspace_asset_daily(
state: &AppState,
local_day: Option<&str>,
user_id: Option<i64>,
source: &str,
kind: &str,
size_bytes: i64,
has_local_file: bool,
delta: i64,
) -> Result<(), AppError> {
let asset_count_delta = delta;
let local_file_delta = if has_local_file { delta } else { 0 };
let size_delta = size_bytes.max(0) * delta;
sqlx::query(
"INSERT INTO workspace_asset_daily
(local_day, user_id, source, kind, asset_count, local_file_count, total_size_bytes, updated_at)
VALUES (COALESCE(?, date('now', 'localtime')), ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(local_day, user_id, source, kind) DO UPDATE SET
asset_count = MAX(0, asset_count + excluded.asset_count),
local_file_count = MAX(0, local_file_count + excluded.local_file_count),
total_size_bytes = MAX(0, total_size_bytes + excluded.total_size_bytes),
updated_at = datetime('now')",
)
.bind(local_day)
.bind(user_id.unwrap_or(0))
.bind(source)
.bind(kind)
.bind(asset_count_delta)
.bind(local_file_delta)
.bind(size_delta)
.execute(&*state.db)
.await?;
Ok(())
}
pub async fn record_remote_asset( pub async fn record_remote_asset(
state: &AppState, state: &AppState,
input: WorkspaceAssetInput, input: WorkspaceAssetInput,
@@ -188,11 +250,7 @@ pub async fn record_remote_asset(
.await; .await;
}; };
let client = reqwest::Client::builder() match state.http.get(url).send().await {
.timeout(std::time::Duration::from_secs(60))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
match client.get(url).send().await {
Ok(response) if response.status().is_success() => { Ok(response) if response.status().is_success() => {
let mime_type = response let mime_type = response
.headers() .headers()
@@ -307,6 +365,45 @@ pub async fn record_bytes_asset(
.await .await
} }
pub async fn project_deleted_asset(state: &AppState, public_id: &str) -> Result<(), AppError> {
use sqlx::Row;
let row = sqlx::query(
"SELECT user_id, source, kind, status, metadata, size_bytes, file_path, date(created_at, 'localtime') AS local_day
FROM workspace_assets
WHERE public_id = ?
LIMIT 1",
)
.bind(public_id)
.fetch_optional(&*state.db)
.await?;
let Some(row) = row else {
return Ok(());
};
let status: String = row.try_get("status").unwrap_or_default();
let metadata_raw: String = row.try_get("metadata").unwrap_or_else(|_| "{}".to_string());
let metadata = serde_json::from_str::<Value>(&metadata_raw).unwrap_or_else(|_| json!({}));
if !should_project_workspace_asset(&status, &metadata) {
return Ok(());
}
let source: String = row.try_get("source").unwrap_or_default();
let kind: String = row.try_get("kind").unwrap_or_default();
let user_id: Option<i64> = row.try_get("user_id").ok().flatten();
let size_bytes: i64 = row.try_get("size_bytes").unwrap_or_default();
let file_path: Option<String> = row.try_get("file_path").ok().flatten();
let local_day: Option<String> = row.try_get("local_day").ok().flatten();
upsert_workspace_asset_daily(
state,
local_day.as_deref(),
user_id,
&source,
&kind,
size_bytes,
file_path.as_deref().is_some_and(|value| !value.trim().is_empty()),
-1,
)
.await
}
pub async fn record_image_assets_from_response( pub async fn record_image_assets_from_response(
state: &AppState, state: &AppState,
user_id: Option<i64>, user_id: Option<i64>,
@@ -338,7 +435,10 @@ pub async fn record_image_assets_from_response(
prompt: prompt.clone(), prompt: prompt.clone(),
model: model.clone(), model: model.clone(),
remote_url: Some(url.trim().to_string()), remote_url: Some(url.trim().to_string()),
metadata: json!({ "responseItem": item }), metadata: json!({
"workspaceAsset": { "role": "output" },
"responseItem": item,
}),
}; };
match record_remote_asset(state, input).await { match record_remote_asset(state, input).await {
Ok(asset) => stored_assets.push(asset), Ok(asset) => stored_assets.push(asset),
@@ -357,7 +457,10 @@ pub async fn record_image_assets_from_response(
prompt: prompt.clone(), prompt: prompt.clone(),
model: model.clone(), model: model.clone(),
remote_url: None, remote_url: None,
metadata: json!({ "responseItem": item }), metadata: json!({
"workspaceAsset": { "role": "output" },
"responseItem": item,
}),
}; };
match record_base64_asset(state, input, b64).await { match record_base64_asset(state, input, b64).await {
Ok(asset) => stored_assets.push(asset), Ok(asset) => stored_assets.push(asset),
-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;
}
+38
View File
@@ -0,0 +1,38 @@
use crate::config::AppConfig;
use reqwest::{Client, Proxy};
use std::time::Duration;
pub fn client(config: &AppConfig, timeout_secs: u64) -> Client {
let mut builder = Client::builder().timeout(Duration::from_secs(timeout_secs));
if let Some(proxy_url) = template_proxy_url(config) {
match Proxy::all(&proxy_url) {
Ok(proxy) => {
builder = builder.proxy(proxy);
}
Err(err) => {
tracing::warn!(%err, %proxy_url, "invalid template proxy url; falling back to direct template request");
}
}
}
builder.build().unwrap_or_else(|_| Client::new())
}
fn template_proxy_url(config: &AppConfig) -> Option<String> {
config
.proxy
.template_proxy_url
.as_deref()
.and_then(non_empty)
.map(str::to_string)
.or_else(|| std::env::var("BOOSAPI_TEMPLATE_PROXY_URL").ok().and_then(|value| non_empty_owned(value)))
}
fn non_empty(value: &str) -> Option<&str> {
let value = value.trim();
(!value.is_empty()).then_some(value)
}
fn non_empty_owned(value: String) -> Option<String> {
let value = value.trim().to_string();
(!value.is_empty()).then_some(value)
}
+453 -10
View File
@@ -1,7 +1,18 @@
import { describe, expect, it, vi } from 'vitest'; import { act } from 'react';
import { create, type ReactTestInstance } from 'react-test-renderer'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom'; import { create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { clearSession, commitSession, getSessionToken } from './authSession.js';
import App from './App.js';
import AdminLoginPage from './pages/auth/AdminLoginPage.js'; import AdminLoginPage from './pages/auth/AdminLoginPage.js';
import UserLoginPage from './pages/auth/UserLoginPage.js';
import UserRegister from './pages/UserRegister.js';
vi.mock('./pages/Dashboard.js', () => ({
default: () => <div>Dashboard Page</div>,
}));
function collectText(node: ReactTestInstance): string { function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => { return (node.children || []).map((child) => {
@@ -10,6 +21,77 @@ function collectText(node: ReactTestInstance): string {
}).join(''); }).join('');
} }
function createMemoryStorage() {
const store = new Map<string, string>();
return {
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, value);
},
removeItem(key: string) {
store.delete(key);
},
};
}
function unsignedJwt(payload: Record<string, unknown>): string {
const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf8')
.toString('base64url');
return `test.${encodedPayload}.signature`;
}
function PathProbe({ onPath }: { onPath: (path: string) => void }) {
const location = useLocation();
onPath(location.pathname);
return null;
}
function stubAppRuntime(storage: ReturnType<typeof createMemoryStorage>) {
const matchMedia = (query: string) => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
onchange: null,
});
const documentElement = {
setAttribute: vi.fn(),
getAttribute: vi.fn(() => null),
};
vi.stubGlobal('localStorage', storage);
vi.stubGlobal('window', {
innerWidth: 1280,
matchMedia,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('document', {
body: { style: {}, dataset: {} },
documentElement,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
getElementById: vi.fn(() => null),
});
vi.stubGlobal('Node', { TEXT_NODE: 3, ELEMENT_NODE: 1 });
vi.stubGlobal('MutationObserver', class {
observe = vi.fn();
disconnect = vi.fn();
takeRecords = vi.fn(() => []);
});
}
afterEach(() => {
clearSession('admin');
clearSession('user');
vi.unstubAllGlobals();
});
describe('Login surface', () => { describe('Login surface', () => {
it('renders a poster-style hero with a floating admin login panel', () => { it('renders a poster-style hero with a floating admin login panel', () => {
const root = create( const root = create(
@@ -36,13 +118,13 @@ describe('Login surface', () => {
&& node.props.className.includes('brand-mark-canvas') && node.props.className.includes('brand-mark-canvas')
)); ));
expect(pageText).toContain('BoosAPI'); expect(pageText).toContain('BossAI');
expect(pageText).toContain('中转站的中转站'); expect(pageText).toContain('AI 应用平台');
expect(pageText).not.toContain('一个 API Key,一个入口'); expect(pageText).not.toContain('一个 API Key,一个入口');
expect(pageText).toContain('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API'); expect(pageText).toContain('兼容 OpenAI / Claude Messages / Comfy Cloud / RunningHub / 火山方舟等多类上游');
expect(pageText).toContain('统一代理网关'); expect(pageText).toContain('统一 API 网关');
expect(pageText).toContain('智能路由引擎'); expect(pageText).toContain('智能路由引擎');
expect(pageText).toContain('自动模型发现'); expect(pageText).toContain('工作流生产台');
expect(lightBrandPanel).toBeTruthy(); expect(lightBrandPanel).toBeTruthy();
expect(authStage).toBeTruthy(); expect(authStage).toBeTruthy();
expect(brandMarkCanvas).toBeTruthy(); expect(brandMarkCanvas).toBeTruthy();
@@ -50,15 +132,376 @@ describe('Login surface', () => {
node.type === 'input' node.type === 'input'
&& node.props.placeholder === '管理员令牌' && node.props.placeholder === '管理员令牌'
)); ));
expect(tokenInput.props.type).toBe('password');
expect(root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('login-mode-tab')
))).toHaveLength(0);
expect(pageText).toContain('仅允许使用管理员 API Token 进入控制台。');
expect(pageText).toContain('管理员后台不接受账号密码或用户 API Key 登录。');
expect(root.root.findAllByProps({ id: 'admin-email-input' })).toHaveLength(0);
expect(root.root.findAllByProps({ id: 'admin-password-input' })).toHaveLength(0);
expect(root.root.findAllByProps({ id: 'admin-api-key-input' })).toHaveLength(0);
const form = root.root.findByProps({ id: 'admin-login-panel-token' });
expect(form.props.onSubmit).toBeTypeOf('function');
} finally {
root?.unmount();
}
});
it('only accepts the admin token on /boos and clears stale user session', async () => {
const storage = createMemoryStorage();
stubAppRuntime(storage);
commitSession('user', 'stale-user-jwt', undefined, storage);
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
text: async () => JSON.stringify({ enabled: true, mode: 'admin-token' }),
});
vi.stubGlobal('fetch', fetchMock);
const paths: string[] = [];
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/boos']}>
<App />
<PathProbe onPath={(path) => paths.push(path)} />
</MemoryRouter>,
);
});
await act(async () => {
root.root.findByProps({ id: 'admin-token-input' }).props.onChange({
target: { value: 'admin-config-token' },
});
});
await act(async () => {
await root.root.findByProps({ id: 'admin-login-panel-token' }).props.onSubmit({
preventDefault: vi.fn(),
});
});
expect(fetchMock).toHaveBeenCalledWith('/api/settings/auth/info', expect.objectContaining({
headers: { Authorization: 'Bearer admin-config-token' },
}));
expect(fetchMock).not.toHaveBeenCalledWith('/api/auth/login-api-key', expect.anything());
expect(fetchMock).not.toHaveBeenCalledWith('/api/auth/login', expect.anything());
expect(getSessionToken('admin', storage)).toBe('admin-config-token');
expect(getSessionToken('user', storage)).toBeNull();
expect(paths.at(-1)).toBe('/dashboard');
} finally {
act(() => {
root?.unmount();
});
}
});
it('does not expose account password or API Key controls on the admin login page', async () => {
const storage = createMemoryStorage();
vi.stubGlobal('localStorage', storage);
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const onLogin = vi.fn();
const root = create(
<MemoryRouter>
<AdminLoginPage t={(text) => text} onLogin={onLogin} />
</MemoryRouter>,
);
try {
expect(root.root.findAllByProps({ id: 'admin-email-input' })).toHaveLength(0);
expect(root.root.findAllByProps({ id: 'admin-password-input' })).toHaveLength(0);
expect(root.root.findAllByProps({ id: 'admin-api-key-input' })).toHaveLength(0);
expect(collectText(root.root)).not.toContain('该 API Key 不属于管理员账号。');
expect(fetchMock).not.toHaveBeenCalled();
expect(onLogin).not.toHaveBeenCalled();
expect(getSessionToken('admin', storage)).toBeNull();
} finally {
root?.unmount();
}
});
it('connects user login mode tabs to the active login form panel', () => {
const root = create(
<MemoryRouter>
<UserLoginPage t={(text) => text} />
</MemoryRouter>,
);
try {
const loginModeTabs = root.root.findAll((node) => ( const loginModeTabs = root.root.findAll((node) => (
node.type === 'button' node.type === 'button'
&& typeof node.props.className === 'string' && typeof node.props.className === 'string'
&& node.props.className.includes('login-mode-tab') && node.props.className.includes('login-mode-tab')
)); ));
expect(tokenInput.props.type).toBe('password'); expect(loginModeTabs.map((node) => collectText(node))).toEqual(['邮箱密码', 'API Key']);
expect(loginModeTabs.map((node) => collectText(node))).toEqual(['Auth Token', '账号密码']); expect(loginModeTabs.every((node) => node.props.role === 'tab')).toBe(true);
expect(loginModeTabs[0].props['aria-selected']).toBe(true);
expect(loginModeTabs[0].props['aria-controls']).toBe('user-login-panel-account');
const activePanel = root.root.find((node) => node.props.role === 'tabpanel');
expect(activePanel.props.id).toBe('user-login-panel-account');
expect(activePanel.props['aria-labelledby']).toBe('user-login-tab-account');
} finally { } finally {
root?.unmount(); root?.unmount();
} }
}); });
it('rejects admin API Key login on the user login page', async () => {
const storage = createMemoryStorage();
vi.stubGlobal('localStorage', storage);
commitSession('user', 'stale-user-jwt', undefined, storage);
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
token: 'jwt-admin-from-key',
user: {
role: 'admin',
},
}),
});
vi.stubGlobal('fetch', fetchMock);
const onLogin = vi.fn();
const paths: string[] = [];
const root = create(
<MemoryRouter initialEntries={['/user/login']}>
<UserLoginPage t={(text) => text} onLogin={onLogin} />
<PathProbe onPath={(path) => paths.push(path)} />
</MemoryRouter>,
);
try {
const apiKeyTab = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('login-mode-tab')
))[1];
await act(async () => {
apiKeyTab.props.onClick();
});
const input = root.root.findByProps({ id: 'user-api-key-input' });
await act(async () => {
input.props.onChange({ target: { value: 'sk-admin-user-key' } });
});
const form = root.root.findByProps({ id: 'user-login-panel-apiKey' });
await act(async () => {
await form.props.onSubmit({ preventDefault: vi.fn() });
});
expect(fetchMock).toHaveBeenCalledWith('/api/users/login-api-key', expect.objectContaining({
method: 'POST',
body: JSON.stringify({ apiKey: 'sk-admin-user-key' }),
}));
expect(getSessionToken('admin', storage)).toBeNull();
expect(getSessionToken('user', storage)).toBe('stale-user-jwt');
expect(onLogin).not.toHaveBeenCalled();
expect(collectText(root.root)).toContain('管理员请使用管理员 API Token 登录。');
expect(paths.at(-1)).toBe('/user/login');
} finally {
root?.unmount();
}
});
it('rejects API Key login when JWT role falls back to admin', async () => {
const storage = createMemoryStorage();
vi.stubGlobal('localStorage', storage);
const adminJwt = unsignedJwt({ sub: '5', role: 'admin', exp: 9_999_999_999 });
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
token: adminJwt,
user: {
id: 5,
email: 'admin@example.test',
},
}),
});
vi.stubGlobal('fetch', fetchMock);
const onLogin = vi.fn();
const paths: string[] = [];
const root = create(
<MemoryRouter initialEntries={['/user/login']}>
<UserLoginPage t={(text) => text} onLogin={onLogin} />
<PathProbe onPath={(path) => paths.push(path)} />
</MemoryRouter>,
);
try {
const apiKeyTab = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('login-mode-tab')
))[1];
await act(async () => {
apiKeyTab.props.onClick();
});
await act(async () => {
root.root.findByProps({ id: 'user-api-key-input' }).props.onChange({
target: { value: 'sk-admin-bound' },
});
});
await act(async () => {
await root.root.findByProps({ id: 'user-login-panel-apiKey' }).props.onSubmit({
preventDefault: vi.fn(),
});
});
expect(getSessionToken('admin', storage)).toBeNull();
expect(getSessionToken('user', storage)).toBeNull();
expect(onLogin).not.toHaveBeenCalled();
expect(collectText(root.root)).toContain('管理员请使用管理员 API Token 登录。');
expect(paths.at(-1)).toBe('/user/login');
} finally {
root?.unmount();
}
});
it('prefers the admin dashboard on public user auth pages when both sessions exist', () => {
const storage = createMemoryStorage();
stubAppRuntime(storage);
commitSession('admin', 'jwt-admin', undefined, storage);
commitSession('user', 'jwt-user-stale', undefined, storage);
vi.stubGlobal('fetch', vi.fn());
const paths: string[] = [];
let root!: ReactTestRenderer;
try {
act(() => {
root = create(
<MemoryRouter initialEntries={['/user/login']}>
<App />
<PathProbe onPath={(path) => paths.push(path)} />
</MemoryRouter>,
);
});
expect(paths.at(-1)).toBe('/dashboard');
} finally {
act(() => {
root?.unmount();
});
}
});
it('renders OAuth provider entries as configured placeholders on user login', () => {
const root = create(
<MemoryRouter>
<UserLoginPage t={(text) => text} />
</MemoryRouter>,
);
try {
const providers = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('login-oauth-provider')
));
expect(providers.map((node) => node.props['data-provider'])).toEqual(['google', 'github', 'wechat']);
expect(providers.every((node) => node.props.disabled === true)).toBe(true);
expect(collectText(root.root)).toContain('待配置');
const wechatProvider = providers.find((node) => node.props['data-provider'] === 'wechat');
const wechatIcons = wechatProvider?.findAll((node) => (
node.type === 'svg'
&& typeof node.props.className === 'string'
&& node.props.className.includes('login-oauth-wechat-glyph')
));
expect(wechatIcons).toHaveLength(1);
expect(wechatIcons?.[0]?.props.width).toBe('30');
expect(wechatIcons?.[0]?.props.height).toBe('30');
expect(wechatIcons?.[0]?.props.viewBox).toBe('0 0 64 64');
expect(wechatProvider?.findAll((node) => node.type === 'img')).toHaveLength(0);
} finally {
root?.unmount();
}
});
it('uses a complete local WeChat glyph on the register OAuth placeholder', () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: {
id: 'captcha-test',
image: 'data:image/png;base64,test',
},
}),
});
const originalFetch = globalThis.fetch;
vi.stubGlobal('fetch', fetchMock);
const root = create(
<MemoryRouter>
<UserRegister />
</MemoryRouter>,
);
try {
const oauthSlot = root.root.find((node) => node.props['data-testid'] === 'oauth-slot');
const wechatProvider = oauthSlot.find((node) => (
node.type === 'button'
&& node.props['data-provider'] === 'wechat'
));
const wechatIcon = wechatProvider.find((node) => (
node.type === 'svg'
&& typeof node.props.className === 'string'
&& node.props.className.includes('login-oauth-wechat-glyph')
));
const vectorParts = wechatIcon.findAll((node) => node.type === 'path' || node.type === 'circle');
expect(oauthSlot.props['data-mode']).toBe('register');
expect(wechatProvider.props.disabled).toBe(true);
expect(wechatProvider.props.onClick).toBeUndefined();
expect(wechatIcon.props.width).toBe('30');
expect(wechatIcon.props.height).toBe('30');
expect(wechatIcon.props.viewBox).toBe('0 0 64 64');
expect(wechatIcon.props['aria-hidden']).toBe('true');
expect(vectorParts).toHaveLength(6);
expect(wechatProvider.findAll((node) => node.type === 'img')).toHaveLength(0);
} finally {
root?.unmount();
vi.stubGlobal('fetch', originalFetch);
}
});
it('keeps the local WeChat OAuth mark unclipped in CSS', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(css).toContain('.login-oauth-provider[data-provider="wechat"] .login-oauth-provider-mark {\n');
expect(css).toContain(' overflow: visible;\n box-shadow:\n inset 0 1px 0 rgba(255, 255, 255, 0.72),');
expect(css).toContain('.login-oauth-wechat-glyph {\n display: block;\n width: 30px;\n height: 30px;\n flex: 0 0 30px;\n overflow: visible;\n}');
});
it('keeps login brand panels bounded on short and mobile viewports', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(css).not.toContain('min-height: 650px;');
expect(css).not.toContain('min-height: 580px;');
expect(css).toContain('.login-brand-panel {\n min-height: min(650px, calc(100dvh - 92px));');
expect(css).toContain('.login-shell[data-variant="user"] .login-brand-panel {\n min-height: min(580px, calc(100dvh - 92px));');
expect(css).toContain('@media (max-height: 760px) and (min-width: 1181px) {');
expect(css).toContain('.login-brand-panel,\n .login-shell[data-variant="user"] .login-brand-panel {\n min-height: auto;');
expect(css).toContain('@media (max-width: 1180px) {');
expect(css).toContain('.login-shell[data-variant="user"] .login-brand-panel {\n min-height: auto;');
});
it('keeps admin and user login columns shrink-safe before the single-column breakpoint', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(css).toContain('grid-template-columns: minmax(min(620px, 100%), 1fr) minmax(min(360px, 100%), 420px);');
expect(css).toContain('grid-template-columns: minmax(min(520px, 100%), 1.08fr) minmax(min(380px, 100%), 0.92fr);');
expect(css).toContain('grid-template-columns: minmax(min(440px, 100%), 0.95fr) minmax(min(380px, 100%), 1.05fr);');
expect(css).not.toContain('grid-template-columns: minmax(620px, 1fr) minmax(360px, 420px);');
expect(css).not.toContain('grid-template-columns: minmax(520px, 1.08fr) minmax(380px, 0.92fr);');
expect(css).not.toContain('grid-template-columns: minmax(440px, 0.95fr) minmax(380px, 1.05fr);');
});
}); });
+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: '令牌管理'"); expect(source).not.toContain("{ to: '/tokens', label: '令牌管理'");
}); });
it('places downstream key navigation under 控制台 instead of 系统', () => { it('places downstream key navigation under 接入管理 instead of 系统', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8'); const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
const consoleGroupIndex = source.indexOf("label: '控制台'"); const accessGroupIndex = source.indexOf("label: '接入管理'");
const downstreamIndex = source.indexOf("{ to: '/downstream-keys', label: '下游密钥'"); const downstreamIndex = source.indexOf("{ to: '/downstream-keys', label: '下游密钥'");
const systemGroupIndex = source.indexOf("label: '系统'"); const systemGroupIndex = source.indexOf("label: '系统'");
expect(consoleGroupIndex).toBeGreaterThanOrEqual(0); expect(accessGroupIndex).toBeGreaterThanOrEqual(0);
expect(downstreamIndex).toBeGreaterThan(consoleGroupIndex); expect(downstreamIndex).toBeGreaterThan(accessGroupIndex);
expect(systemGroupIndex).toBeGreaterThan(downstreamIndex); expect(systemGroupIndex).toBeGreaterThan(downstreamIndex);
}); });
it('keeps access navigation flat and non-collapsible', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
const accessGroupIndex = source.indexOf("label: '接入管理'");
const creativeGroupIndex = source.indexOf("label: '创作应用'");
const accessGroupSource = source.slice(accessGroupIndex, creativeGroupIndex);
expect(accessGroupIndex).toBeGreaterThanOrEqual(0);
expect(source).not.toContain("label: '模型接入'");
expect(source).not.toContain("label: '渠道管理'");
expect(source).not.toContain("label: '接入配置'");
expect(source).not.toContain("label: '接入与路由'");
expect(accessGroupSource).not.toContain('children:');
expect(accessGroupSource).not.toContain('collapsed');
expect(accessGroupSource).not.toContain('expanded');
expect(accessGroupSource).not.toContain('aria-expanded');
expect(accessGroupSource).toContain("{ to: '/sites', label: '站点管理'");
expect(accessGroupSource).toContain("{ to: '/routes', label: '路由管理'");
expect(accessGroupSource).toContain("{ to: '/ip-management', label: 'IP 管理'");
});
it('adds an accessible collapsible desktop sidebar', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
expect(source).toContain("const BOOSAPI_SIDEBAR_COLLAPSED_KEY = 'boosapi.sidebar.collapsed';");
expect(source).toContain('function resolveStoredSidebarCollapsed(): boolean {');
expect(source).toContain('const [sidebarCollapsed, setSidebarCollapsed] = useState(() => resolveStoredSidebarCollapsed());');
expect(source).toContain('className={`app-layout ${sidebarCollapsed ? \'sidebar-is-collapsed\' : \'\'}`.trim()}');
expect(source).toContain('<aside ref={sidebarRef} className="sidebar" data-collapsed={sidebarCollapsed ? \'true\' : \'false\'}>');
expect(source).toContain('className="sidebar-collapse-button"');
expect(source).toContain("aria-label={sidebarCollapsed ? t('展开侧边栏') : t('收起侧边栏')}");
expect(source).toContain('aria-controls={sidebarNavId}');
expect(source).toContain('aria-expanded={!sidebarCollapsed}');
expect(source).toContain('onClick={() => setSidebarCollapsed((value) => !value)}');
expect(css).toContain('.app-layout.sidebar-is-collapsed .sidebar {');
expect(css).toContain('flex-basis: 72px;');
expect(css).toContain('.sidebar-collapse-button {');
});
it('uses flat sidebar selection styling without tree-like rails', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
const sidebarCss = css.slice(css.indexOf('/* ===== Sidebar ===== */'));
const sidebarActiveBeforeRules = Array.from(
sidebarCss.matchAll(/\.sidebar-item\.active::before\s*\{[^}]*\}/g),
(match) => match[0],
);
expect(sidebarActiveBeforeRules.length).toBeGreaterThan(0);
sidebarActiveBeforeRules.forEach((rule) => {
expect(rule).toContain('content: none;');
expect(rule).toContain('display: none;');
expect(rule).not.toContain('left: 6px;');
expect(rule).not.toContain('width: 3px;');
expect(rule).not.toContain('height: 16px;');
});
const mobileActiveBeforeRules = Array.from(
sidebarCss.matchAll(/\.mobile-nav-item\.active::before\s*\{[^}]*\}/g),
(match) => match[0],
);
expect(mobileActiveBeforeRules.length).toBeGreaterThan(0);
mobileActiveBeforeRules.forEach((rule) => {
expect(rule).toContain('content: none;');
expect(rule).toContain('display: none;');
});
const mobileLabelRules = Array.from(
sidebarCss.matchAll(/\.mobile-nav-label\s*\{[^}]*\}/g),
(match) => match[0],
);
expect(mobileLabelRules.length).toBeGreaterThan(0);
mobileLabelRules.forEach((rule) => {
expect(rule).toContain('text-transform: none;');
expect(rule).toContain('letter-spacing: 0;');
});
expect(sidebarCss).not.toContain('inset 4px 0 0');
expect(sidebarCss).not.toContain('sidebar-submenu');
expect(sidebarCss).not.toContain('mobile-nav-parent');
const sidebarGroupRules = Array.from(
css.matchAll(/\.sidebar-group(?:\:last-of-type)?\s*\{[^}]*\}/g),
(match) => match[0],
);
expect(sidebarGroupRules.length).toBeGreaterThan(0);
const hasNonZeroDeclaration = (rule: string, property: string) => (
Array.from(rule.matchAll(new RegExp(`${property}\\s*:\\s*([^;]+)`, 'g')))
.some((match) => match[1]?.trim() !== '0')
);
const treeLikeSidebarGroupRules = sidebarGroupRules.filter((rule) => (
hasNonZeroDeclaration(rule, 'border-left')
|| hasNonZeroDeclaration(rule, 'padding-left')
|| hasNonZeroDeclaration(rule, 'margin-left')
));
expect(treeLikeSidebarGroupRules).toEqual([]);
expect(sidebarGroupRules.some((rule) => rule.includes('border-left: 0;'))).toBe(true);
expect(sidebarGroupRules.some((rule) => rule.includes('padding-left: 0;'))).toBe(true);
const mobileFlatRules = Array.from(
css.matchAll(/\.mobile-nav-group,\s*\.mobile-nav-label,\s*\.mobile-nav-item\s*\{[^}]*\}/g),
(match) => match[0],
);
expect(mobileFlatRules.length).toBeGreaterThan(0);
mobileFlatRules.forEach((rule) => {
expect(rule).toContain('margin-left: 0;');
expect(rule).toContain('border-left: 0;');
});
});
it('keeps internal usage log navigation and removes external monitor from admin shell', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
const adminGroupsSource = source.slice(
source.indexOf('export const sidebarGroups'),
source.indexOf('export const userSidebarGroups'),
);
expect(adminGroupsSource).toContain("{ to: '/logs', label: '使用日志'");
expect(adminGroupsSource).toContain("{ to: '/events', label: '系统日志'");
expect(adminGroupsSource).not.toContain("{ to: '/monitor', label: '外部监控'");
expect(adminGroupsSource).not.toContain("{ to: '/events', label: '程序日志'");
expect(adminGroupsSource).not.toContain('children:');
expect(adminGroupsSource).not.toContain('aria-expanded');
expect(adminGroupsSource).not.toContain('sidebar-submenu');
expect(adminGroupsSource).not.toContain('mobile-nav-parent');
expect(adminGroupsSource).toContain("label: '接入管理'");
expect(adminGroupsSource).toContain("{ to: '/ops', label: '运维监控'");
const accessGroupSource = adminGroupsSource.slice(
adminGroupsSource.indexOf("label: '接入管理'"),
adminGroupsSource.indexOf("label: '创作应用'"),
);
const systemGroupSource = adminGroupsSource.slice(adminGroupsSource.indexOf("label: '系统'"));
expect(accessGroupSource).toContain("{ to: '/logs', label: '使用日志'");
expect(accessGroupSource).not.toContain("{ to: '/events', label: '系统日志'");
expect(systemGroupSource).toContain("{ to: '/events', label: '系统日志'");
expect(systemGroupSource).not.toContain("{ to: '/logs', label: '使用日志'");
expect(source).toContain('<Route path="/logs" element={<ProxyLogs />} />');
expect(source).not.toContain("const Monitors = lazy(() => import('./pages/Monitors.js'));");
expect(source).not.toContain('<Route path="/monitor"');
expect(source).toContain('<Route path="/events" element={<ProgramLogs />} />');
});
it('keeps user-side usage records navigation and route intact', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
const userGroupsSource = source.slice(
source.indexOf('export const userSidebarGroups'),
source.indexOf('const topNavItems'),
);
expect(userGroupsSource).toContain("{ to: '/user/usage', label: '使用记录'");
expect(source).toContain("const UsageRecords = lazy(() => import('./pages/UsageRecords.js'));");
expect(source).toContain('<Route path="/user/usage" element={<UsageRecords />} />');
});
it('adds standalone OAuth 管理 navigation entry', () => { it('adds standalone OAuth 管理 navigation entry', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8'); const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
@@ -29,4 +178,26 @@ describe('App sidebar config', () => {
expect(source).toContain("const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));"); expect(source).toContain("const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));");
expect(source).toContain('<Route path="/oauth" element={<OAuthManagement />} />'); expect(source).toContain('<Route path="/oauth" element={<OAuthManagement />} />');
}); });
it('registers the model tester route used by route diagnostics', () => {
const appSource = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
const tokenRoutesSource = readFileSync(resolve(process.cwd(), 'src/web/pages/TokenRoutes.tsx'), 'utf8');
const topNavSource = appSource.slice(
appSource.indexOf('const topNavItems'),
appSource.indexOf('function isExactSidebarPath'),
);
expect(appSource).toContain("const ModelTester = lazy(() => import('./pages/ModelTester.js'));");
expect(topNavSource).toContain("{ label: '模型测试', to: '/model-tester' }");
expect(appSource).toContain('<Route path="/model-tester" element={<ModelTester />} />');
expect(tokenRoutesSource).toContain('navigate(`/model-tester?model=${encodeURIComponent(model)}`)');
});
it('adds a system-internal recharge page to the user workspace', () => {
const appSource = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
expect(appSource).toContain("const UserRecharge = lazy(() => import('./pages/UserRecharge.js'));");
expect(appSource).toContain("{ to: '/user/recharge', label: '账户充值'");
expect(appSource).toContain('<Route path="/user/recharge" element={<UserRecharge />} />');
});
}); });
@@ -0,0 +1,117 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
function readAppSource(): string {
return readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8').replace(/\r\n/g, '\n');
}
describe('App topbar menu accessibility', () => {
it('exposes theme and account dropdowns as controlled menus', () => {
const source = readAppSource();
expect(source).toContain('const themeMenuId = useId();');
expect(source).toContain('const userMenuId = useId();');
expect(source).toContain('const userTriggerRef = useRef<HTMLButtonElement>(null);');
expect(source).toContain('const themeTriggerRef = useRef<HTMLButtonElement>(null);');
expect(source).toContain('const searchDialogId = useId();');
expect(source).toContain('const notificationPanelId = useId();');
expect(source).toContain('function openSearch() {');
expect(source).toContain('aria-haspopup="menu"');
expect(source).toContain('aria-haspopup="dialog"');
expect(source).toContain('aria-expanded={showSearch}');
expect(source).toContain('aria-controls={showSearch ? searchDialogId : undefined}');
expect(source).toContain('aria-controls={themeMenuPresence.shouldRender ? themeMenuId : undefined}');
expect(source).toContain('aria-controls={userMenuPresence.shouldRender ? userMenuId : undefined}');
expect(source).toContain('aria-controls={showNotifications ? notificationPanelId : undefined}');
expect(source).toContain('role="menu"');
expect(source).toContain('aria-label={t(\'主题菜单\')}');
expect(source).toContain('aria-label={t(\'账户菜单\')}');
expect(source).toContain('role="menuitemradio"');
expect(source).toContain('role="menuitem"');
expect(source).toContain('dialogId={searchDialogId}');
expect(source).toContain("type ThemePreset = 'clearops' | 'terminal' | 'studio';");
expect(source).toContain('const THEME_PRESETS: Array<{ value: ThemePreset; label: string; description: string }> = [');
expect(source).toContain("const [themePreset, setThemePreset] = useState<ThemePreset>(() => resolveStoredThemePreset());");
expect(source).toContain("document.documentElement.setAttribute('data-theme-preset', themePreset);");
expect(source).toContain("document.documentElement.setAttribute('data-theme-tone', resolvedThemeTone);");
expect(source).toContain('className={`theme-preset-swatch theme-preset-swatch-${preset.value}`}');
});
it('keeps closing dropdown actions out of the tab order and closes menus on Escape', () => {
const source = readAppSource();
expect(source).toContain('tabIndex={themeMenuPresence.isVisible ? 0 : -1}');
expect(source).toContain('tabIndex={userMenuPresence.isVisible ? 0 : -1}');
expect(source).toContain('aria-hidden={!themeMenuPresence.isVisible}');
expect(source).toContain('aria-hidden={!userMenuPresence.isVisible}');
expect(source).toContain('if (!showThemeMenu && !showUserMenu) return undefined;');
expect(source).toContain("if (e.key !== 'Escape') return;");
expect(source).toContain('setShowThemeMenu(false);');
expect(source).toContain('setShowUserMenu(false);');
expect(source).toContain('setShowNotifications(false);');
expect(source).toContain('setShowSearch(false);');
expect(source).toContain('triggerRef.current?.focus({ preventScroll: true });');
expect(source).toContain('themeTriggerRef.current?.focus({ preventScroll: true });');
expect(source).toContain('if (e.defaultPrevented) return;');
expect(source).toContain("e.key.toLowerCase() === 'k'");
expect(source).toContain("document.addEventListener('keydown', handler);");
expect(source).toContain("return () => document.removeEventListener('keydown', handler);");
});
it('supports keyboard opening and roving focus inside topbar menus', () => {
const source = readAppSource();
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(source).toContain("type MenuFocusEdge = 'first' | 'last';");
expect(source).toContain("type PendingMenuFocus = { menu: 'theme' | 'user'; edge: MenuFocusEdge } | null;");
expect(source).toContain('const [pendingMenuFocus, setPendingMenuFocus] = useState<PendingMenuFocus>(null);');
expect(source).toContain('const focusMenuItem = (menuRef: React.RefObject<HTMLDivElement>, edge: MenuFocusEdge = \'first\') => {');
expect(source).toContain('target?.focus({ preventScroll: true });');
expect(source).toContain('if (!pendingMenuFocus) return;');
expect(source).toContain('const menuRef = pendingMenuFocus.menu === \'theme\' ? themeMenuRef : userMenuRef;');
expect(source).toContain('setPendingMenuFocus(null);');
expect(source).toContain('const handleTopbarMenuTriggerKeyDown = (');
expect(source).toContain("if (!['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) return;");
expect(source).toContain("const focusEdge: MenuFocusEdge = e.key === 'ArrowUp' ? 'last' : 'first';");
expect(source).toContain("setPendingMenuFocus({ menu: 'theme', edge: focusEdge });");
expect(source).toContain("setPendingMenuFocus({ menu: 'user', edge: focusEdge });");
expect(source).toContain('const handleTopbarMenuKeyDown = (');
expect(source).toContain("if (e.key === 'Escape') {\n e.preventDefault();\n setShowThemeMenu(false);");
expect(source).toContain("if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {");
expect(source).toContain('items[nextIndex]?.focus({ preventScroll: true });');
expect(source).toContain("if (e.key === 'Home') {");
expect(source).toContain('items[0]?.focus({ preventScroll: true });');
expect(source).toContain("if (e.key === 'End') {");
expect(source).toContain('items[items.length - 1]?.focus({ preventScroll: true });');
expect(source).toContain('target?.focus({ preventScroll: true });');
expect(source).toContain("onKeyDown={(e) => handleTopbarMenuTriggerKeyDown(e, 'theme')}");
expect(source).toContain("onKeyDown={(e) => handleTopbarMenuTriggerKeyDown(e, 'user')}");
expect(source).toContain('onKeyDown={handleNotificationTriggerKeyDown}');
expect(source).toContain("onKeyDown={(e) => handleTopbarMenuKeyDown(e, 'theme')}");
expect(source).toContain("onKeyDown={(e) => handleTopbarMenuKeyDown(e, 'user')}");
expect(css).toContain('.user-dropdown-item:focus-visible');
expect(css).toContain('[data-theme="dark"] .user-dropdown-item:focus-visible');
});
it('keeps the desktop topbar grid shrink-safe before the mobile breakpoint', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
const finalTopbarStart = css.indexOf('/* Final topbar layout guard: keep brand, nav, and user actions in their own lanes. */');
const finalTopbarEnd = css.indexOf('@media (max-width: 768px)', finalTopbarStart);
const finalTopbarCss = css.slice(finalTopbarStart, finalTopbarEnd);
expect(finalTopbarStart).toBeGreaterThanOrEqual(0);
expect(finalTopbarEnd).toBeGreaterThan(finalTopbarStart);
expect(finalTopbarCss).toContain('grid-template-columns: minmax(min(230px, 100%), 280px) auto minmax(min(300px, 100%), 1fr);');
expect(finalTopbarCss).toContain('grid-template-columns: minmax(min(170px, 100%), 220px) auto minmax(min(220px, 100%), 1fr);');
expect(finalTopbarCss).toContain('grid-template-columns: minmax(min(150px, 100%), 190px) auto minmax(min(150px, 100%), 1fr);');
expect(finalTopbarCss).toContain('width: max-content;');
expect(finalTopbarCss).toContain('max-width: min(430px, 100%);');
expect(finalTopbarCss).toContain('overflow-x: auto;');
expect(finalTopbarCss).toContain('.topbar-nav::-webkit-scrollbar {\n display: none;\n}');
expect(finalTopbarCss).not.toContain('width: min(520px, 100%);');
expect(finalTopbarCss).not.toContain('grid-template-columns: minmax(230px, 280px) minmax(320px, max-content) minmax(300px, 1fr);');
expect(finalTopbarCss).not.toContain('grid-template-columns: minmax(170px, 220px) minmax(280px, max-content) minmax(220px, 1fr);');
expect(finalTopbarCss).not.toContain('grid-template-columns: minmax(150px, 190px) minmax(240px, max-content) minmax(150px, 1fr);');
});
});
+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'; import { resolve } from 'node:path';
describe('App topbar tooltips', () => { describe('App topbar tooltips', () => {
it('removes topbar hover tooltips while preserving sidebar collapsed tooltips', () => { it('removes hover-only tooltips from topbar and fixed desktop sidebar navigation', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8'); const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
const topbarStart = source.indexOf('<div className="topbar-right">'); const topbarStart = source.indexOf('<div className="topbar-right">');
const topbarEnd = source.indexOf('</header>'); const topbarEnd = source.indexOf('</header>');
const sidebarItemStart = source.indexOf('const renderDesktopSidebarItem');
const sidebarItemEnd = source.indexOf('return (', source.indexOf('<a'));
expect(topbarStart).toBeGreaterThanOrEqual(0); expect(topbarStart).toBeGreaterThanOrEqual(0);
expect(topbarEnd).toBeGreaterThan(topbarStart); expect(topbarEnd).toBeGreaterThan(topbarStart);
const topbarSection = source.slice(topbarStart, topbarEnd); const topbarSection = source.slice(topbarStart, topbarEnd);
expect(topbarSection).not.toContain('data-tooltip='); expect(topbarSection).not.toContain('data-tooltip=');
expect(source).toContain("data-tooltip={sidebarCollapsed ? t(item.label) : undefined}"); expect(source).not.toContain('data-tooltip={sidebarCollapsed');
expect(sidebarItemStart).toBeGreaterThanOrEqual(0);
expect(sidebarItemEnd).toBeGreaterThan(sidebarItemStart);
}); });
}); });
+902 -197
View File
File diff suppressed because it is too large Load Diff
+426 -2
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { api, type ProxyTestRequestEnvelope } from './api.js'; import { api, buildSiteProbeStreamUrl, userApi, type ProxyTestRequestEnvelope } from './api.js';
import { persistAuthSession } from './authSession.js'; import { commitSession, persistAuthSession } from './authSession.js';
function createMemoryStorage() { function createMemoryStorage() {
const store = new Map<string, string>(); const store = new Map<string, string>();
@@ -122,6 +122,20 @@ describe('api proxy test timeout handling', () => {
expect(result.error.message).toBe('请求超时(120s)'); expect(result.error.message).toBe('请求超时(120s)');
}); });
it('builds site probe stream URLs with frontend camelCase query names', () => {
expect(buildSiteProbeStreamUrl(9, {
scope: 'single',
modelName: ' gpt-4o ',
latencyThresholdMs: 1800.9,
})).toBe('/api/sites/9/probe-stream?scope=single&modelName=gpt-4o&latencyThresholdMs=1800');
expect(buildSiteProbeStreamUrl(9, {
scope: 'all',
modelName: 'gpt-4o',
latencyThresholdMs: 0,
})).toBe('/api/sites/9/probe-stream?scope=all');
});
it('times out replay hydration file-content fetches after 30 seconds', async () => { it('times out replay hydration file-content fetches after 30 seconds', async () => {
installPendingFetch(); installPendingFetch();
@@ -179,3 +193,413 @@ describe('api proxy test timeout handling', () => {
expect(api.proxyTestStream).toBe(api.testProxyStream); expect(api.proxyTestStream).toBe(api.testProxyStream);
}); });
}); });
describe('api oauth compatibility', () => {
beforeEach(() => {
vi.useRealTimers();
vi.stubGlobal('localStorage', createMemoryStorage());
persistAuthSession(globalThis.localStorage as Storage, 'token-1');
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('normalizes oauth provider responses before the page renders selectors', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
providers: [
{
id: 'codex',
name: 'Codex',
type: 'codex',
enabled: true,
},
{
provider: 'undefined',
label: 'undefined',
platform: 'undefined',
enabled: true,
},
{
value: 'gemini-cli',
displayName: 'Gemini CLI',
requires_project_id: true,
},
],
defaults: {
systemProxyConfigured: true,
},
}), { status: 200, headers: { 'content-type': 'application/json' } }));
vi.stubGlobal('fetch', fetchMock);
const result = await api.getOAuthProviders();
expect(fetchMock).toHaveBeenCalledWith('/api/oauth/providers', expect.any(Object));
expect(result.defaults?.systemProxyConfigured).toBe(true);
expect(result.providers).toEqual([
{
provider: 'codex',
label: 'Codex',
platform: 'codex',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
{
provider: 'gemini-cli',
label: 'Gemini CLI',
platform: 'gemini-cli',
enabled: true,
loginType: 'oauth',
requiresProjectId: true,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
]);
});
it('normalizes legacy oauth connection list responses into the page contract', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
data: [
{
id: 7,
site_id: 2,
username: 'codex-user',
oauthProvider: 'codex',
oauthAccountKey: 'chatgpt-account-123',
oauthProjectId: 'project-a',
models_preview: '["gpt-5.4","gpt-5.2-codex"]',
expires_at: '2030-01-01T00:00:00+00:00',
token_expires_at: 1893456000000,
status: 'active',
},
],
total: 1,
}), { status: 200, headers: { 'content-type': 'application/json' } }));
vi.stubGlobal('fetch', fetchMock);
const result = await api.getOAuthConnections({ limit: 25, offset: 5 });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/oauth/connections?limit=25&offset=5');
expect(result.items).toEqual([
expect.objectContaining({
accountId: 7,
siteId: 2,
username: 'codex-user',
provider: 'codex',
accountKey: 'chatgpt-account-123',
projectId: 'project-a',
modelCount: 2,
modelsPreview: ['gpt-5.4', 'gpt-5.2-codex'],
expiresAt: '2030-01-01T00:00:00+00:00',
tokenExpiresAt: 1893456000000,
status: 'healthy',
}),
]);
expect(result.total).toBe(1);
expect(result.offset).toBe(0);
});
it('uses the Rust oauth route paths for quota and proxy mutations', async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
})));
vi.stubGlobal('fetch', fetchMock);
await api.refreshOAuthConnectionQuota(7);
await api.refreshOAuthConnectionQuotaBatch([7, 8]);
await api.updateOAuthConnectionProxy(7, { proxyUrl: 'http://127.0.0.1:7890', useSystemProxy: false });
expect(fetchMock.mock.calls.map((call) => [call[0], (call[1] as RequestInit | undefined)?.method])).toEqual([
['/api/oauth/connections/7/refresh-quota', 'POST'],
['/api/oauth/connections/refresh-quota-batch', 'POST'],
['/api/oauth/connections/7/proxy', 'POST'],
]);
});
it('uses the Rust oauth route paths for connection import, deletion, and route pools', async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
})));
vi.stubGlobal('fetch', fetchMock);
await api.deleteOAuthConnection(7);
await api.importOAuthConnections({ type: 'codex', access_token: 'token-a' });
await api.importOAuthConnections({ items: [{ type: 'claude', access_token: 'token-b' }] });
await api.createOAuthRouteUnit({
accountIds: [7, 8],
name: 'OAuth Pool',
strategy: 'round_robin',
});
await api.updateOAuthRouteUnit(42, {
name: 'OAuth Pool 2',
strategy: 'stick_until_unavailable',
enabled: true,
});
await api.deleteOAuthRouteUnit(42);
expect(fetchMock.mock.calls.map((call) => [call[0], (call[1] as RequestInit | undefined)?.method])).toEqual([
['/api/oauth/connections/7', 'DELETE'],
['/api/oauth/import', 'POST'],
['/api/oauth/import', 'POST'],
['/api/oauth/route-units', 'POST'],
['/api/oauth/route-units/42', 'PUT'],
['/api/oauth/route-units/42', 'DELETE'],
]);
const singleImportInit = fetchMock.mock.calls[1]?.[1] as RequestInit | undefined;
const batchImportInit = fetchMock.mock.calls[2]?.[1] as RequestInit | undefined;
expect(singleImportInit?.body).toBe(JSON.stringify({ data: { type: 'codex', access_token: 'token-a' } }));
expect(batchImportInit?.body).toBe(JSON.stringify({ items: [{ type: 'claude', access_token: 'token-b' }] }));
});
});
describe('api workflow model folder compatibility', () => {
beforeEach(() => {
vi.useRealTimers();
vi.stubGlobal('localStorage', createMemoryStorage());
persistAuthSession(globalThis.localStorage as Storage, 'token-1');
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('normalizes model folder responses from items, names, data, and value fields', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
success: true,
items: [
{ name: 'wan2.1-t2v', pathIndex: 0 },
{ value: 'seedance-1-0-lite-t2v-250428' },
],
names: ['wan2.1-t2v', 'seedance-1-0-pro-t2v-250528'],
data: [{ filename: 'kling-video' }],
}), { status: 200, headers: { 'content-type': 'application/json' } }));
vi.stubGlobal('fetch', fetchMock);
const payload = await api.getWorkflowModelFolder('video_models');
expect(fetchMock.mock.calls[0]?.[0]).toBe('/workflow-ui/api/experiment/models/video_models');
expect((fetchMock.mock.calls[0]?.[1] as RequestInit)?.headers).toBeInstanceOf(Headers);
expect(((fetchMock.mock.calls[0]?.[1] as RequestInit)?.headers as Headers).get('Authorization')).toBe('Bearer token-1');
expect(payload.names).toEqual([
'wan2.1-t2v',
'seedance-1-0-lite-t2v-250428',
'kling-video',
'seedance-1-0-pro-t2v-250528',
]);
expect(payload.items.map((item) => item.name)).toEqual(payload.names);
});
});
describe('api proxy log filters', () => {
beforeEach(() => {
vi.useRealTimers();
vi.stubGlobal('localStorage', createMemoryStorage());
persistAuthSession(globalThis.localStorage as Storage, 'token-1');
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('serializes downstream key filters for server-driven proxy logs', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
items: [],
total: 0,
summary: { total: 0, success: 0, failed: 0 },
}), { status: 200, headers: { 'content-type': 'application/json' } }));
vi.stubGlobal('fetch', fetchMock);
await api.getProxyLogsQuery({
limit: 25,
offset: 50,
status: 'failed',
downstreamKeyId: 17,
search: 'gpt-4o',
});
const url = String(fetchMock.mock.calls[0]?.[0] || '');
expect(url).toContain('/api/stats/proxy-logs?');
expect(url).toContain('downstreamKeyId=17');
expect(url).toContain('status=failed');
expect(url).toContain('search=gpt-4o');
expect(url).toContain('view=query');
});
it('parses proxy log billing details from snake_case JSON strings', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
items: [
{
id: 42,
model_requested: 'seedance-comfy',
status: 'success',
estimated_cost: 0.35,
billing_details: JSON.stringify({
source: 'comfy-native-task',
totalCost: 0.35,
steps: [{ name: 'archive_outputs', cost: 0.35 }],
}),
},
],
total: 1,
summary: { total: 1, success: 1, failed: 0, cost: 0.35 },
}), { status: 200, headers: { 'content-type': 'application/json' } }));
vi.stubGlobal('fetch', fetchMock);
const response = await api.getProxyLogsQuery({ limit: 1 });
expect(response.items[0]?.billingDetails).toMatchObject({
source: 'comfy-native-task',
totalCost: 0.35,
steps: [{ name: 'archive_outputs', cost: 0.35 }],
});
});
});
describe('user recharge api', () => {
beforeEach(() => {
vi.useRealTimers();
const storage = createMemoryStorage();
vi.stubGlobal('localStorage', storage);
commitSession('user', 'user-token', undefined, storage);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('creates recharge checkout records before returning the checkout url', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
success: true,
checkoutUrl: 'https://checkout.comfy.org/c/pay/session',
record: {
id: 1,
publicId: 'rch_test',
provider: 'comfy_checkout',
checkoutUrl: 'https://checkout.comfy.org/c/pay/session',
status: 'pending',
},
}), { status: 201, headers: { 'content-type': 'application/json' } }));
vi.stubGlobal('fetch', fetchMock);
const payload = await userApi.createRechargeCheckout({
optionId: 'usd-50',
amountUsd: 50,
});
expect(payload.record.publicId).toBe('rch_test');
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/user/recharge/checkout');
expect((fetchMock.mock.calls[0]?.[1] as RequestInit)?.method).toBe('POST');
expect((fetchMock.mock.calls[0]?.[1] as RequestInit)?.body).toBe(JSON.stringify({
optionId: 'usd-50',
amountUsd: 50,
}));
});
it('can cancel and clear user recharge records', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
success: true,
record: {
id: 1,
publicId: 'rch_test',
provider: 'comfy_checkout',
checkoutUrl: 'https://checkout.comfy.org/c/pay/session',
status: 'cancelled',
},
}), { status: 200, headers: { 'content-type': 'application/json' } }))
.mockResolvedValueOnce(new Response(JSON.stringify({
success: true,
cleared: 2,
records: [],
items: [],
}), { status: 200, headers: { 'content-type': 'application/json' } }));
vi.stubGlobal('fetch', fetchMock);
const cancelled = await userApi.cancelRechargeRecord('rch_test');
const cleared = await userApi.clearRechargeRecords();
expect(cancelled.record.status).toBe('cancelled');
expect(cleared.cleared).toBe(2);
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/user/recharge-records/rch_test/cancel');
expect((fetchMock.mock.calls[0]?.[1] as RequestInit)?.method).toBe('POST');
expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/user/recharge-records');
expect((fetchMock.mock.calls[1]?.[1] as RequestInit)?.method).toBe('DELETE');
});
it('sanitizes upstream HTML errors from recharge checkout', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(
'<!DOCTYPE html><!--[if lt IE 7]> <html class="no-js ie6 oldie" lang="en-US">',
{ status: 502, headers: { 'content-type': 'text/html' } },
));
vi.stubGlobal('fetch', fetchMock);
await expect(userApi.createRechargeCheckout({
optionId: 'usd-10',
amountUsd: 10,
})).rejects.toMatchObject({
status: 502,
message: 'HTTP 502: 上游返回 HTML 错误页,请检查支付/上游 Token 配置',
});
});
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,
});
});
it('fetches billing ledger with track/entryType filters and summary', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
success: true,
records: [
{ track: 'points', entryType: 'consume', amount: -10, balanceAfter: 490 },
],
}), { status: 200, headers: { 'content-type': 'application/json' } }))
.mockResolvedValueOnce(new Response(JSON.stringify({
success: true,
summary: [
{ track: 'points', entryType: 'consume', total: -10, count: 1 },
],
}), { status: 200, headers: { 'content-type': 'application/json' } }));
vi.stubGlobal('fetch', fetchMock);
const ledger = await userApi.getBillingLedger({ track: 'points', entryType: 'consume', limit: 50 });
const summary = await userApi.getBillingSummary();
expect(ledger.records[0].entryType).toBe('consume');
expect(summary.summary[0].count).toBe(1);
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/user/billing/ledger?track=points&entryType=consume&limit=50');
expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/user/billing/summary');
});
});
+978 -60
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -1,9 +1,13 @@
import { clearSession } from './authSession.js'; import { clearSession } from './authSession.js';
export const THEME_MODE_STORAGE_KEY = 'theme_mode'; export const THEME_MODE_STORAGE_KEY = 'theme_mode';
export const THEME_PRESET_STORAGE_KEY = 'theme_preset';
export const LEGACY_THEME_STORAGE_KEY = 'theme'; export const LEGACY_THEME_STORAGE_KEY = 'theme';
export const USER_PROFILE_STORAGE_KEY = 'user_profile'; export const USER_PROFILE_STORAGE_KEY = 'user_profile';
export const ADMIN_PROFILE_STORAGE_KEY = 'admin_profile';
export const LEGACY_USER_PROFILE_STORAGE_KEY = USER_PROFILE_STORAGE_KEY;
export const FIRST_USE_DOC_REMINDER_KEY = 'boosapi_first_use_docs_reminder_seen_v1'; export const FIRST_USE_DOC_REMINDER_KEY = 'boosapi_first_use_docs_reminder_seen_v1';
export const LEGACY_FIRST_USE_DOC_REMINDER_KEY = 'metapi_first_use_docs_reminder_seen_v1';
type StorageLike = { type StorageLike = {
getItem?: (key: string) => string | null; getItem?: (key: string) => string | null;
@@ -22,7 +26,10 @@ export function clearAppInstallationState(storage?: StorageLike | null): void {
if (!target) return; if (!target) return;
clearSession('admin', target as never); clearSession('admin', target as never);
target.removeItem(THEME_MODE_STORAGE_KEY); target.removeItem(THEME_MODE_STORAGE_KEY);
target.removeItem(THEME_PRESET_STORAGE_KEY);
target.removeItem(LEGACY_THEME_STORAGE_KEY); target.removeItem(LEGACY_THEME_STORAGE_KEY);
target.removeItem(USER_PROFILE_STORAGE_KEY); target.removeItem(USER_PROFILE_STORAGE_KEY);
target.removeItem(ADMIN_PROFILE_STORAGE_KEY);
target.removeItem(FIRST_USE_DOC_REMINDER_KEY); target.removeItem(FIRST_USE_DOC_REMINDER_KEY);
target.removeItem(LEGACY_FIRST_USE_DOC_REMINDER_KEY);
} }
@@ -0,0 +1,73 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
function readSource(relativePath: string): string {
return readFileSync(resolve(process.cwd(), relativePath), 'utf8').replace(/\r\n/g, '\n');
}
describe('AppMotion animation contract', () => {
it('uses GSAP matchMedia so page transitions respect reduced motion and viewport density', () => {
const source = readSource('src/web/components/AppMotion.tsx');
expect(source).toContain("import { gsap } from 'gsap';");
expect(source).toContain('const mm = gsap.matchMedia();');
expect(source).toContain("reduceMotion: '(prefers-reduced-motion: reduce)'");
expect(source).toContain("isDesktop: '(min-width: 900px)'");
expect(source).toContain('if (conditions.reduceMotion) {');
expect(source).toContain("gsap.set([root, ...targets], { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });");
expect(source).toContain('return () => mm.revert();');
});
it('keeps route/login entrance motion scoped, subtle, and cleaned up on unmount', () => {
const source = readSource('src/web/components/AppMotion.tsx');
expect(source).toContain('motionVariant === \'login\' ? 14 : (conditions.isDesktop ? 26 : 16)');
expect(source).toContain('const timeline = gsap.timeline({');
expect(source).toContain("ease: 'power2.out'");
expect(source).toContain("overwrite: 'auto'");
expect(source).toContain('timeline.fromTo(');
expect(source).toContain("stagger: {\n each: motionVariant === 'login' ? 0.045 : 0.026,");
expect(source).toContain('timeline.kill();');
expect(source).toContain('gsap.killTweensOf([root, ...targets]);');
expect(source).toContain("gsap.set([root, ...targets], { clearProps: 'transform,opacity,visibility' });");
});
it('includes the newer dashboard operations blocks in the GSAP entrance target set', () => {
const source = readSource('src/web/components/AppMotion.tsx');
expect(source).toContain("':scope > .dashboard-war-room'");
expect(source).toContain("':scope > .dashboard-command-strip'");
expect(source).toContain("':scope .dashboard-pulse-card'");
expect(source).toContain("':scope .dashboard-risk-panel'");
expect(source).toContain("':scope .dashboard-command-item'");
expect(source).toContain("':scope .ops-monitor-log-card'");
expect(source).toContain("':scope .responsive-batch-action-bar'");
});
it('keeps responsive batch action bars reachable by the GSAP selector', () => {
const mobileBatchBar = readSource('src/web/components/MobileBatchBar.tsx');
const responsiveBatchBar = readSource('src/web/components/ResponsiveBatchActionBar.tsx');
expect(mobileBatchBar).toContain('responsive-batch-action-bar');
expect(responsiveBatchBar).toContain('responsive-batch-action-bar');
expect(mobileBatchBar).toContain('className="mobile-actions-info" role="status" aria-live="polite"');
expect(responsiveBatchBar).toContain('<span role="status" aria-live="polite"');
});
it('keeps high-frequency overlay motion reduced-motion friendly', () => {
const css = readSource('src/web/index.css');
expect(css).toContain('@media (prefers-reduced-motion: reduce) {');
expect(css).toContain('.user-dropdown,\n .user-dropdown.is-closing');
expect(css).toContain('.modal-backdrop,\n .modal-backdrop.is-closing');
expect(css).toContain('.mobile-drawer-panel,\n .mobile-drawer-panel-right');
expect(css).toContain('.filter-panel.filter-collapsible,\n .filter-panel.filter-collapsible.is-closing');
expect(css).toContain('animation: none !important;');
expect(css).toContain('transition: none !important;');
expect(css).toContain('transform: none !important;');
expect(css).toContain('.user-dropdown.is-closing,\n .modal-backdrop.is-closing');
expect(css).not.toContain('.main-content.is-route-transitioning');
expect(css).not.toContain('.route-progress');
});
});
+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(() => { const observer = new MutationObserver(() => {
setIsDark(document.documentElement.getAttribute('data-theme') === 'dark'); 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 () => observer.disconnect();
}, []); }, []);
return isDark ? ICON_CDN : ICON_CDN_LIGHT; 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 { createPortal } from 'react-dom';
import { trapFocusInContainer } from './focusTrap.js';
import { useAnimatedVisibility } from './useAnimatedVisibility.js'; import { useAnimatedVisibility } from './useAnimatedVisibility.js';
type CenteredModalProps = { type CenteredModalProps = {
@@ -28,6 +29,9 @@ export default function CenteredModal({
showCloseButton = true, showCloseButton = true,
}: CenteredModalProps) { }: CenteredModalProps) {
const presence = useAnimatedVisibility(open, 220); 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' const canUsePortal = typeof document !== 'undefined'
&& !!document.body && !!document.body
&& typeof document.body.appendChild === 'function' && typeof document.body.appendChild === 'function'
@@ -43,9 +47,52 @@ export default function CenteredModal({
}, [canUsePortal, open]); }, [canUsePortal, open]);
useEffect(() => { 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) => { const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose(); if (event.key === 'Escape' && closeOnEscape) {
onClose();
return;
}
trapFocusInContainer(event, dialogRef.current);
}; };
document.addEventListener('keydown', handleKeydown); document.addEventListener('keydown', handleKeydown);
return () => { return () => {
@@ -55,24 +102,33 @@ export default function CenteredModal({
if (!presence.shouldRender) return null; if (!presence.shouldRender) return null;
const interactive = open && presence.isVisible;
const modal = ( const modal = (
<div <div
className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()}
onClick={closeOnBackdrop ? onClose : undefined} onClick={closeOnBackdrop ? onClose : undefined}
> >
<div <div
ref={dialogRef}
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-hidden={!interactive}
tabIndex={-1}
style={{ maxWidth }} style={{ maxWidth }}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<div className="modal-header"> <div className="modal-header">
<div className="modal-title">{title}</div> <div id={titleId} className="modal-title">{title}</div>
{showCloseButton ? ( {showCloseButton ? (
<button <button
type="button" type="button"
className="modal-close-button" className="modal-close-button"
onClick={onClose} onClick={onClose}
aria-label="关闭弹框" aria-label="关闭弹框"
tabIndex={interactive ? 0 : -1}
> >
× ×
</button> </button>
+54 -66
View File
@@ -1,12 +1,10 @@
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { api } from '../api.js'; import { api } from '../api.js';
import { useToast } from './Toast.js'; import { useToast } from './Toast.js';
import { commitSession } from '../authSession.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 }) { export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const presence = useAnimatedVisibility(open, 200);
const [oldToken, setOldToken] = useState(''); const [oldToken, setOldToken] = useState('');
const [newToken, setNewToken] = useState(''); const [newToken, setNewToken] = useState('');
const [confirmToken, setConfirmToken] = useState(''); const [confirmToken, setConfirmToken] = useState('');
@@ -14,15 +12,6 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
const [error, setError] = useState(''); const [error, setError] = useState('');
const toast = useToast(); 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 () => { const handleSubmit = async () => {
setError(''); setError('');
if (!oldToken || !newToken || !confirmToken) { if (!oldToken || !newToken || !confirmToken) {
@@ -58,67 +47,66 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
} }
}; };
if (!presence.shouldRender) return null;
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)', width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none', borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none',
background: 'var(--color-bg)', color: 'var(--color-text-primary)', background: 'var(--color-bg)', color: 'var(--color-text-primary)',
}; };
const modal = ( return (
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}> <CenteredModal
<div className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={e => e.stopPropagation()} style={{ maxWidth: 420 }}> open={open}
<div className="modal-header">修改管理员 Token</div> onClose={onClose}
title="修改管理员 Token"
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> maxWidth={420}
<div> closeOnBackdrop
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>旧 Token</label> closeOnEscape
<input bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
type="password" footer={(
value={oldToken} <>
onChange={e => { setOldToken(e.target.value); setError(''); }} <button type="button" onClick={onClose} className="btn btn-ghost">取消</button>
placeholder="输入当前 Token" <button type="button" onClick={handleSubmit} disabled={saving} className="btn btn-primary">
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>新 Token</label>
<input
type="password"
value={newToken}
onChange={e => { setNewToken(e.target.value); setError(''); }}
placeholder="输入新 Token (至少 6 位)"
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>确认新 Token</label>
<input
type="password"
value={confirmToken}
onChange={e => { setConfirmToken(e.target.value); setError(''); }}
placeholder="再次输入新 Token"
style={inputStyle}
/>
</div>
{error && (
<div className="alert alert-error">
{error}
</div>
)}
</div>
<div className="modal-footer">
<button onClick={onClose} className="btn btn-ghost">取消</button>
<button onClick={handleSubmit} disabled={saving} className="btn btn-primary">
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />更新中...</> : '确认修改'} {saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />更新中...</> : '确认修改'}
</button> </button>
</div> </>
)}
>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>旧 Token</label>
<input
type="password"
value={oldToken}
onChange={e => { setOldToken(e.target.value); setError(''); }}
placeholder="输入当前 Token"
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>新 Token</label>
<input
type="password"
value={newToken}
onChange={e => { setNewToken(e.target.value); setError(''); }}
placeholder="输入新 Token (至少 6 位)"
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>确认新 Token</label>
<input
type="password"
value={confirmToken}
onChange={e => { setConfirmToken(e.target.value); setError(''); }}
placeholder="再次输入新 Token"
style={inputStyle}
/>
</div> </div>
</div>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal; {error && (
<div className="alert alert-error">
{error}
</div>
)}
</CenteredModal>
);
} }
+3 -2
View File
@@ -28,11 +28,12 @@ export default function DeleteConfirmModal({
onClose={onClose} onClose={onClose}
title={title} title={title}
maxWidth={560} maxWidth={560}
closeOnEscape
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }} bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={( footer={(
<> <>
<button onClick={onClose} className="btn btn-ghost" disabled={loading}>{cancelText}</button> <button type="button" onClick={onClose} className="btn btn-ghost" disabled={loading}>{cancelText}</button>
<button onClick={onConfirm} className="btn btn-danger" disabled={loading}> <button type="button" onClick={onConfirm} className="btn btn-danger" disabled={loading}>
{loading {loading
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 删除中...</> ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 删除中...</>
: confirmText} : 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) { export default function MobileBatchBar({ info, children }: MobileBatchBarProps) {
return ( return (
<div className="mobile-actions-bar mobile-batch-bar"> <div
<span className="mobile-actions-info">{info}</span> 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 className="mobile-actions-row">{children}</div>
</div> </div>
); );

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